diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 15:40:28 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 15:40:28 +0000 |
commit | b595cb0c1dec83de5bdee18284abe86614bed33b (patch) | |
tree | 8c3d4540f193c5ff98019352f554e921b3a41a72 /app | |
parent | 2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff) | |
download | gitlab-ce-b595cb0c1dec83de5bdee18284abe86614bed33b.tar.gz |
Add latest changes from gitlab-org/gitlab@15-2-stable-eev15.2.0-rc42
Diffstat (limited to 'app')
1258 files changed, 15831 insertions, 9280 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue index 944a2ef7f64..59f0e0dd17d 100644 --- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -138,10 +138,9 @@ export default { }}</span> </template> - <template #cell(action)="{ item: { revokePath, expiresAt } }"> + <template #cell(action)="{ item: { revokePath } }"> <gl-button - variant="danger" - :category="expiresAt ? 'primary' : 'secondary'" + category="tertiary" :aria-label="$options.i18n.revokeButton" :data-confirm="modalMessage" data-confirm-btn-variant="danger" diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue index 904052688f3..e111ae91e5c 100644 --- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue +++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue @@ -117,7 +117,7 @@ export default { <template v-if="errors"> <gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null"> - <ul class="m-0"> + <ul class="gl-m-0"> <li v-for="error in errors" :key="error"> {{ error }} </li> diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue index 78a575ffe96..a58b6e62254 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue @@ -37,11 +37,11 @@ export default { <gl-button :class="[ { - 'ml-3': !contextCommitsEmpty, - 'mt-3': !commitsEmpty && contextCommitsEmpty, + 'gl-ml-5': !contextCommitsEmpty, + 'gl-mt-5': !commitsEmpty && contextCommitsEmpty, }, ]" - :variant="commitsEmpty ? 'info' : 'default'" + :variant="commitsEmpty ? 'confirm' : 'default'" @click="openModal" > {{ buttonText }} diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue index 96584080d0f..8ad218ab97b 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -244,7 +244,7 @@ export default { </template> </gl-sprintf> </template> - <div class="mt-2"> + <div class="gl-mt-3"> <gl-search-box-by-type ref="searchInput" :placeholder="__(`Search by commit title or SHA`)" diff --git a/app/assets/javascripts/add_context_commits_modal/index.js b/app/assets/javascripts/add_context_commits_modal/index.js index 697d32664e8..110677781a7 100644 --- a/app/assets/javascripts/add_context_commits_modal/index.js +++ b/app/assets/javascripts/add_context_commits_modal/index.js @@ -8,7 +8,7 @@ export default function initAddContextCommitsTriggers() { const addContextCommitsModalTriggerEl = document.querySelector('.add-review-item-modal-trigger'); const addContextCommitsModalWrapperEl = document.querySelector('.add-review-item-modal-wrapper'); - if (addContextCommitsModalTriggerEl || addContextCommitsModalWrapperEl) { + if (addContextCommitsModalTriggerEl) { // eslint-disable-next-line no-new new Vue({ el: addContextCommitsModalTriggerEl, @@ -28,7 +28,9 @@ export default function initAddContextCommitsTriggers() { }); }, }); + } + if (addContextCommitsModalWrapperEl) { const store = createStore(); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue index 29e8b9a724e..46e7ac3cf28 100644 --- a/app/assets/javascripts/admin/deploy_keys/components/table.vue +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -35,8 +35,12 @@ export default { label: __('Title'), }, { + key: 'fingerprint_sha256', + label: __('Fingerprint (SHA256)'), + }, + { key: 'fingerprint', - label: __('Fingerprint'), + label: __('Fingerprint (MD5)'), }, { key: 'projects', @@ -130,10 +134,18 @@ export default { } this.items = items.map( - ({ id, title, fingerprint, projects_with_write_access, created_at }) => ({ + ({ id, title, fingerprint, + fingerprint_sha256, + projects_with_write_access, + created_at, + }) => ({ + id, + title, + fingerprint, + fingerprint_sha256, projects: projects_with_write_access, created: created_at, }), @@ -196,8 +208,12 @@ export default { > </template> + <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }"> + <span v-if="fingerprint_sha256" class="monospace">{{ fingerprint_sha256 }}</span> + </template> + <template #cell(fingerprint)="{ item: { fingerprint } }"> - <code>{{ fingerprint }}</code> + <span v-if="fingerprint" class="monospace">{{ fingerprint }}</span> </template> <template #cell(created)="{ item: { created } }"> diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue index f250bdae4f5..347d5f0229c 100644 --- a/app/assets/javascripts/admin/statistics_panel/components/app.vue +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlLoadingIcon } from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import statisticsLabels from '../constants'; export default { components: { + GlCard, GlLoadingIcon, }, data() { @@ -26,20 +27,14 @@ export default { </script> <template> - <div class="gl-card"> - <div class="gl-card-body"> - <h4>{{ __('Statistics') }}</h4> - <gl-loading-icon v-if="isLoading" size="lg" class="my-3" /> - <template v-else> - <p - v-for="statistic in getStatistics(statisticsLabels)" - :key="statistic.key" - class="js-stats" - > - {{ statistic.label }} - <span class="light float-right">{{ statistic.value }}</span> - </p> - </template> - </div> - </div> + <gl-card> + <h4>{{ __('Statistics') }}</h4> + <gl-loading-icon v-if="isLoading" size="lg" class="my-3" /> + <template v-else> + <p v-for="statistic in getStatistics(statisticsLabels)" :key="statistic.key" class="js-stats"> + {{ statistic.label }} + <span class="light float-right">{{ statistic.value }}</span> + </p> + </template> + </gl-card> </template> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index 40e5f8d9d70..691a292673c 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -94,13 +94,13 @@ export default { :data-testid="`user-actions-${user.id}`" > <div v-if="hasEditAction" class="gl-p-2"> - <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs">{{ + <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{ $options.i18n.edit }}</gl-button> <gl-button v-else v-gl-tooltip="$options.i18n.edit" - icon="pencil" + icon="pencil-square" v-bind="editButtonAttrs" :aria-label="$options.i18n.edit" /> @@ -108,18 +108,12 @@ export default { <div v-if="hasDropdownActions" class="gl-p-2"> <gl-dropdown - v-gl-tooltip="$options.i18n.userAdministration" + :text="$options.i18n.userAdministration" data-testid="dropdown-toggle" - icon="ellipsis_v" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" - no-caret - right + left > - <gl-dropdown-section-header>{{ - $options.i18n.userAdministration - }}</gl-dropdown-section-header> - <template v-for="action in dropdownSafeActions"> <component :is="getActionComponent(action)" diff --git a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue index 5a394059931..fd966425920 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue @@ -40,7 +40,7 @@ export default { return this.devopsScoreMetrics.averageScore === undefined; }, }, - devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_report'), + devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_reports'), tableHeaderFields: [ { key: 'title', diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index 38d05552783..e1bc59b36ef 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -16,60 +16,32 @@ export const dateFormats = { // Some content is duplicated due to backward compatibility. // It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9 export const METRICS_POPOVER_CONTENT = { - 'lead-time': { - description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), - }, lead_time: { description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), }, - 'cycle-time': { - description: s__( - "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", - ), - }, cycle_time: { description: s__( "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", ), }, - 'lead-time-for-changes': { - description: s__( - 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.', - ), - }, lead_time_for_changes: { description: s__( 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.', ), }, issues: { description: s__('ValueStreamAnalytics|Number of new issues created.') }, - 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, - 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') }, - 'deployment-frequency': { - description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'), - }, deployment_frequency: { description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'), }, commits: { description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'), }, - 'time-to-restore-service': { - description: s__( - 'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.', - ), - }, time_to_restore_service: { description: s__( 'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.', ), }, - 'change-failure-rate': { - description: s__( - 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.', - ), - }, change_failure_rate: { description: s__( 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.', diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 2b1ab911fbe..300a81caa5c 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableNote from '~/notes/components/noteable_note.vue'; import PublishButton from './publish_button.vue'; @@ -14,6 +15,7 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, + mixins: [glFeatureFlagMixin()], props: { draft: { type: Object, @@ -92,6 +94,7 @@ export default { :note="draft" :line="line" :discussion-root="true" + :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" class="draft-note" @handleEdit="handleEditing" @cancelForm="handleNotEditing" @@ -113,7 +116,11 @@ export default { class="referenced-commands draft-note-commands" ></div> - <p class="draft-note-actions d-flex" data-qa-selector="draft_note_content"> + <p + v-if="!glFeatures.mrReviewSubmitComment" + class="draft-note-actions d-flex" + data-qa-selector="draft_note_content" + > <publish-button :show-count="true" :should-publish="false" diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index f839056daf8..ba5cc0d1a76 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import PreviewItem from './preview_item.vue'; import DraftsCount from './drafts_count.vue'; @@ -17,6 +18,7 @@ export default { computed: { ...mapState('diffs', ['viewDiffsFileByFile']), ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), + ...mapGetters(['getNoteableData']), }, methods: { ...mapActions('diffs', ['setCurrentFileHash']), @@ -24,12 +26,21 @@ export default { isLast(index) { return index === this.sortedDrafts.length - 1; }, + isOnLatestDiff(draft) { + return draft.position?.head_sha === this.getNoteableData.diff_head_sha; + }, async onClickDraft(draft) { if (this.viewDiffsFileByFile && draft.file_hash) { await this.setCurrentFileHash(draft.file_hash); } - await this.scrollToDraft(draft); + if (draft.position && !this.isOnLatestDiff(draft)) { + const url = new URL(setUrlParams({ commit_id: draft.position.head_sha })); + url.hash = `note_${draft.id}`; + visitUrl(url.toString()); + } else { + await this.scrollToDraft(draft); + } }, }, }; diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index 5f4a1e44ea3..b070848cae9 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -22,6 +22,18 @@ export default { computed: { ...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']), }, + mounted() { + // 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.dropdown.$refs.dropdown.clickOutHandler; + this.$refs.dropdown.$refs.dropdown.clickOutHandler = (e) => { + if (!e.target.closest('.atwho-container')) { + originalClickOutHandler(e); + } + }; + }, methods: { ...mapActions('batchComments', ['publishReview']), async submitReview() { @@ -52,7 +64,13 @@ export default { </script> <template> - <gl-dropdown right class="submit-review-dropdown" variant="info" category="secondary"> + <gl-dropdown + ref="dropdown" + right + class="submit-review-dropdown" + variant="info" + category="secondary" + > <template #button-content> {{ __('Finish review') }} <gl-icon class="dropdown-chevron" name="chevron-up" /> 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 908cbfd6dc8..a44b9827fe9 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 @@ -138,6 +138,12 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { window.mrTabs.tabShown(tab); } + const { file_path: filePath } = draft; + + if (filePath) { + dispatch('diffs/setFileCollapsedAutomatically', { filePath, collapsed: false }, { root: true }); + } + if (discussion) { dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true }); } diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js index fd064e7ca8f..d4efe409fef 100644 --- a/app/assets/javascripts/blob/3d_viewer/index.js +++ b/app/assets/javascripts/blob/3d_viewer/index.js @@ -98,9 +98,9 @@ export default class Renderer { requestAnimationFrame(this.renderWrapper); } - changeObjectMaterials(type) { + changeObjectMaterials(material) { this.objects.forEach((obj) => { - obj.changeMaterial(type); + obj.changeMaterial(material); }); } diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js index cb7fcff8674..c55a9ca8926 100644 --- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js +++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js @@ -30,7 +30,7 @@ export default class MeshObject extends Mesh { } } - changeMaterial(type) { - this.material = materials[type]; + changeMaterial(materialKey) { + this.material = materials[materialKey]; } } diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js index 0ea623a705a..768bbce9c57 100644 --- a/app/assets/javascripts/blob/stl_viewer.js +++ b/app/assets/javascripts/blob/stl_viewer.js @@ -5,15 +5,15 @@ export default () => { [].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => { el.addEventListener('click', (e) => { - const { target } = e; + const { currentTarget } = e; e.preventDefault(); document.querySelector('.js-material-changer.selected').classList.remove('selected'); - target.classList.add('selected'); - target.blur(); + currentTarget.classList.add('selected'); + currentTarget.blur(); - viewer.changeObjectMaterials(target.dataset.type); + viewer.changeObjectMaterials(currentTarget.dataset.material); }); }); }; diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index dc821cb9f58..3638fdd2ca5 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -48,6 +48,15 @@ export default { isDraggable() { return !this.disabled && this.item.id && !this.item.isLoading; }, + cardStyle() { + return this.isColorful && this.item.color ? { borderColor: this.item.color } : ''; + }, + isColorful() { + return gon?.features?.epicColorHighlight; + }, + colorClass() { + return this.isColorful ? 'gl-pl-4 gl-border-l-solid gl-border-4' : ''; + }, }, methods: { ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), @@ -70,17 +79,21 @@ export default { <template> <li data-qa-selector="board_card" - :class="{ - 'multi-select': multiSelectVisible, - 'gl-cursor-grab': isDraggable, - 'is-disabled': isDisabled, - 'is-active': isActive, - 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading, - }" + :class="[ + { + 'multi-select': multiSelectVisible, + 'gl-cursor-grab': isDraggable, + 'is-disabled': isDisabled, + 'is-active': isActive, + 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading, + }, + colorClass, + ]" :index="index" :data-item-id="item.id" :data-item-iid="item.iid" :data-item-path="item.referencePath" + :style="cardStyle" data-testid="board_card" class="board-card gl-p-5 gl-rounded-base" @click="toggleIssue($event)" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 98ce1ac7f97..a632f5ae0ed 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -243,6 +243,7 @@ export default { :description="label.description" size="sm" :scoped="showScopedLabel(label)" + target="#" @click="filterByLabel(label)" /> </template> @@ -253,7 +254,7 @@ export default { <div class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden" > - <gl-loading-icon v-if="item.isLoading" size="lg" class="mt-3" /> + <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> <span v-if="item.referencePath" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 71612e0742f..990a6fa63d4 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -20,7 +20,7 @@ export default { hide(this.$refs.toggleFocusModeButton); const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); - issueBoardsContent.classList.toggle('is-focused'); + issueBoardsContent?.classList.toggle('is-focused'); this.isFullscreen = !this.isFullscreen; }, 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 ebcc4b85ac4..9d8cb40b60a 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 @@ -3,7 +3,6 @@ import { GlAlert, GlButton, GlIcon, - GlLink, GlLoadingIcon, GlModal, GlModalDirective, @@ -14,9 +13,9 @@ import { } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Api, { DEFAULT_PER_PAGE } from '~/api'; -import { helpPagePath } from '~/helpers/help_page_helper'; import httpStatusCodes from '~/lib/utils/http_status'; import { __, s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { @@ -24,7 +23,6 @@ export default { GlAlert, GlButton, GlIcon, - GlLink, GlLoadingIcon, GlModal, GlPagination, @@ -36,22 +34,18 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, + mixins: [Tracking.mixin()], inject: ['projectId', 'admin', 'fileSizeLimit'], - docsLink: helpPagePath('ci/secure_files/index'), DEFAULT_PER_PAGE, i18n: { deleteLabel: __('Delete File'), uploadLabel: __('Upload File'), uploadingLabel: __('Uploading...'), + noFilesMessage: __('There are no secure files yet.'), pagination: { next: __('Next'), prev: __('Prev'), }, - title: __('Secure Files'), - overviewMessage: __( - 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.', - ), - moreInformation: __('More information'), uploadErrorMessages: { duplicate: __('A file with this name already exists.'), tooLarge: __('File too large. Secure Files must be less than %{limit} MB.'), @@ -79,12 +73,12 @@ export default { fields: [ { key: 'name', - label: __('Filename'), + label: __('File name'), tdClass: 'gl-vertical-align-middle!', }, { key: 'created_at', - label: __('Uploaded'), + label: __('Uploaded date'), tdClass: 'gl-vertical-align-middle!', }, { @@ -113,6 +107,8 @@ export default { try { await Api.deleteProjectSecureFile(this.projectId, secureFileId); this.getProjectSecureFiles(); + + this.track('delete_secure_file'); } catch (error) { Sentry.captureException(error); this.error = true; @@ -129,6 +125,7 @@ export default { this.loading = false; this.uploading = false; + this.track('render_secure_files_list'); }, async uploadSecureFile() { this.error = null; @@ -137,6 +134,7 @@ export default { try { await Api.uploadProjectSecureFile(this.projectId, this.uploadFormData(file)); this.getProjectSecureFiles(); + this.track('upload_secure_file'); } catch (error) { this.error = true; this.errorMessage = this.formattedErrorMessage(error); @@ -157,7 +155,7 @@ export default { } return message; }, - loadFileSelctor() { + loadFileSelector() { this.$refs.fileUpload.click(); }, setDeleteModalData(secureFile) { @@ -177,91 +175,74 @@ export default { <template> <div> - <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null"> - {{ errorMessage }} - </gl-alert> - <div class="row"> - <div class="col-md-12 col-lg-6 gl-display-flex"> - <div class="gl-flex-direction-column gl-flex-wrap"> - <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-0"> - {{ $options.i18n.title }} - </h1> - </div> - </div> + <div class="ci-secure-files-table"> + <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null"> + {{ errorMessage }} + </gl-alert> + + <gl-table + :busy="loading" + :fields="fields" + :items="projectSecureFiles" + tbody-tr-class="js-ci-secure-files-row" + data-qa-selector="ci_secure_files_table_content" + sort-by="key" + sort-direction="asc" + stacked="lg" + table-class="text-secondary" + show-empty + sort-icon-left + no-sort-reset + :empty-text="$options.i18n.noFilesMessage" + > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> + + <template #cell(name)="{ item }"> + {{ item.name }} + </template> - <div class="col-md-12 col-lg-6"> - <div class="gl-display-flex gl-flex-wrap gl-justify-content-end"> - <gl-button v-if="admin" class="gl-mt-3" variant="confirm" @click="loadFileSelctor"> - <span v-if="uploading"> - <gl-loading-icon size="sm" class="gl-my-5" inline /> - {{ $options.i18n.uploadingLabel }} - </span> - <span v-else> - <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }} - </span> - </gl-button> - <input - id="file-upload" - ref="fileUpload" - type="file" - class="hidden" - data-qa-selector="file_upload_field" - @change="uploadSecureFile" + <template #cell(created_at)="{ item }"> + <timeago-tooltip :time="item.created_at" /> + </template> + + <template #cell(actions)="{ item }"> + <gl-button + v-if="admin" + v-gl-modal="$options.deleteModalId" + v-gl-tooltip.hover.top="$options.i18n.deleteLabel" + category="secondary" + variant="danger" + icon="remove" + :aria-label="$options.i18n.deleteLabel" + data-testid="delete-button" + @click="setDeleteModalData(item)" /> - </div> - </div> + </template> + </gl-table> </div> - <div class="row"> - <div class="col-md-12 col-lg-12 gl-my-4"> - <span data-testid="info-message"> - {{ $options.i18n.overviewMessage }} - <gl-link :href="$options.docsLink" target="_blank">{{ - $options.i18n.moreInformation - }}</gl-link> + <div class="gl-display-flex gl-mt-5"> + <gl-button v-if="admin" variant="confirm" @click="loadFileSelector"> + <span v-if="uploading"> + <gl-loading-icon class="gl-my-5" inline /> + {{ $options.i18n.uploadingLabel }} + </span> + <span v-else> + <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }} </span> - </div> + </gl-button> + <input + id="file-upload" + ref="fileUpload" + type="file" + class="hidden" + data-qa-selector="file_upload_field" + @change="uploadSecureFile" + /> </div> - <gl-table - :busy="loading" - :fields="fields" - :items="projectSecureFiles" - tbody-tr-class="js-ci-secure-files-row" - data-qa-selector="ci_secure_files_table_content" - sort-by="key" - sort-direction="asc" - stacked="lg" - table-class="text-secondary" - show-empty - sort-icon-left - no-sort-reset - > - <template #table-busy> - <gl-loading-icon size="lg" class="gl-my-5" /> - </template> - - <template #cell(name)="{ item }"> - {{ item.name }} - </template> - - <template #cell(created_at)="{ item }"> - <timeago-tooltip :time="item.created_at" /> - </template> - - <template #cell(actions)="{ item }"> - <gl-button - v-if="admin" - v-gl-modal="$options.deleteModalId" - v-gl-tooltip.hover.top="$options.i18n.deleteLabel" - variant="danger" - icon="remove" - :aria-label="$options.i18n.deleteLabel" - @click="setDeleteModalData(item)" - /> - </template> - </gl-table> - <gl-pagination v-if="!loading" v-model="page" diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 496baf8cb08..e0e3b961c51 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -58,7 +58,7 @@ export default { }, computed: { fields() { - const tdClass = 'gl-py-5!'; + const tdClass = 'gl-pt-3! gl-pb-4! gl-vertical-align-middle!'; return [ { key: 'name', @@ -184,7 +184,7 @@ export default { data-testid="cluster-agent-connection-status" > <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> - <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span + <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span >{{ $options.AGENT_STATUSES[item.status].name }} </span> <gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right"> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 10e71513065..7bc8a1a7304 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -145,8 +145,8 @@ export const AGENT_STATUSES = { }, inactive: { name: s__('ClusterAgents|Not connected'), - icon: 'severity-critical', - class: 'text-danger-800', + icon: 'status-alert', + class: 'text-danger-500', tooltip: { title: s__('ClusterAgents|Agent might not be connected to GitLab'), body: sprintf( @@ -159,7 +159,7 @@ export const AGENT_STATUSES = { unused: { name: s__('ClusterAgents|Never connected'), icon: 'status-neutral', - class: 'text-secondary-400', + class: 'text-secondary-500', tooltip: { title: s__('ClusterAgents|Agent never connected to GitLab'), body: s__('ClusterAgents|Make sure you are using a valid token.'), diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue index e35fbf14de5..f0726ff3e63 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue @@ -91,6 +91,26 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-button + data-testid="superscript" + content-type="superscript" + icon-name="superscript" + editor-command="toggleSuperscript" + category="tertiary" + size="medium" + :label="__('Superscript')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="subscript" + content-type="subscript" + icon-name="subscript" + editor-command="toggleSubscript" + category="tertiary" + size="medium" + :label="__('Subscript')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button data-testid="link" content-type="link" icon-name="link" diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue index cba3b627390..5dcff1f6295 100644 --- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue +++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue @@ -19,7 +19,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue index 02de6470cf2..252f69f7a5d 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -58,7 +58,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index ecde593147c..6e4cde5dad6 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -10,9 +10,31 @@ export default { GlTooltip, }, inject: ['tiptapEditor'], + data() { + return { + isActive: {}, + }; + }, methods: { - execute(contentType, attrs) { - this.tiptapEditor.chain().focus().setNode(contentType, attrs).run(); + insert(contentType, ...args) { + this.tiptapEditor + .chain() + .focus() + .setNode(contentType, ...args) + .run(); + + this.$emit('execute', { contentType }); + }, + + insertList(listType, listItemType) { + if (!this.tiptapEditor.isActive(listType)) + this.tiptapEditor.chain().focus().toggleList(listType, listItemType).run(); + + this.$emit('execute', { contentType: listType }); + }, + + execute(command, contentType) { + this.tiptapEditor.chain().focus()[command]().run(); this.$emit('execute', { contentType }); }, @@ -20,15 +42,30 @@ export default { }; </script> <template> - <gl-dropdown size="small" category="tertiary" icon="plus"> - <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })"> - {{ __('Mermaid diagram') }} + <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right> + <gl-dropdown-item @click="insert('codeBlock')"> + {{ __('Code block') }} </gl-dropdown-item> - <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })"> - {{ __('PlantUML diagram') }} + <gl-dropdown-item @click="insertList('details', 'detailsContent')"> + {{ __('Details block') }} + </gl-dropdown-item> + <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')"> + {{ __('Bullet list') }} + </gl-dropdown-item> + <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')"> + {{ __('Ordered list') }} + </gl-dropdown-item> + <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')"> + {{ __('Task list') }} </gl-dropdown-item> - <gl-dropdown-item @click="execute('horizontalRule')"> + <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')"> {{ __('Horizontal rule') }} </gl-dropdown-item> + <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })"> + {{ __('Mermaid diagram') }} + </gl-dropdown-item> + <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })"> + {{ __('PlantUML diagram') }} + </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index 46db806da94..18928acef3c 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -62,7 +62,7 @@ export default { }; </script> <template> - <gl-dropdown size="small" category="tertiary" icon="table" class="table-dropdown"> + <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right> <gl-dropdown-form class="gl-px-3!"> <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> <gl-button diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index b652e634b0c..65d71814268 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -51,12 +51,12 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-button - data-testid="strike" - content-type="strike" - icon-name="strikethrough" + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" class="gl-mx-2" - editor-command="toggleStrike" - :label="__('Strikethrough')" + editor-command="toggleBlockquote" + :label="__('Insert a quote')" @execute="trackToolbarControlExecution" /> <toolbar-button @@ -69,34 +69,11 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" /> - <toolbar-image-button - ref="imageButton" - data-testid="image" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="blockquote" - content-type="blockquote" - icon-name="quote" - class="gl-mx-2" - editor-command="toggleBlockquote" - :label="__('Insert a quote')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="code-block" - content-type="codeBlock" - icon-name="doc-code" - class="gl-mx-2" - editor-command="toggleCodeBlock" - :label="__('Insert a code block')" - @execute="trackToolbarControlExecution" - /> <toolbar-button data-testid="bullet-list" content-type="bulletList" icon-name="list-bulleted" - class="gl-mx-2" + class="gl-mx-2 gl-display-none gl-sm-display-inline" editor-command="toggleBulletList" :label="__('Add a bullet list')" @execute="trackToolbarControlExecution" @@ -105,18 +82,23 @@ export default { data-testid="ordered-list" content-type="orderedList" icon-name="list-numbered" - class="gl-mx-2" + class="gl-mx-2 gl-display-none gl-sm-display-inline" editor-command="toggleOrderedList" :label="__('Add a numbered list')" @execute="trackToolbarControlExecution" /> <toolbar-button - data-testid="details" - content-type="details" - icon-name="details-block" - class="gl-mx-2" - editor-command="toggleDetails" - :label="__('Add a collapsible section')" + data-testid="task-list" + content-type="taskList" + icon-name="list-task" + class="gl-mx-2 gl-display-none gl-sm-display-inline" + editor-command="toggleTaskList" + :label="__('Add a task list')" + @execute="trackToolbarControlExecution" + /> + <toolbar-image-button + ref="imageButton" + data-testid="image" @execute="trackToolbarControlExecution" /> <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> 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 61f6a233694..edf8b3d3a0b 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -42,11 +42,14 @@ export default CodeBlockLowlight.extend({ }, parseHTML() { return [ - ...(this.parent?.() || []), { tag: 'div.markdown-code-block', skip: true, }, + { + tag: 'pre.js-syntax-highlight', + preserveWhitespace: 'full', + }, ]; }, renderHTML({ HTMLAttributes }) { diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js deleted file mode 100644 index 566ed85acf3..00000000000 --- a/app/assets/javascripts/content_editor/extensions/division.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Node } from '@tiptap/core'; -import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; - -const getDiv = (element) => { - if (element.nodeName === 'DIV') return element; - return element.querySelector('div'); -}; - -export default Node.create({ - name: 'division', - content: 'block*', - group: 'block', - defining: true, - - addAttributes() { - return { - className: { - default: null, - parseHTML: (element) => getDiv(element).className || null, - }, - }; - }, - - parseHTML() { - return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }]; - }, - - renderHTML({ HTMLAttributes }) { - return ['div', HTMLAttributes, 0]; - }, -}); diff --git a/app/assets/javascripts/content_editor/extensions/html_nodes.js b/app/assets/javascripts/content_editor/extensions/html_nodes.js new file mode 100644 index 00000000000..23409354814 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/html_nodes.js @@ -0,0 +1,25 @@ +import { Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; + +const tags = ['div', 'pre']; + +const createHtmlNodeExtension = (tagName) => + Node.create({ + name: tagName, + content: 'block*', + group: 'block', + defining: true, + addOptions() { + return { + tagName, + }; + }, + parseHTML() { + return [{ tag: tagName, priority: PARSE_HTML_PRIORITY_LOWEST }]; + }, + renderHTML({ HTMLAttributes }) { + return [tagName, HTMLAttributes, 0]; + }, + }); + +export default tags.map(createHtmlNodeExtension); diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index 87118074462..618f17b1c5e 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -9,6 +9,7 @@ import FootnoteDefinition from './footnote_definition'; import Heading from './heading'; import HardBreak from './hard_break'; import HorizontalRule from './horizontal_rule'; +import HTMLNodes from './html_nodes'; import Image from './image'; import Italic from './italic'; import Link from './link'; @@ -51,13 +52,22 @@ export default Extension.create({ TableCell.name, TableHeader.name, TableRow.name, + ...HTMLNodes.map((htmlNode) => htmlNode.name), ], attributes: { + /** + * The reason to add a function that returns an empty + * string in these attributes is indicate that these + * attributes shouldn’t be rendered in the ProseMirror + * view. + */ sourceMarkdown: { default: null, + renderHTML: () => '', }, sourceMapKey: { default: null, + renderHTML: () => '', }, }, }, diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 06757e7a280..867bf0b4d55 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -39,12 +39,12 @@ export class ContentEditor { this._eventHub.dispose(); } - deserialize(serializedContent) { + deserialize(markdown) { const { _tiptapEditor: editor, _deserializer: deserializer } = this; return deserializer.deserialize({ schema: editor.schema, - content: serializedContent, + markdown, }); } diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 15aac3d86e5..c5cfa9a4285 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -16,7 +16,6 @@ import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; import Diagram from '../extensions/diagram'; -import Division from '../extensions/division'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; @@ -32,6 +31,7 @@ import Heading from '../extensions/heading'; import History from '../extensions/history'; import HorizontalRule from '../extensions/horizontal_rule'; import HTMLMarks from '../extensions/html_marks'; +import HTMLNodes from '../extensions/html_nodes'; import Image from '../extensions/image'; import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; @@ -103,7 +103,6 @@ export const createContentEditor = ({ DetailsContent, Document, Diagram, - Division, Dropcursor, Emoji, Figure, @@ -118,6 +117,7 @@ export const createContentEditor = ({ History, HorizontalRule, ...HTMLMarks, + ...HTMLNodes, Image, InlineDiff, Italic, diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js index dcd56e55268..fa46bd9ff81 100644 --- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js @@ -16,8 +16,8 @@ export default ({ render }) => { * document. The dom property contains the HTML generated from the Markdown Source. */ return { - deserialize: async ({ schema, content }) => { - const html = await render(content); + deserialize: async ({ schema, markdown }) => { + const html = await render(markdown); if (!html) return {}; @@ -25,7 +25,7 @@ export default ({ render }) => { const { body } = parser.parseFromString(html, 'text/html'); // append original source as a comment that nodes can access - body.append(document.createComment(content)); + body.append(document.createComment(markdown)); return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; }, diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js index 2c462cdde91..312ab88de4a 100644 --- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -21,9 +21,10 @@ import { Mark } from 'prosemirror-model'; import { visitParents, SKIP } from 'unist-util-visit-parents'; -import { toString } from 'hast-util-to-string'; import { isFunction, isString, noop } from 'lodash'; +const NO_ATTRIBUTES = {}; + /** * Merges two ProseMirror text nodes if both text nodes * have the same set of marks. @@ -51,7 +52,7 @@ function maybeMerge(a, b) { * Hast node documentation: https://github.com/syntax-tree/hast * * @param {HastNode} hastNode A Hast node - * @param {String} source Markdown source file + * @param {String} markdown Markdown source file * * @returns It returns an object with the following attributes: * @@ -60,13 +61,13 @@ function maybeMerge(a, b) { * - sourceMarkdown: A node’s original Markdown source extrated * from the Markdown source file. */ -function createSourceMapAttributes(hastNode, source) { +function createSourceMapAttributes(hastNode, markdown) { const { position } = hastNode; return position && position.end ? { sourceMapKey: `${position.start.offset}:${position.end.offset}`, - sourceMarkdown: source.substring(position.start.offset, position.end.offset), + sourceMarkdown: markdown.substring(position.start.offset, position.end.offset), } : {}; } @@ -82,16 +83,16 @@ function createSourceMapAttributes(hastNode, source) { * @param {*} proseMirrorNodeSpec ProseMirror node spec object * @param {HastNode} hastNode A hast node * @param {Array<HastNode>} hastParents All the ancestors of the hastNode - * @param {String} source Markdown source file’s content + * @param {String} markdown Markdown source file’s content * * @returns An object that contains a ProseMirror node’s attributes */ -function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) { +function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) { const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; return { - ...createSourceMapAttributes(hastNode, source), - ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}), + ...createSourceMapAttributes(hastNode, markdown), + ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}), }; } @@ -136,6 +137,10 @@ class HastToProseMirrorConverterState { return this.stack[this.stack.length - 1]; } + get topNode() { + return this.findInStack((item) => item.type === 'node'); + } + /** * Detects if the node stack is empty */ @@ -177,7 +182,7 @@ class HastToProseMirrorConverterState { */ addText(schema, text) { if (!text) return; - const nodes = this.top.content; + const nodes = this.topNode?.content; const last = nodes[nodes.length - 1]; const node = schema.text(text, this.marks); const merged = maybeMerge(last, node); @@ -187,57 +192,92 @@ class HastToProseMirrorConverterState { } else { nodes.push(node); } - - this.closeMarks(); } /** * Adds a mark to the set of marks stored temporarily - * until addText is called. - * @param {*} markType - * @param {*} attrs + * until an inline node is created. + * @param {https://prosemirror.net/docs/ref/#model.MarkType} schemaType Mark schema type + * @param {https://github.com/syntax-tree/hast#nodes} hastNode AST node that the mark is based on + * @param {Object} attrs Mark attributes + * @param {Object} factorySpec Specifications on how th mark should be created */ - openMark(markType, attrs) { - this.marks = markType.create(attrs).addToSet(this.marks); + openMark(schemaType, hastNode, attrs, factorySpec) { + const mark = schemaType.create(attrs); + this.stack.push({ + type: 'mark', + mark, + attrs, + hastNode, + factorySpec, + }); + + this.marks = mark.addToSet(this.marks); } /** - * Empties the temporary Mark set. + * Removes a mark from the list of active marks that + * are applied to inline nodes. */ - closeMarks() { - this.marks = Mark.none; + closeMark() { + const { mark } = this.stack.pop(); + + this.marks = mark.removeFromSet(this.marks); } /** * Adds a node to the stack data structure. * - * @param {Schema.NodeType} type ProseMirror Schema for the node - * @param {HastNode} hastNode Hast node from which the ProseMirror node will be created + * @param {https://prosemirror.net/docs/ref/#model.NodeType} schemaType ProseMirror Schema for the node + * @param {https://github.com/syntax-tree/hast#nodes} hastNode Hast node from which the ProseMirror node will be created * @param {*} attrs Node’s attributes * @param {*} factorySpec The factory spec used to create the node factory */ - openNode(type, hastNode, attrs, factorySpec) { - this.stack.push({ type, attrs, content: [], hastNode, factorySpec }); + openNode(schemaType, hastNode, attrs, factorySpec) { + this.stack.push({ + type: 'node', + schemaType, + attrs, + content: [], + hastNode, + factorySpec, + }); } /** * Removes the top ProseMirror node from the * conversion stack and adds the node to the * previous element. - * @returns */ closeNode() { - const { type, attrs, content } = this.stack.pop(); - const node = type.createAndFill(attrs, content); - - if (!node) return null; - - if (this.marks.length) { - this.marks = Mark.none; + const { schemaType, attrs, content, factorySpec } = this.stack.pop(); + const node = + factorySpec.type === 'inline' && this.marks.length + ? schemaType.createAndFill(attrs, content, this.marks) + : schemaType.createAndFill(attrs, content); + + if (!node) { + /* + When the node returned by `createAndFill` is null is because the `content` passed as a parameter + doesn’t conform with the document schema. We are handling the most likely scenario here that happens + when a paragraph is inside another paragraph. + + This scenario happens when the converter encounters a mark wrapping one or more paragraphs. + In this case, the converter will wrap the mark in a paragraph as well because ProseMirror does + not allow marks wrapping block nodes or being direct children of certain nodes like the root nodes + or list items. + */ + if ( + schemaType.name === 'paragraph' && + content.some((child) => child.type.name === 'paragraph') + ) { + this.topNode.content.push(...content); + } + return null; } if (!this.empty) { - this.top.content.push(node); + this.topNode.content.push(node); } return node; @@ -245,9 +285,27 @@ class HastToProseMirrorConverterState { closeUntil(hastNode) { while (hastNode !== this.top?.hastNode) { - this.closeNode(); + if (this.top.type === 'node') { + this.closeNode(); + } else { + this.closeMark(); + } } } + + buildDoc() { + let doc; + + do { + if (this.top.type === 'node') { + doc = this.closeNode(); + } else { + this.closeMark(); + } + } while (!this.empty); + + return doc; + } } /** @@ -260,20 +318,21 @@ class HastToProseMirrorConverterState { * @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the * ProseMirror nodes and marks. * @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications. - * @param {String} source Markdown source file’s content + * @param {String} markdown Markdown source file’s content * * @returns An object that contains ProseMirror node factories */ -const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => { +const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => { const factories = { root: { selector: 'root', wrapInParagraph: true, - handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}), + handle: (state, hastNode) => + state.openNode(schema.topNodeType, hastNode, NO_ATTRIBUTES, factories.root), }, text: { selector: 'text', - handle: (state, hastNode) => { + handle: (state, hastNode, parent) => { const found = state.findInStack((node) => isFunction(node.factorySpec.processText)); const { value: text } = hastNode; @@ -281,17 +340,14 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) return; } + state.closeUntil(parent); state.addText(schema, found ? found.factorySpec.processText(text) : text); }, }, }; for (const [proseMirrorName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) { const factory = { - selector: factorySpec.selector, - skipChildren: factorySpec.skipChildren, - processText: factorySpec.processText, - parent: factorySpec.parent, - wrapInParagraph: factorySpec.wrapInParagraph, + ...factorySpec, }; if (factorySpec.type === 'block') { @@ -299,48 +355,22 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) const nodeType = schema.nodeType(proseMirrorName); state.closeUntil(parent); - state.openNode( - nodeType, - hastNode, - getAttrs(factorySpec, hastNode, parent, source), - factorySpec, - ); - - /** - * If a getContent function is provided, we immediately close - * the node to delegate content processing to this function. - * */ - if (isFunction(factorySpec.getContent)) { - state.addText( - schema, - factorySpec.getContent({ hastNode, hastNodeText: toString(hastNode) }), - ); - state.closeNode(); - } + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); }; - } else if (factorySpec.type === 'inline') { + } else if (factory.type === 'inline') { const nodeType = schema.nodeType(proseMirrorName); factory.handle = (state, hastNode, parent) => { state.closeUntil(parent); - state.openNode( - nodeType, - hastNode, - getAttrs(factorySpec, hastNode, parent, source), - factorySpec, - ); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); // Inline nodes do not have children therefore they are immediately closed state.closeNode(); }; - } else if (factorySpec.type === 'mark') { + } else if (factory.type === 'mark') { const markType = schema.marks[proseMirrorName]; factory.handle = (state, hastNode, parent) => { - state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source)); - - if (factorySpec.inlineContent) { - state.addText(schema, hastNode.value); - } + state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); }; - } else if (factorySpec.type === 'ignore') { + } else if (factory.type === 'ignore') { factory.handle = noop; } else { throw new RangeError( @@ -371,7 +401,7 @@ const findParent = (ancestors, parent) => { return ancestors[ancestors.length - 1]; }; -const calcTextNodePosition = (textNode) => { +const resolveNodePosition = (textNode) => { const { position, value, type } = textNode; if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) { @@ -414,11 +444,14 @@ const wrapInlineElements = (nodes, wrappableTags) => nodes.reduce((children, child) => { const previous = children[children.length - 1]; - if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) { + if ( + child.type === 'comment' || + (child.type !== 'text' && !wrappableTags.includes(child.tagName)) + ) { return [...children, child]; } - const wrapperExists = previous?.properties.wrapper; + const wrapperExists = previous?.properties?.wrapper; if (wrapperExists) { const wrapper = previous; @@ -432,7 +465,7 @@ const wrapInlineElements = (nodes, wrappableTags) => const wrapper = { type: 'element', tagName: 'p', - position: calcTextNodePosition(child), + position: resolveNodePosition(child), children: [child], properties: { wrapper: true }, }; @@ -528,19 +561,6 @@ const wrapInlineElements = (nodes, wrappableTags) => * it allows applying a processing function to that text. This is useful when * you can transform the text node, i.e trim(), substring(), etc. * - * **skipChildren** - * - * Skips a hast node’s children while traversing the tree. - * - * **getContent** - * - * Allows to pass a custom function that returns the content of a block node. The - * Content is limited to a single text node therefore the function should return - * a String value. - * - * Use this property along skipChildren to provide custom processing of child nodes - * for a block node. - * * **parent** * * Specifies what is the node’s parent. This is useful when the node’s parent is not @@ -561,20 +581,16 @@ export const createProseMirrorDocFromMdastTree = ({ factorySpecs, wrappableTags, tree, - source, + markdown, }) => { - const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source); + const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown); const state = new HastToProseMirrorConverterState(); visitParents(tree, (hastNode, ancestors) => { const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories); if (!factory) { - throw new Error( - `Hast node of type "${ - hastNode.tagName || hastNode.type - }" not supported by this converter. Please, provide an specification.`, - ); + return SKIP; } const parent = findParent(ancestors, factory.parent); @@ -595,14 +611,8 @@ export const createProseMirrorDocFromMdastTree = ({ factory.handle(state, hastNode, parent); - return factory.skipChildren === true ? SKIP : true; + return true; }); - let doc; - - do { - doc = state.closeNode(); - } while (!state.empty); - - return doc; + return state.buildDoc(); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 2d33a16f1a5..c1c7af6b1af 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -12,7 +12,6 @@ import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; -import Division from '../extensions/division'; import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; @@ -24,6 +23,7 @@ import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; import HorizontalRule from '../extensions/horizontal_rule'; import HTMLMarks from '../extensions/html_marks'; +import HTMLNodes from '../extensions/html_nodes'; import Image from '../extensions/image'; import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; @@ -123,16 +123,6 @@ const defaultSerializerConfig = { [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), [Diagram.name]: renderCodeBlock, - [Division.name]: (state, node) => { - if (node.attrs.className?.includes('js-markdown-code')) { - state.renderInline(node); - } else { - const newNode = node; - delete newNode.attrs.className; - - renderHTMLNode('div')(state, newNode); - } - }, [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); @@ -206,6 +196,12 @@ const defaultSerializerConfig = { [Text.name]: defaultMarkdownSerializer.nodes.text, [Video.name]: renderPlayable, [WordBreak.name]: (state) => state.write('<wbr>'), + ...HTMLNodes.reduce((serializers, htmlNode) => { + return { + ...serializers, + [htmlNode.name]: (state, node) => renderHTMLNode(htmlNode.options.tagName)(state, node), + }; + }, {}), }, }; diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index da10c684b0b..8e2c066e011 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -1,11 +1,10 @@ -import { isString } from 'lodash'; import { render } from '~/lib/gfm'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; const isTaskItem = (hastNode) => { - const { className } = hastNode.properties; + const className = hastNode.properties?.className; return ( hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item') @@ -23,16 +22,16 @@ const factorySpecs = { listItem: { type: 'block', wrapInParagraph: true, - selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className, + selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties?.className, processText: (text) => text.trimRight(), }, orderedList: { type: 'block', - selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className, + selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties?.className, }, bulletList: { type: 'block', - selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className, + selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties?.className, }, heading: { type: 'block', @@ -45,15 +44,8 @@ const factorySpecs = { }, codeBlock: { type: 'block', - skipChildren: true, - selector: 'pre', - getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''), - getAttrs: (hastNode) => { - const languageClass = hastNode.children[0]?.properties.className?.[0]; - const language = isString(languageClass) ? languageClass.replace('language-', '') : null; - - return { language }; - }, + selector: 'codeblock', + getAttrs: (hastNode) => ({ ...hastNode.properties }), }, horizontalRule: { type: 'block', @@ -62,7 +54,7 @@ const factorySpecs = { taskList: { type: 'block', selector: (hastNode) => { - const { className } = hastNode.properties; + const className = hastNode.properties?.className; return ( ['ul', 'ol'].includes(hastNode.tagName) && @@ -88,6 +80,11 @@ const factorySpecs = { selector: (hastNode, ancestors) => hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]), }, + div: { + type: 'block', + selector: 'div', + wrapInParagraph: true, + }, table: { type: 'block', selector: 'table', @@ -118,6 +115,11 @@ const factorySpecs = { selector: 'footnotedefinition', getAttrs: (hastNode) => hastNode.properties, }, + pre: { + type: 'block', + selector: 'pre', + wrapInParagraph: true, + }, image: { type: 'inline', selector: 'img', @@ -160,11 +162,19 @@ const factorySpecs = { type: 'mark', selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName), }, + /* TODO + * Implement proper editing support for HTML comments in the Content Editor + * https://gitlab.com/gitlab-org/gitlab/-/issues/342173 + */ + comment: { + type: 'ignore', + selector: (hastNode) => hastNode.type === 'comment', + }, }; export default () => { return { - deserialize: async ({ schema, content: markdown }) => { + deserialize: async ({ schema, markdown }) => { const document = await render({ markdown, renderer: (tree) => @@ -173,8 +183,9 @@ export default () => { factorySpecs, tree, wrappableTags, - source: markdown, + markdown, }), + skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'], }); return { document }; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 88f5192af77..7d5e718b41c 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -5,6 +5,8 @@ const defaultAttrs = { th: { colspan: 1, rowspan: 1, colwidth: null }, }; +const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey']; + const ignoreAttrs = { dd: ['isTerm'], dt: ['isTerm'], @@ -101,13 +103,17 @@ function htmlEncode(str = '') { .replace(/"/g, '"'); } +const shouldIgnoreAttr = (tagName, attrKey, attrValue) => + ignoreAttrs[tagName]?.includes(attrKey) || + defaultIgnoreAttrs.includes(attrKey) || + defaultAttrs[tagName]?.[attrKey] === attrValue; + export function openTag(tagName, attrs) { let str = `<${tagName}`; str += Object.entries(attrs || {}) .map(([key, value]) => { - if ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value) - return ''; + if (shouldIgnoreAttr(tagName, key, value)) return ''; return ` ${key}="${htmlEncode(value?.toString())}"`; }) diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 8a7d3430063..d811bb3b0bf 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -115,10 +115,20 @@ export default { <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> <div class="table-mobile-content" data-qa-selector="key_container"> <strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong> - <div class="fingerprint" data-qa-selector="key_md5_fingerprint_content"> - {{ __('MD5') }}:{{ deployKey.fingerprint }} - </div> - <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div> + <dl> + <dt>{{ __('SHA256') }}</dt> + <dd class="fingerprint" data-qa-selector="key_sha256_fingerprint_content"> + {{ deployKey.fingerprint_sha256 }} + </dd> + <template v-if="deployKey.fingerprint"> + <dt> + {{ __('MD5') }} + </dt> + <dd class="fingerprint" data-qa-selector="key_md5_fingerprint_content"> + {{ deployKey.fingerprint }} + </dd> + </template> + </dl> </div> </div> <div class="table-section section-30 section-wrap"> diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 73d872cf962..2ac62b9b927 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -1,4 +1,4 @@ -/* eslint-disable no-restricted-properties, camelcase, +/* eslint-disable camelcase, no-unused-expressions, default-case, consistent-return, no-param-reassign, no-shadow, no-useless-escape, @@ -10,7 +10,7 @@ class-methods-use-this */ deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app. */ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import Autosize from 'autosize'; import $ from 'jquery'; import { escape, uniqueId } from 'lodash'; @@ -357,7 +357,7 @@ export default class Notes { if (shouldReset == null) { shouldReset = true; } - const nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + const nthInterval = this.basePollingInterval * 2 ** (this.maxPollingSteps - 1); if (shouldReset) { this.pollingInterval = this.basePollingInterval; } else if (this.pollingInterval < nthInterval) { @@ -1233,10 +1233,10 @@ export default class Notes { new Vue({ el, components: { - GlSkeletonLoading, + GlSkeletonLoader, }, render(createElement) { - return createElement('gl-skeleton-loading'); + return createElement('gl-skeleton-loader'); }, }); } diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index 8a6dd17a25b..24cc93f5eaf 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -1,6 +1,6 @@ <script> -import { GlCollapse, GlButton, GlPopover, GlSkeletonLoader } from '@gitlab/ui'; -import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils'; +import { GlAccordion, GlAccordionItem, GlSkeletonLoader } from '@gitlab/ui'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import Participants from '~/sidebar/components/participants/participants.vue'; @@ -17,9 +17,8 @@ export default { DesignDiscussion, DesignNoteSignedOut, Participants, - GlCollapse, - GlButton, - GlPopover, + GlAccordion, + GlAccordionItem, GlSkeletonLoader, DesignTodoButton, }, @@ -58,7 +57,7 @@ export default { }, data() { return { - isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)), + isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded, discussionWithOpenForm: '', isLoggedIn: isLoggedIn(), }; @@ -79,18 +78,22 @@ export default { resolvedDiscussions() { return this.discussions.filter((discussion) => discussion.resolved); }, + hasResolvedDiscussions() { + return this.resolvedDiscussions.length > 0; + }, + resolvedDiscussionsTitle() { + return `${this.$options.i18n.resolveCommentsToggleText} (${this.resolvedDiscussions.length})`; + }, unresolvedDiscussions() { return this.discussions.filter((discussion) => !discussion.resolved); }, - resolvedCommentsToggleIcon() { - return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right'; - }, }, watch: { - isResolvedCommentsPopoverHidden(newVal) { - if (!newVal) { - this.$refs.resolvedComments.scrollIntoView(); - } + resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) { + this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded; + }, + isResolvedDiscussionsExpanded() { + this.$emit('toggleResolvedComments'); }, }, mounted() { @@ -100,8 +103,6 @@ export default { }, methods: { handleSidebarClick() { - this.isResolvedCommentsPopoverHidden = true; - setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 }); this.updateActiveDiscussion(); }, updateActiveDiscussion(id) { @@ -121,8 +122,9 @@ export default { this.discussionWithOpenForm = id; }, }, - resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'), - cookieKey: 'hide_design_resolved_comments_popover', + i18n: { + resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'), + }, }; </script> @@ -181,40 +183,12 @@ export default { @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" @open-form="updateDiscussionWithOpenForm" /> - <template v-if="resolvedDiscussions.length > 0"> - <gl-button - id="resolved-comments" - ref="resolvedComments" - data-testid="resolved-comments" - :icon="resolvedCommentsToggleIcon" - variant="link" - class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4" - @click="$emit('toggleResolvedComments')" - >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }}) - </gl-button> - <gl-popover - v-if="!isResolvedCommentsPopoverHidden" - :show="!isResolvedCommentsPopoverHidden" - target="resolved-comments" - container="popovercontainer" - placement="top" - :title="s__('DesignManagement|Resolved Comments')" + <gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5"> + <gl-accordion-item + v-model="isResolvedDiscussionsExpanded" + :title="resolvedDiscussionsTitle" + header-class="gl-mb-5!" > - <p> - {{ - s__( - 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below', - ) - }} - </p> - <a - href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads" - rel="noopener noreferrer" - target="_blank" - >{{ s__('DesignManagement|Learn more about resolving comments') }}</a - > - </gl-popover> - <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3"> <design-discussion v-for="discussion in resolvedDiscussions" :key="discussion.id" @@ -232,8 +206,8 @@ export default { @open-form="updateDiscussionWithOpenForm" @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" /> - </gl-collapse> - </template> + </gl-accordion-item> + </gl-accordion> <slot name="reply-form"></slot> </template> </div> diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js index 92928ca429f..afe621ac3c5 100644 --- a/app/assets/javascripts/design_management/constants.js +++ b/app/assets/javascripts/design_management/constants.js @@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = { }; export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0']; + +export const MAXIMUM_FILE_UPLOAD_LIMIT = 10; diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index f81d4f6662f..51983b19677 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -4,16 +4,15 @@ import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import VueDraggable from 'vuedraggable'; import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; -import createFlash, { FLASH_TYPES } from '~/flash'; import { getFilename, validateImageName } from '~/lib/utils/file_upload'; -import { __, s__, sprintf } from '~/locale'; +import { __, s__ } from '~/locale'; import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import DeleteButton from '../components/delete_button.vue'; import DesignDestroyer from '../components/design_destroyer.vue'; import Design from '../components/list/item.vue'; import UploadButton from '../components/upload/button.vue'; import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; -import { VALID_DESIGN_FILE_MIMETYPE } from '../constants'; +import { MAXIMUM_FILE_UPLOAD_LIMIT, VALID_DESIGN_FILE_MIMETYPE } from '../constants'; import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql'; import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; import allDesignsMixin from '../mixins/all_designs'; @@ -35,11 +34,10 @@ import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR, designUploadSkippedWarning, designDeletionError, + MAXIMUM_FILE_UPLOAD_LIMIT_REACHED, } from '../utils/error_messages'; import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking'; -const MAXIMUM_FILE_UPLOAD_LIMIT = 10; - export default { components: { GlLoadingIcon, @@ -87,6 +85,7 @@ export default { isDraggingDesign: false, reorderedDesigns: null, isReorderingInProgress: false, + uploadError: null, }; }, computed: { @@ -159,16 +158,7 @@ export default { if (!this.canCreateDesign) return false; if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) { - createFlash({ - message: sprintf( - s__( - 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', - ), - { - upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, - }, - ), - }); + this.uploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED; return false; } @@ -206,7 +196,7 @@ export default { const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); if (skippedWarningMessage) { - createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING }); + this.uploadError = skippedWarningMessage; } // if this upload resulted in a new version being created, redirect user to the latest version @@ -229,7 +219,7 @@ export default { }, onUploadDesignError() { this.resetFilesToBeSaved(); - createFlash({ message: UPLOAD_DESIGN_ERROR }); + this.uploadError = UPLOAD_DESIGN_ERROR; }, changeSelectedDesigns(filename) { if (this.isDesignSelected(filename)) { @@ -260,21 +250,21 @@ export default { }, onDesignDeleteError() { const errorMessage = designDeletionError(this.selectedDesigns.length); - createFlash({ message: errorMessage }); + this.uploadError = errorMessage; }, onDesignDropzoneError() { - createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR }); + this.uploadError = UPLOAD_DESIGN_INVALID_FILETYPE_ERROR; }, onExistingDesignDropzoneChange(files, existingDesignFilename) { const filesArr = Array.from(files); if (filesArr.length > 1) { - createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE }); + this.uploadError = EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE; return; } if (!filesArr.some(({ name }) => existingDesignFilename === name)) { - createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE }); + this.uploadError = EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE; return; } @@ -329,7 +319,7 @@ export default { optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns), }) .catch(() => { - createFlash({ message: MOVE_DESIGN_ERROR }); + this.uploadError = MOVE_DESIGN_ERROR; }) .finally(() => { this.isReorderingInProgress = false; @@ -338,6 +328,9 @@ export default { onDesignMove(designs) { this.reorderedDesigns = designs; }, + unsetUpdateError() { + this.uploadError = null; + }, }, dragOptions: { animation: 200, @@ -356,6 +349,15 @@ export default { @mouseenter="toggleOnPasteListener" @mouseleave="toggleOffPasteListener" > + <gl-alert + v-if="uploadError" + variant="danger" + class="gl-mb-3" + data-testid="design-update-alert" + @dismiss="unsetUpdateError" + > + {{ uploadError }} + </gl-alert> <header v-if="showToolbar" class="gl-display-flex gl-my-0 gl-text-gray-900" @@ -371,6 +373,7 @@ export default { <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2" + data-testid="design-selector-toolbar" > <gl-button v-if="isLatestVersion" diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js index 981b50329b2..42f752efc9e 100644 --- a/app/assets/javascripts/design_management/utils/error_messages.js +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -1,4 +1,5 @@ import { __, s__, n__, sprintf } from '~/locale'; +import { MAXIMUM_FILE_UPLOAD_LIMIT } from '../constants'; export const ADD_DISCUSSION_COMMENT_ERROR = s__( 'DesignManagement|Could not add a new comment. Please try again.', @@ -27,11 +28,11 @@ export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.'); export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.'); export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __( - 'You can only upload one design when dropping onto an existing design.', + 'Your update failed. You can only upload one design when dropping onto an existing design.', ); export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __( - 'You must upload a file with the same file name when dropping onto an existing design.', + 'Your update failed. You must upload a file with the same file name when dropping onto an existing design.', ); export const MOVE_DESIGN_ERROR = __( @@ -122,3 +123,12 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => { return someDesignsSkippedMessage(skippedFiles); }; + +export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf( + s__( + 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', + ), + { + upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, + }, +); diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 9f3fb715150..8388458b11c 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -101,6 +101,7 @@ export default class Diff { const clickTarget = $('.js-file-title, .click-to-expand', diffFile); diffFile.data('singleFileDiff').toggleDiff(clickTarget, () => { this.highlightSelectedLine(); + this.prepareRenderedDiff(); if (cb) cb(); }); } else if (cb) { @@ -156,20 +157,22 @@ export default class Diff { } prepareRenderedDiff() { - const $elements = $('[data-diff-toggle-entity]'); - - if ($elements.length === 0) return; - + const allElements = this.elementsForRenderedDiff(); const diff = this; - const elements = $elements.toArray().map(this.formatElementToObject).reduce(merge); + for (const [fileHash, fileElements] of Object.entries(allElements)) { + // eslint-disable no-param-reassign + fileElements.rawButton.onclick = () => { + diff.showRawViewer(fileHash, diff.elementsForRenderedDiff()[fileHash]); + }; - Object.values(elements).forEach((e) => { - e.toShowBtn.onclick = () => diff.showOneHideAnother('rendered', e); // eslint-disable-line no-param-reassign - e.toHideBtn.onclick = () => diff.showOneHideAnother('raw', e); // eslint-disable-line no-param-reassign + fileElements.renderedButton.onclick = () => { + diff.showRenderedViewer(fileHash, diff.elementsForRenderedDiff()[fileHash]); + }; + // eslint-enable no-param-reassign - diff.showOneHideAnother('rendered', e); - }); + diff.showRenderedViewer(fileHash, fileElements); + } } formatElementToObject = (element) => { @@ -179,18 +182,33 @@ export default class Diff { return { [key]: { [name]: element } }; }; - showOneHideAnother = (mode, elements) => { - let { toShowBtn, toHideBtn, toShow, toHide } = elements; + elementsForRenderedDiff = () => { + const $elements = $('[data-diff-toggle-entity]'); + + if ($elements.length === 0) return {}; - if (mode === 'raw') { - [toShowBtn, toHideBtn] = [toHideBtn, toShowBtn]; - [toShow, toHide] = [toHide, toShow]; - } + const diff = this; + + return $elements.toArray().map(diff.formatElementToObject).reduce(merge); + }; + + showRawViewer = (fileHash, elements) => { + if (elements === undefined) return; + + elements.rawButton.classList.add('selected'); + elements.renderedButton.classList.remove('selected'); + + elements.renderedViewer.classList.add('hidden'); + elements.rawViewer.classList.remove('hidden'); + }; + + showRenderedViewer = (fileHash, elements) => { + if (elements === undefined) return; - toShowBtn.classList.add('selected'); - toHideBtn.classList.remove('selected'); + elements.rawButton.classList.remove('selected'); + elements.rawViewer.classList.add('hidden'); - toHide.classList.add('hidden'); - toShow.classList.remove('hidden'); + elements.renderedButton.classList.add('selected'); + elements.renderedViewer.classList.remove('hidden'); }; } diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 54b648e8d03..ad163a2a615 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -134,7 +134,9 @@ export default { class="avatar-cell d-none d-sm-block" /> </div> - <div class="commit-detail flex-list"> + <div + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" + > <div class="commit-content" data-qa-selector="commit_content"> <a v-safe-html:[$options.safeHtmlConfig]="commit.title_html" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue new file mode 100644 index 00000000000..f339b108a11 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants'; + +export default { + components: { GlButton, GlIcon }, + props: { + line: { + type: Number, + required: true, + }, + codeQuality: { + type: Array, + required: true, + }, + }, + methods: { + severityClass(severity) { + return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; + }, + severityIcon(severity) { + return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + }, + }, +}; +</script> + +<template> + <div data-testid="diff-codequality" class="gl-relative"> + <ul + class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10" + > + <li + v-for="finding in codeQuality" + :key="finding.description" + class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100" + > + <gl-icon + :size="12" + :name="severityIcon(finding.severity)" + :class="severityClass(finding.severity)" + class="codequality-severity-icon" + /> + {{ finding.description }} + </li> + </ul> + <gl-button + data-testid="diff-codequality-close" + category="tertiary" + size="small" + icon="close" + class="gl-absolute gl-right-2 gl-top-2" + @click="$emit('hideCodeQualityFindings', line)" + /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index bfe35e9346d..70071a3ff53 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -80,7 +80,7 @@ export default { return this.getUserData; }, mappedLines() { - // TODO: Do this data generation when we recieve a response to save a computed property being created + // TODO: Do this data generation when we receive a response to save a computed property being created return this.diffLines(this.diffFile).map(mapParallel(this)) || []; }, }, diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index ebc68bafb9a..467a0f8d2db 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -206,6 +206,7 @@ export default { ); }, updateStartLine(line) { + this.commentLineStart = line; this.lines.start = line; }, }, @@ -216,7 +217,6 @@ export default { <div class="content discussion-form discussion-form-container discussion-notes"> <div class="gl-mb-3 gl-text-gray-500 gl-pb-3"> <multiline-comment-form - v-model="commentLineStart" :line="line" :line-range="lines" :comment-line-options="commentLineOptions" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 1b07b00d725..63c5aedd7ce 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -274,6 +274,9 @@ export default { v-if="$options.showCodequalityLeft(props)" :codequality="props.line.left.codequality" :file-path="props.filePath" + @showCodeQualityFindings=" + listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line) + " /> </div> <div @@ -395,6 +398,9 @@ export default { :codequality="props.line.right.codequality" :file-path="props.filePath" data-testid="codeQualityIcon" + @showCodeQualityFindings=" + listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line) + " /> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index d740d5adcb6..ad406947561 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -2,12 +2,14 @@ import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters, mapState, mapActions } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import { hide } from '~/tooltips'; import { pickDirection } from '../utils/diff_line'; import DiffCommentCell from './diff_comment_cell.vue'; +import DiffCodeQuality from './diff_code_quality.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import DiffRow from './diff_row.vue'; import { isHighlighted } from './diff_row_utils'; @@ -17,12 +19,17 @@ export default { DiffExpansionCell, DiffRow, DiffCommentCell, + DiffCodeQuality, DraftNote, }, directives: { SafeHtml, }, - mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })], + mixins: [ + draftCommentsMixin, + IdState({ idProp: (vm) => vm.diffFile.file_hash }), + glFeatureFlagsMixin(), + ], props: { diffFile: { type: Object, @@ -43,6 +50,11 @@ export default { default: false, }, }, + data() { + return { + codeQualityExpandedLines: [], + }; + }, idState() { return { dragStart: null, @@ -84,6 +96,23 @@ export default { } this.idState.dragStart = line; }, + parseCodeQuality(line) { + return (line.left ?? line.right)?.codequality; + }, + + hideCodeQualityFindings(line) { + const index = this.codeQualityExpandedLines.indexOf(line); + if (index > -1) { + this.codeQualityExpandedLines.splice(index, 1); + } + }, + toggleCodeQualityFindings(line) { + if (!this.codeQualityExpandedLines.includes(line)) { + this.codeQualityExpandedLines.push(line); + } else { + this.hideCodeQualityFindings(line); + } + }, onDragOver(line) { if (line.chunk !== this.idState.dragStart.chunk) return; @@ -125,15 +154,16 @@ export default { }, handleParallelLineMouseDown(e) { const line = e.target.closest('.diff-td'); - const table = line.closest('.diff-table'); - - table.classList.remove('left-side-selected', 'right-side-selected'); - const [lineClass] = ['left-side', 'right-side'].filter((name) => - line.classList.contains(name), - ); + if (line) { + const table = line.closest('.diff-table'); + table.classList.remove('left-side-selected', 'right-side-selected'); + const [lineClass] = ['left-side', 'right-side'].filter((name) => + line.classList.contains(name), + ); - if (lineClass) { - table.classList.add(`${lineClass}-selected`); + if (lineClass) { + table.classList.add(`${lineClass}-selected`); + } } }, getCountBetweenIndex(index) { @@ -148,6 +178,9 @@ export default { Number(this.diffLines[index - 1].left.new_line) ); }, + getCodeQualityLine(line) { + return this.parseCodeQuality(line)?.[0]?.line; + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -190,6 +223,7 @@ export default { :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" @setHighlightedRow="setHighlightedRow" + @toggleCodeQualityFindings="toggleCodeQualityFindings" @toggleLineDiscussions=" ({ lineCode, expanded }) => toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded }) @@ -198,6 +232,17 @@ export default { @startdragging="onStartDragging" @stopdragging="onStopDragging" /> + + <diff-code-quality + v-if=" + glFeatures.refactorCodeQualityInlineFindings && + codeQualityExpandedLines.includes(getCodeQualityLine(line)) + " + :key="line.line_code" + :line="getCodeQualityLine(line)" + :code-quality="parseCodeQuality(line)" + @hideCodeQualityFindings="hideCodeQualityFindings" + /> <div v-if="line.renderCommentRow" :key="`dcr-${line.line_code || index}`" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 85e4199d1c1..ffbea854001 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -1,6 +1,7 @@ <script> import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import micromatch from 'micromatch'; import { s__, sprintf } from '~/locale'; import FileTree from '~/vue_shared/components/file_tree.vue'; import DiffFileRow from './diff_file_row.vue'; @@ -28,14 +29,24 @@ export default { ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']), ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { - const search = this.search.toLowerCase().trim(); + let search = this.search.toLowerCase().trim(); if (search === '') { return this.renderTreeList ? this.tree : this.allBlobs; } + const searchSplit = search.split(',').filter((t) => t); + + if (searchSplit.length > 1) { + search = `(${searchSplit.map((s) => s.replace(/(^ +| +$)/g, '')).join('|')})`; + } else { + [search] = searchSplit; + } + return this.allBlobs.reduce((acc, folder) => { - const tree = folder.tree.filter((f) => f.path.toLowerCase().indexOf(search) >= 0); + const tree = folder.tree.filter((f) => + micromatch.contains(f.path, search, { nocase: true }), + ); if (tree.length) { return acc.concat({ @@ -54,7 +65,7 @@ export default { this.search = ''; }, }, - searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), { + searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)'), { modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+', }), DiffFileRow, @@ -74,6 +85,7 @@ export default { type="search" name="diff-tree-search" class="form-control" + data-testid="diff-tree-search" /> <button v-show="search" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index d5cd4af4d06..ace507f601a 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -744,6 +744,10 @@ export const setFileCollapsedByUser = ({ commit }, { filePath, collapsed }) => { commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_MANUAL_COLLAPSE }); }; +export const setFileCollapsedAutomatically = ({ commit }, { filePath, collapsed }) => { + commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_AUTOMATIC_COLLAPSE }); +}; + export const setSuggestPopoverDismissed = ({ commit, state }) => axios .post(state.dismissEndpoint, { diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index c8015f884b7..e8b96c25965 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -436,6 +436,33 @@ "type": "string" } }, + "pull_policy": { + "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#servicepull_policy).", + "default": "always", + "oneOf": [ + { + "type": "string", + "enum": [ + "always", + "never", + "if-not-present" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "always", + "never", + "if-not-present" + ] + }, + "minItems": 1, + "uniqueItems": true + } + ] + }, "command": { "type": "array", "description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.", diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js index 95a43c2b2d0..fb5d5414ca4 100644 --- a/app/assets/javascripts/editor/source_editor_instance.js +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -90,6 +90,7 @@ export default class EditorInstance { this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore); + // eslint-disable-next-line no-constructor-return return instProxy; } diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js index f83bfe614dd..427a504e038 100644 --- a/app/assets/javascripts/emoji/awards_app/store/actions.js +++ b/app/assets/javascripts/emoji/awards_app/store/actions.js @@ -14,8 +14,6 @@ import { export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data); export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => { - if (!window.gon?.current_user_id) return; - try { const { data, headers } = await axios.get(joinPaths(gon.relative_url_root || '', state.path), { params: { per_page: 100, page }, diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue index fd4885a9dbd..cacd868bed0 100644 --- a/app/assets/javascripts/environments/components/canary_update_modal.vue +++ b/app/assets/javascripts/environments/components/canary_update_modal.vue @@ -42,7 +42,7 @@ export default { modalId: CANARY_UPDATE_MODAL, actionPrimary: { text: s__('CanaryIngress|Change ratio'), - attributes: [{ variant: 'info' }], + attributes: [{ variant: 'confirm' }], }, actionCancel: { text: __('Cancel') }, static: true, diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index ce919f73858..8259574f8e3 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -6,6 +6,7 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; import { escape } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import rollbackEnvironment from '../graphql/mutations/rollback_environment.mutation.graphql'; import eventHub from '../event_hub'; @@ -84,7 +85,9 @@ export default { return this.environment.commitUrl; }, modalActionText() { - return this.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback'); + return this.isLastDeployment + ? s__('Environments|Re-deploy environment') + : s__('Environments|Rollback environment'); }, primaryProps() { let attributes = [{ variant: 'danger' }]; @@ -101,6 +104,15 @@ export default { isLastDeployment() { return this.environment?.isLastDeployment || this.environment?.lastDeployment?.isLast; }, + modalBodyText() { + return this.isLastDeployment + ? s__( + 'Environments|This action will %{docsStart}retry the latest deployment%{docsEnd} with the commit %{commitId}, for this environment. Are you sure you want to continue?', + ) + : s__( + 'Environments|This action will %{docsStart}roll back this environment%{docsEnd} to a previously successful deployment for commit %{commitId}. Are you sure you want to continue?', + ); + }, }, methods: { handleChange(event) { @@ -125,6 +137,7 @@ export default { text: __('Cancel'), attributes: [{ variant: 'danger' }], }, + docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }), }; </script> <template> @@ -137,33 +150,14 @@ export default { @ok="onOk" @change="handleChange" > - <gl-sprintf - v-if="environment.isLastDeployment" - :message=" - s__( - 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?', - ) - " - > - <template #link> + <gl-sprintf :message="modalBodyText"> + <template #commitId> <gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{ commitShortSha }}</gl-link> </template> - </gl-sprintf> - <gl-sprintf - v-else - :message=" - s__( - 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?', - ) - " - > - <template #name>{{ environment.name }}</template> - <template #link> - <gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{ - commitShortSha - }}</gl-link> + <template #docs="{ content }"> + <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-modal> diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index 8a379ebdf66..7a2c9a8600e 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -51,11 +51,6 @@ export default { type: Boolean, required: true, }, - logsPath: { - type: String, - required: false, - default: '', - }, graphql: { type: Boolean, required: false, @@ -186,7 +181,6 @@ export default { :status="instance.status" :tooltip-text="instance.tooltip" :pod-name="podName(instance)" - :logs-path="logsPath" :stable="instance.stable" /> </template> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 895a6cf2ccb..b47086a19da 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -164,7 +164,6 @@ export default { :deploy-board-data="model.deployBoardData" :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" - :logs-path="model.logs_path" @changeCanaryWeight="changeCanaryWeight(model, $event)" /> </div> @@ -199,7 +198,6 @@ export default { :deploy-board-data="child.deployBoardData" :is-loading="child.isLoadingDeployBoard" :is-empty="child.isEmptyDeployBoard" - :logs-path="child.logs_path" @changeCanaryWeight="changeCanaryWeight(child, $event)" /> </div> diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index a67e44b3348..4d70e29a684 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -15,8 +15,6 @@ export default class EnvironmentsStore { this.state.availableCounter = 0; this.state.paginationInformation = {}; this.state.reviewAppDetails = {}; - - return this; } /** diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index d29d5aa0671..a07428dafea 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -150,6 +150,12 @@ export default { paginationRequired() { return !isEmpty(this.pagination); }, + previousPage() { + return this.pagination.previous ? this.$options.PREV_PAGE : null; + }, + nextPage() { + return this.pagination.next ? this.$options.NEXT_PAGE : null; + }, errorTrackingHelpUrl() { return helpPagePath('operations/error_tracking'); }, @@ -430,8 +436,8 @@ export default { <gl-pagination v-show="!loading" v-if="paginationRequired" - :prev-page="$options.PREV_PAGE" - :next-page="$options.NEXT_PAGE" + :prev-page="previousPage" + :next-page="nextPage" :value="pageValue" align="center" @input="goToPage" diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue index cb8ffbddfbd..04190d7bfda 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/default.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue @@ -4,7 +4,7 @@ export default { this.$emit('change', { parameters: {} }); }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue new file mode 100644 index 00000000000..f17a05999b0 --- /dev/null +++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue @@ -0,0 +1,84 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { captureException } from '@sentry/browser'; +import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue'; +import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml'; +import { logError } from '~/lib/logger'; +import { s__ } from '~/locale'; +import { redirectTo } from '~/lib/utils/url_utility'; +import pagesMarkOnboardingComplete from '../queries/mark_onboarding_complete.graphql'; + +export const i18n = { + loadingMessage: s__('GitLabPages|Updating your Pages configuration...'), +}; + +export default { + name: 'PagesPipelineWizard', + i18n, + PagesWizardTemplate, + components: { + PipelineWizard, + GlLoadingIcon, + }, + props: { + projectPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + redirectToWhenDone: { + type: String, + required: true, + }, + }, + data() { + return { + loading: false, + }; + }, + methods: { + async updateOnboardingState() { + try { + await this.$apollo.mutate({ + mutation: pagesMarkOnboardingComplete, + variables: { + input: { projectPath: this.projectPath }, + }, + }); + } catch (e) { + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Updating the pages onboarding state failed', e); + captureException(e); + } + }, + async onDone() { + this.loading = true; + await this.updateOnboardingState(); + redirectTo(this.redirectToWhenDone); + }, + }, +}; +</script> + +<template> + <div> + <div + v-if="loading" + class="gl-p-3 gl-rounded-base gl-text-center" + data-testid="onboarding-mutation-loading" + > + <gl-loading-icon /> + {{ $options.i18n.loadingMessage }} + </div> + <pipeline-wizard + v-else + :template="$options.PagesWizardTemplate" + :project-path="projectPath" + :default-branch="defaultBranch" + @done="onDone" + /> + </div> +</template> diff --git a/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql b/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql new file mode 100644 index 00000000000..abedd54b079 --- /dev/null +++ b/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql @@ -0,0 +1,6 @@ +mutation pagesMarkOnboardingComplete($input: PagesMarkOnboardingCompleteInput!) { + pagesMarkOnboardingComplete(input: $input) { + onboardingComplete + errors + } +} diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue deleted file mode 100644 index b3d773e6bee..00000000000 --- a/app/assets/javascripts/google_cloud/components/app.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { __ } from '~/locale'; - -import Home from './home.vue'; -import IncubationBanner from './incubation_banner.vue'; -import ServiceAccountsForm from './service_accounts_form.vue'; -import GcpRegionsForm from './gcp_regions_form.vue'; -import NoGcpProjects from './errors/no_gcp_projects.vue'; -import GcpError from './errors/gcp_error.vue'; - -const SCREEN_GCP_ERROR = 'gcp_error'; -const SCREEN_HOME = 'home'; -const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects'; -const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form'; -const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form'; - -export default { - components: { - IncubationBanner, - }, - inheritAttrs: false, - props: { - screen: { - required: true, - type: String, - }, - }, - computed: { - mainComponent() { - switch (this.screen) { - case SCREEN_HOME: - return Home; - case SCREEN_GCP_ERROR: - return GcpError; - case SCREEN_NO_GCP_PROJECTS: - return NoGcpProjects; - case SCREEN_SERVICE_ACCOUNTS_FORM: - return ServiceAccountsForm; - case SCREEN_GCP_REGIONS_FORM: - return GcpRegionsForm; - default: - throw new Error(__('Unknown screen')); - } - }, - }, - methods: { - feedbackUrl(template) { - return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`; - }, - }, -}; -</script> - -<template> - <div> - <incubation-banner - :share-feedback-url="feedbackUrl('general_feedback')" - :report-bug-url="feedbackUrl('report_bug')" - :feature-request-url="feedbackUrl('feature_request')" - /> - <component :is="mainComponent" v-bind="$attrs" /> - </div> -</template> diff --git a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue deleted file mode 100644 index 90aa0e1ae68..00000000000 --- a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue +++ /dev/null @@ -1,29 +0,0 @@ -<script> -import { GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { GlAlert }, - props: { - error: { - type: String, - required: true, - }, - }, - i18n: { - title: __('Google Cloud project misconfigured'), - description: __( - 'GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:', - ), - }, -}; -</script> - -<template> - <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> - {{ $options.i18n.description }} - <blockquote> - <code>{{ error }}</code> - </blockquote> - </gl-alert> -</template> diff --git a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue deleted file mode 100644 index da229ac3f0e..00000000000 --- a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { GlAlert, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { GlAlert, GlButton }, - i18n: { - title: __('Google Cloud project required'), - description: __( - 'You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page.', - ), - createLabel: __('Create Google Cloud project'), - }, -}; -</script> - -<template> - <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> - {{ $options.i18n.description }} - <template #actions> - <gl-button href="https://console.cloud.google.com/projectcreate" target="_blank"> - {{ $options.i18n.createLabel }} - </gl-button> - </template> - </gl-alert> -</template> diff --git a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue new file mode 100644 index 00000000000..d6b7c702b54 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue @@ -0,0 +1,85 @@ +<script> +import { s__ } from '~/locale'; + +const CONFIGURATION_KEY = 'configuration'; +const DEPLOYMENTS_KEY = 'deployments'; +const DATABASES_KEY = 'databases'; + +const i18n = { + configuration: { title: s__('CloudSeed|Configuration') }, + deployments: { title: s__('CloudSeed|Deployments') }, + databases: { title: s__('CloudSeed|Databases') }, +}; + +export default { + props: { + active: { + type: String, + required: true, + }, + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + }, + computed: { + isConfigurationActive() { + return this.active === CONFIGURATION_KEY; + }, + isDeploymentsActive() { + return this.active === DEPLOYMENTS_KEY; + }, + isDatabasesActive() { + return this.active === DATABASES_KEY; + }, + }, + i18n, +}; +</script> +<template> + <div class="tabs gl-tabs"> + <ul role="tablist" class="nav gl-tabs-nav"> + <li role="presentation" class="nav-item"> + <a + data-testid="configurationLink" + role="tab" + :href="configurationUrl" + class="nav-link gl-tab-nav-item" + :class="{ 'gl-tab-nav-item-active': isConfigurationActive }" + > + {{ $options.i18n.configuration.title }}</a + > + </li> + <li role="presentation" class="nav-item"> + <a + data-testid="deploymentsLink" + role="tab" + :href="deploymentsUrl" + class="nav-link gl-tab-nav-item" + :class="{ 'gl-tab-nav-item-active': isDeploymentsActive }" + > + {{ $options.i18n.deployments.title }} + </a> + </li> + <li role="presentation" class="nav-item"> + <a + data-testid="databasesLink" + role="tab" + :href="databasesUrl" + class="nav-link gl-tab-nav-item" + :class="{ 'gl-tab-nav-item-active': isDatabasesActive }" + > + {{ $options.i18n.databases.title }} + </a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue deleted file mode 100644 index e41337e2679..00000000000 --- a/app/assets/javascripts/google_cloud/components/home.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlTabs, GlTab } from '@gitlab/ui'; -import DeploymentsServiceTable from './deployments_service_table.vue'; -import RevokeOauth from './revoke_oauth.vue'; -import ServiceAccountsList from './service_accounts_list.vue'; -import GcpRegionsList from './gcp_regions_list.vue'; - -export default { - components: { - GlTabs, - GlTab, - DeploymentsServiceTable, - RevokeOauth, - ServiceAccountsList, - GcpRegionsList, - }, - props: { - serviceAccounts: { - type: Array, - required: true, - }, - createServiceAccountUrl: { - type: String, - required: true, - }, - configureGcpRegionsUrl: { - type: String, - required: true, - }, - emptyIllustrationUrl: { - type: String, - required: true, - }, - enableCloudRunUrl: { - type: String, - required: true, - }, - enableCloudStorageUrl: { - type: String, - required: true, - }, - gcpRegions: { - type: Array, - required: true, - }, - revokeOauthUrl: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <gl-tabs> - <gl-tab :title="__('Configuration')"> - <service-accounts-list - class="gl-mx-4" - :list="serviceAccounts" - :create-url="createServiceAccountUrl" - :empty-illustration-url="emptyIllustrationUrl" - /> - <hr /> - <gcp-regions-list - class="gl-mx-4" - :empty-illustration-url="emptyIllustrationUrl" - :create-url="configureGcpRegionsUrl" - :list="gcpRegions" - /> - <hr v-if="revokeOauthUrl" /> - <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" /> - </gl-tab> - <gl-tab :title="__('Deployments')"> - <deployments-service-table - :cloud-run-url="enableCloudRunUrl" - :cloud-storage-url="enableCloudStorageUrl" - /> - </gl-tab> - <gl-tab :title="__('Services')" disabled /> - </gl-tabs> -</template> diff --git a/app/assets/javascripts/google_cloud/components/incubation_banner.vue b/app/assets/javascripts/google_cloud/components/incubation_banner.vue index 652b8c1aecb..128b3dcb1d9 100644 --- a/app/assets/javascripts/google_cloud/components/incubation_banner.vue +++ b/app/assets/javascripts/google_cloud/components/incubation_banner.vue @@ -1,22 +1,20 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +const FEATURE_REQUEST_KEY = 'feature_request'; +const REPORT_BUG_KEY = 'report_bug'; +const GENERAL_FEEDBACK_KEY = 'general_feedback'; + export default { components: { GlAlert, GlLink, GlSprintf }, - props: { - shareFeedbackUrl: { - required: true, - type: String, - }, - reportBugUrl: { - required: true, - type: String, - }, - featureRequestUrl: { - required: true, - type: String, + methods: { + feedbackUrl(template) { + return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`; }, }, + FEATURE_REQUEST_KEY, + REPORT_BUG_KEY, + GENERAL_FEEDBACK_KEY, }; </script> @@ -31,13 +29,13 @@ export default { " > <template #featureLink="{ content }"> - <gl-link :href="featureRequestUrl">{{ content }}</gl-link> + <gl-link :href="feedbackUrl($options.FEATURE_REQUEST_KEY)">{{ content }}</gl-link> </template> <template #bugLink="{ content }"> - <gl-link :href="reportBugUrl">{{ content }}</gl-link> + <gl-link :href="feedbackUrl($options.REPORT_BUG_KEY)">{{ content }}</gl-link> </template> <template #feedbackLink="{ content }"> - <gl-link :href="shareFeedbackUrl">{{ content }}</gl-link> + <gl-link :href="feedbackUrl($options.GENERAL_FEEDBACK_KEY)">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> diff --git a/app/assets/javascripts/google_cloud/configuration/index.js b/app/assets/javascripts/google_cloud/configuration/index.js new file mode 100644 index 00000000000..580315588d0 --- /dev/null +++ b/app/assets/javascripts/google_cloud/configuration/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default (containerId = '#js-google-cloud-configuration') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/configuration/panel.vue b/app/assets/javascripts/google_cloud/configuration/panel.vue new file mode 100644 index 00000000000..ee046eb1988 --- /dev/null +++ b/app/assets/javascripts/google_cloud/configuration/panel.vue @@ -0,0 +1,88 @@ +<script> +import GcpRegionsList from '../gcp_regions/list.vue'; +import GoogleCloudMenu from '../components/google_cloud_menu.vue'; +import IncubationBanner from '../components/incubation_banner.vue'; +import RevokeOauth from '../components/revoke_oauth.vue'; +import ServiceAccountsList from '../service_accounts/list.vue'; + +export default { + components: { + GcpRegionsList, + GoogleCloudMenu, + IncubationBanner, + RevokeOauth, + ServiceAccountsList, + }, + props: { + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + serviceAccounts: { + type: Array, + required: true, + }, + createServiceAccountUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + configureGcpRegionsUrl: { + type: String, + required: true, + }, + gcpRegions: { + type: Array, + required: true, + }, + revokeOauthUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner /> + + <google-cloud-menu + active="configuration" + :configuration-url="configurationUrl" + :deployments-url="deploymentsUrl" + :databases-url="databasesUrl" + /> + + <service-accounts-list + class="gl-mx-4" + :list="serviceAccounts" + :create-url="createServiceAccountUrl" + :empty-illustration-url="emptyIllustrationUrl" + /> + + <hr /> + + <gcp-regions-list + class="gl-mx-4" + :empty-illustration-url="emptyIllustrationUrl" + :create-url="configureGcpRegionsUrl" + :list="gcpRegions" + /> + + <hr v-if="revokeOauthUrl" /> + + <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" /> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue new file mode 100644 index 00000000000..0ac561b6132 --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue @@ -0,0 +1,132 @@ +<script> +import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +const i18n = { + gcpProjectLabel: s__('CloudSeed|Google Cloud project'), + gcpProjectDescription: s__( + 'CloudSeed|Database instance is generated within the selected Google Cloud project', + ), + refsLabel: s__('CloudSeed|Refs'), + refsDescription: s__( + 'CloudSeed|Generated database instance is linked to the selected branch or tag', + ), + databaseVersionLabel: s__('CloudSeed|Database version'), + tierLabel: s__('CloudSeed|Machine type'), + tierDescription: s__('CloudSeed|Determines memory and virtual cores available to your instance'), + checkboxLabel: s__( + 'CloudSeed|I accept Google Cloud pricing and responsibilities involved with managing database instances', + ), + cancelLabel: s__('CloudSeed|Cancel'), + submitLabel: s__('CloudSeed|Create instance'), + all: s__('CloudSeed|All'), +}; + +export default { + ALL_REFS: '*', + components: { + GlButton, + GlFormCheckbox, + GlFormGroup, + GlFormSelect, + }, + props: { + cancelPath: { required: true, type: String }, + gcpProjects: { required: true, type: Array }, + refs: { required: true, type: Array }, + formTitle: { required: true, type: String }, + formDescription: { required: true, type: String }, + databaseVersions: { required: true, type: Array }, + tiers: { required: true, type: Array }, + }, + i18n, +}; +</script> +<template> + <div> + <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"> + <h2 class="gl-font-size-h1">{{ formTitle }}</h2> + <p>{{ formDescription }}</p> + </header> + + <gl-form-group + data-testid="form_group_gcp_project" + label-for="gcp_project" + :label="$options.i18n.gcpProjectLabel" + :description="$options.i18n.gcpProjectDescription" + > + <gl-form-select id="gcp_project" data-testid="select_gcp_project" name="gcp_project" required> + <option + v-for="gcpProject in gcpProjects" + :key="gcpProject.project_id" + :value="gcpProject.project_id" + > + {{ gcpProject.name }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + data-testid="form_group_environments" + label-for="ref" + :label="$options.i18n.refsLabel" + :description="$options.i18n.refsDescription" + > + <gl-form-select id="ref" data-testid="select_environments" name="ref" required> + <option :value="$options.ALL_REFS">{{ $options.i18n.all }}</option> + <option v-for="ref in refs" :key="ref" :value="ref"> + {{ ref }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + data-testid="form_group_tier" + label-for="tier" + :label="$options.i18n.tierLabel" + :description="$options.i18n.tierDescription" + > + <gl-form-select id="tier" data-testid="select_tier" name="tier" required> + <option v-for="tier in tiers" :key="tier.value" :value="tier.value"> + {{ tier.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + data-testid="form_group_database_version" + label-for="database-version" + :label="$options.i18n.databaseVersionLabel" + > + <gl-form-select + id="database-version" + data-testid="select_database_version" + name="database_version" + required + > + <option + v-for="databaseVersion in databaseVersions" + :key="databaseVersion.value" + :value="databaseVersion.value" + > + {{ databaseVersion.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group> + <gl-form-checkbox name="confirmation" required> + {{ $options.i18n.checkboxLabel }} + </gl-form-checkbox> + </gl-form-group> + + <div class="form-actions row"> + <gl-button type="submit" category="primary" variant="confirm" data-testid="submit-button"> + {{ $options.i18n.submitLabel }} + </gl-button> + <gl-button class="gl-ml-1" :href="cancelPath" data-testid="cancel-button">{{ + $options.i18n.cancelLabel + }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue new file mode 100644 index 00000000000..823895214df --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue @@ -0,0 +1,75 @@ +<script> +import { GlEmptyState, GlLink, GlTable } from '@gitlab/ui'; +import { encodeSaferUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +const i18n = { + noInstancesTitle: s__('CloudSeed|No instances'), + noInstancesDescription: s__('CloudSeed|There are no instances to display.'), + title: s__('CloudSeed|Instances'), + description: s__('CloudSeed|Database instances associated with this project'), +}; + +export default { + components: { GlEmptyState, GlLink, GlTable }, + props: { + cloudsqlInstances: { + type: Array, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + }, + computed: { + tableData() { + return this.cloudsqlInstances.filter((instance) => instance.instance_name); + }, + }, + methods: { + gcpProjectUrl(id) { + return setUrlParams({ project: id }, 'https://console.cloud.google.com/sql/instances'); + }, + instanceUrl(name, id) { + const saferName = encodeSaferUrl(name); + + return setUrlParams( + { project: id }, + `https://console.cloud.google.com/sql/instances/${saferName}/overview`, + ); + }, + }, + fields: [ + { key: 'ref', label: s__('CloudSeed|Environment') }, + { key: 'gcp_project', label: s__('CloudSeed|Google Cloud Project') }, + { key: 'instance_name', label: s__('CloudSeed|CloudSQL Instance') }, + { key: 'version', label: s__('CloudSeed|Version') }, + ], + i18n, +}; +</script> + +<template> + <div class="gl-mx-3"> + <gl-empty-state + v-if="tableData.length === 0" + :title="$options.i18n.noInstancesTitle" + :description="$options.i18n.noInstancesDescription" + :svg-path="emptyIllustrationUrl" + /> + + <div v-else> + <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2> + <p>{{ $options.i18n.description }}</p> + <gl-table :fields="$options.fields" :items="tableData"> + <template #cell(gcp_project)="{ value }"> + <gl-link :href="gcpProjectUrl(value)">{{ value }}</gl-link> + </template> + <template #cell(instance_name)="{ item: { instance_name, gcp_project } }"> + <a :href="instanceUrl(instance_name, gcp_project)">{{ instance_name }}</a> + </template> + </gl-table> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js new file mode 100644 index 00000000000..e240a1116e8 --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default (containerId = '#js-google-cloud-databases') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue new file mode 100644 index 00000000000..e2f18c286a5 --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/panel.vue @@ -0,0 +1,38 @@ +<script> +import GoogleCloudMenu from '../components/google_cloud_menu.vue'; +import IncubationBanner from '../components/incubation_banner.vue'; + +export default { + components: { + IncubationBanner, + GoogleCloudMenu, + }, + props: { + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner /> + + <google-cloud-menu + active="databases" + :configuration-url="configurationUrl" + :deployments-url="deploymentsUrl" + :databases-url="databasesUrl" + /> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/databases/service_table.vue b/app/assets/javascripts/google_cloud/databases/service_table.vue new file mode 100644 index 00000000000..80bd6ef28fb --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/service_table.vue @@ -0,0 +1,221 @@ +<script> +import { GlAlert, GlButton, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +const KEY_CLOUDSQL_POSTGRES = 'cloudsql-postgres'; +const KEY_CLOUDSQL_MYSQL = 'cloudsql-mysql'; +const KEY_CLOUDSQL_SQLSERVER = 'cloudsql-sqlserver'; +const KEY_ALLOYDB_POSTGRES = 'alloydb-postgres'; +const KEY_MEMORYSTORE_REDIS = 'memorystore-redis'; +const KEY_FIRESTORE = 'firestore'; + +const i18n = { + columnService: s__('CloudSeed|Service'), + columnDescription: s__('CloudSeed|Description'), + cloudsqlPostgresTitle: s__('CloudSeed|Cloud SQL for Postgres'), + cloudsqlPostgresDescription: s__( + 'CloudSeed|Fully managed relational database service for PostgreSQL', + ), + cloudsqlMysqlTitle: s__('CloudSeed|Cloud SQL for MySQL'), + cloudsqlMysqlDescription: s__('CloudSeed|Fully managed relational database service for MySQL'), + cloudsqlSqlserverTitle: s__('CloudSeed|Cloud SQL for SQL Server'), + cloudsqlSqlserverDescription: s__( + 'CloudSeed|Fully managed relational database service for SQL Server', + ), + alloydbPostgresTitle: s__('CloudSeed|AlloyDB for Postgres'), + alloydbPostgresDescription: s__( + 'CloudSeed|Fully managed PostgreSQL-compatible service for high-demand workloads', + ), + memorystoreRedisTitle: s__('CloudSeed|Memorystore for Redis'), + memorystoreRedisDescription: s__( + 'CloudSeed|Scalable, secure, and highly available in-memory service for Redis', + ), + firestoreTitle: s__('CloudSeed|Cloud Firestore'), + firestoreDescription: s__( + 'CloudSeed|Flexible, scalable NoSQL cloud database for client- and server-side development', + ), + createInstance: s__('CloudSeed|Create instance'), + createCluster: s__('CloudSeed|Create cluster'), + createDatabase: s__('CloudSeed|Create database'), + title: s__('CloudSeed|Services'), + description: s__('CloudSeed|Available database services through which instances may be created'), + pricingAlert: s__( + 'CloudSeed|Learn more about pricing for %{cloudsqlPricingStart}Cloud SQL%{cloudsqlPricingEnd}, %{alloydbPricingStart}Alloy DB%{alloydbPricingEnd}, %{memorystorePricingStart}Memorystore%{memorystorePricingEnd} and %{firestorePricingStart}Firestore%{firestorePricingEnd}.', + ), + secretManagersDescription: s__( + 'CloudSeed|Enhance security by storing database variables in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}', + ), +}; + +const helpUrlSecrets = helpPagePath('ee/ci/secrets'); + +export default { + components: { GlAlert, GlButton, GlLink, GlSprintf, GlTable }, + props: { + cloudsqlPostgresUrl: { + type: String, + required: true, + }, + cloudsqlMysqlUrl: { + type: String, + required: true, + }, + cloudsqlSqlserverUrl: { + type: String, + required: true, + }, + alloydbPostgresUrl: { + type: String, + required: true, + }, + memorystoreRedisUrl: { + type: String, + required: true, + }, + firestoreUrl: { + type: String, + required: true, + }, + }, + methods: { + actionUrl(key) { + switch (key) { + case KEY_CLOUDSQL_POSTGRES: + return this.cloudsqlPostgresUrl; + case KEY_CLOUDSQL_MYSQL: + return this.cloudsqlMysqlUrl; + case KEY_CLOUDSQL_SQLSERVER: + return this.cloudsqlSqlserverUrl; + case KEY_ALLOYDB_POSTGRES: + return this.alloydbPostgresUrl; + case KEY_MEMORYSTORE_REDIS: + return this.memorystoreRedisUrl; + case KEY_FIRESTORE: + return this.firestoreUrl; + default: + return '#'; + } + }, + }, + fields: [ + { key: 'title', label: i18n.columnService }, + { key: 'description', label: i18n.columnDescription }, + { key: 'action', label: '' }, + ], + items: [ + { + title: i18n.cloudsqlPostgresTitle, + description: i18n.cloudsqlPostgresDescription, + action: { + key: KEY_CLOUDSQL_POSTGRES, + title: i18n.createInstance, + testId: 'button-cloudsql-postgres', + }, + }, + { + title: i18n.cloudsqlMysqlTitle, + description: i18n.cloudsqlMysqlDescription, + action: { + disabled: false, + key: KEY_CLOUDSQL_MYSQL, + title: i18n.createInstance, + testId: 'button-cloudsql-mysql', + }, + }, + { + title: i18n.cloudsqlSqlserverTitle, + description: i18n.cloudsqlSqlserverDescription, + action: { + disabled: false, + key: KEY_CLOUDSQL_SQLSERVER, + title: i18n.createInstance, + testId: 'button-cloudsql-sqlserver', + }, + }, + { + title: i18n.alloydbPostgresTitle, + description: i18n.alloydbPostgresDescription, + action: { + disabled: true, + key: KEY_ALLOYDB_POSTGRES, + title: i18n.createCluster, + testId: 'button-alloydb-postgres', + }, + }, + { + title: i18n.memorystoreRedisTitle, + description: i18n.memorystoreRedisDescription, + action: { + disabled: true, + key: KEY_MEMORYSTORE_REDIS, + title: i18n.createInstance, + testId: 'button-memorystore-redis', + }, + }, + { + title: i18n.firestoreTitle, + description: i18n.firestoreDescription, + action: { + disabled: true, + key: KEY_FIRESTORE, + title: i18n.createDatabase, + testId: 'button-firestore', + }, + }, + ], + helpUrlSecrets, + i18n, +}; +</script> + +<template> + <div class="gl-mx-3"> + <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2> + <p>{{ $options.i18n.description }}</p> + + <gl-table :fields="$options.fields" :items="$options.items"> + <template #cell(action)="{ value }"> + <gl-button + block + :disabled="value.disabled" + :href="actionUrl(value.key)" + :data-testid="value.testId" + category="secondary" + variant="confirm" + > + {{ value.title }} + </gl-button> + </template> + </gl-table> + + <gl-alert class="gl-mt-5" :dismissible="false" variant="tip"> + <gl-sprintf :message="$options.i18n.pricingAlert"> + <template #cloudsqlPricing="{ content }"> + <gl-link href="https://cloud.google.com/sql/pricing">{{ content }}</gl-link> + </template> + <template #alloydbPricing="{ content }"> + <gl-link href="https://cloud.google.com/alloydb/pricing">{{ content }}</gl-link> + </template> + <template #memorystorePricing="{ content }"> + <gl-link href="https://cloud.google.com/memorystore/docs/redis/pricing">{{ + content + }}</gl-link> + </template> + <template #firestorePricing="{ content }"> + <gl-link href="https://cloud.google.com/firestore/pricing">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + + <gl-alert class="gl-mt-5" :dismissible="false" variant="tip"> + <gl-sprintf :message="$options.i18n.secretManagersDescription"> + <template #docLink="{ content }"> + <gl-link :href="$options.helpUrlSecrets"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/deployments/index.js b/app/assets/javascripts/google_cloud/deployments/index.js new file mode 100644 index 00000000000..fcbb2209c40 --- /dev/null +++ b/app/assets/javascripts/google_cloud/deployments/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default (containerId = '#js-google-cloud-deployments') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/deployments/panel.vue b/app/assets/javascripts/google_cloud/deployments/panel.vue new file mode 100644 index 00000000000..89db132ad5e --- /dev/null +++ b/app/assets/javascripts/google_cloud/deployments/panel.vue @@ -0,0 +1,50 @@ +<script> +import GoogleCloudMenu from '../components/google_cloud_menu.vue'; +import IncubationBanner from '../components/incubation_banner.vue'; +import ServiceTable from './service_table.vue'; + +export default { + components: { + ServiceTable, + IncubationBanner, + GoogleCloudMenu, + }, + props: { + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + enableCloudRunUrl: { + type: String, + required: true, + }, + enableCloudStorageUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner /> + + <google-cloud-menu + active="deployments" + :configuration-url="configurationUrl" + :deployments-url="deploymentsUrl" + :databases-url="databasesUrl" + /> + + <service-table :cloud-run-url="enableCloudRunUrl" :cloud-storage-url="enableCloudStorageUrl" /> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/deployments/service_table.vue index 26c9fd14dc6..26c9fd14dc6 100644 --- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue +++ b/app/assets/javascripts/google_cloud/deployments/service_table.vue diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/gcp_regions/form.vue index 23011e5a5b0..23011e5a5b0 100644 --- a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue +++ b/app/assets/javascripts/google_cloud/gcp_regions/form.vue diff --git a/app/assets/javascripts/google_cloud/gcp_regions/index.js b/app/assets/javascripts/google_cloud/gcp_regions/index.js new file mode 100644 index 00000000000..da37c612805 --- /dev/null +++ b/app/assets/javascripts/google_cloud/gcp_regions/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Form from './form.vue'; + +export default (containerId = '#js-google-cloud-gcp-regions') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Form, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/gcp_regions/list.vue index 5d403d5cd65..5d403d5cd65 100644 --- a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue +++ b/app/assets/javascripts/google_cloud/gcp_regions/list.vue diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js deleted file mode 100644 index ab9e8227812..00000000000 --- a/app/assets/javascripts/google_cloud/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; -import App from './components/app.vue'; - -export default () => { - const root = '#js-google-cloud'; - const element = document.querySelector(root); - const { screen, ...attrs } = JSON.parse(element.getAttribute('data')); - return new Vue({ - el: element, - render: (createElement) => createElement(App, { props: { screen }, attrs }), - }); -}; diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/service_accounts/form.vue index faec94e735b..faec94e735b 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/form.vue diff --git a/app/assets/javascripts/google_cloud/service_accounts/index.js b/app/assets/javascripts/google_cloud/service_accounts/index.js new file mode 100644 index 00000000000..5207b44deac --- /dev/null +++ b/app/assets/javascripts/google_cloud/service_accounts/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Form from './form.vue'; + +export default (containerId = '#js-google-cloud-service-accounts') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Form, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue index 4b580c594f5..4b580c594f5 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index 2969121bf06..c8204f397ff 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -176,6 +176,14 @@ export const trackSaasTrialGetStarted = () => { }); }; +export const trackTrialAcceptTerms = () => { + if (!isSupported()) { + return; + } + + pushEvent('saasTrialAcceptTerms'); +}; + export const trackCheckout = (selectedPlan, quantity) => { if (!isSupported()) { return; diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 50b40526ee0..45c5cca68cc 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -131,7 +131,9 @@ "VulnerabilityLocationSecretDetection" ], "WorkItemWidget": [ + "WorkItemWidgetAssignees", "WorkItemWidgetDescription", - "WorkItemWidgetHierarchy" + "WorkItemWidgetHierarchy", + "WorkItemWidgetWeight" ] } diff --git a/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql b/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql new file mode 100644 index 00000000000..93335c93c1d --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql @@ -0,0 +1,7 @@ +#import "../fragments/user.fragment.graphql" + +query currentUser { + currentUser { + ...User + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql new file mode 100644 index 00000000000..07398867544 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql @@ -0,0 +1,9 @@ +#import "../fragments/user.fragment.graphql" + +query getUsersByUsernames($usernames: [String!]) { + users(usernames: $usernames) { + nodes { + ...User + } + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql new file mode 100644 index 00000000000..9c75df84e78 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql @@ -0,0 +1,9 @@ +#import "../fragments/user.fragment.graphql" + +query searchAllUsers($search: String!, $first: Int = null) { + users(search: $search, first: $first) { + nodes { + ...User + } + } +} diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 2241d57f96f..7345afb8545 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -4,13 +4,24 @@ import { GlLoadingIcon, GlBadge, GlIcon, + GlLabel, + GlButton, + GlPopover, + GlLink, GlTooltipDirective, GlSafeHtmlDirective, } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { __ } from '~/locale'; +import { + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, + ITEM_TYPE, + VISIBILITY_PRIVATE, +} from '../constants'; import eventHub from '../event_hub'; import itemActions from './item_actions.vue'; @@ -28,16 +39,17 @@ export default { GlBadge, GlLoadingIcon, GlIcon, + GlLabel, + GlButton, + GlPopover, + GlLink, UserAccessRoleBadge, - ComplianceFrameworkLabel: () => - import( - 'ee_component/vue_shared/components/compliance_framework_label/compliance_framework_label.vue' - ), itemCaret, itemTypeIcon, itemActions, itemStats, }, + inject: ['currentGroupVisibility'], props: { parentGroup: { type: Object, @@ -58,6 +70,9 @@ export default { groupDomId() { return `group-${this.group.id}`; }, + itemTestId() { + return `group-overview-item-${this.group.id}`; + }, rowClass() { return { 'is-open': this.group.isOpen, @@ -76,10 +91,10 @@ export default { return Boolean(this.group.complianceFramework?.name); }, isGroup() { - return this.group.type === 'group'; + return this.group.type === ITEM_TYPE.GROUP; }, isGroupPendingRemoval() { - return this.group.type === 'group' && this.group.pendingRemoval; + return this.group.type === ITEM_TYPE.GROUP && this.group.pendingRemoval; }, visibilityIcon() { return VISIBILITY_TYPE_ICON[this.group.visibility]; @@ -96,6 +111,13 @@ export default { showActionsMenu() { return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave); }, + shouldShowVisibilityWarning() { + return ( + this.action === 'shared' && + this.currentGroupVisibility === VISIBILITY_PRIVATE && + this.group.visibility !== VISIBILITY_PRIVATE + ); + }, }, methods: { onClickRowGroup(e) { @@ -112,6 +134,17 @@ export default { } }, }, + i18n: { + popoverTitle: __('Less restrictive visibility'), + popoverBody: __('Project visibility level is less restrictive than the group settings.'), + learnMore: __('Learn more'), + }, + shareProjectsWithGroupsHelpPagePath: helpPagePath( + 'user/project/members/share_project_with_groups', + { + anchor: 'share-a-public-project-with-private-group', + }, + ), safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, AVATAR_SHAPE_OPTION_RECT, }; @@ -120,6 +153,7 @@ export default { <template> <li :id="groupDomId" + :data-testid="itemTestId" :class="rowClass" class="group-row" :itemprop="microdata.itemprop" @@ -165,7 +199,7 @@ export default { data-testid="group-name" :href="group.relativePath" :title="group.fullName" - class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!" + class="no-expand gl-mr-3 gl-text-gray-900!" :itemprop="microdata.nameItemprop" > {{ @@ -176,20 +210,44 @@ export default { </a> <gl-icon v-gl-tooltip.hover.bottom - class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500" + class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-text-gray-500" :name="visibilityIcon" :title="visibilityTooltip" data-testid="group-visibility-icon" /> - <user-access-role-badge v-if="group.permission" class="gl-mt-3"> + <template v-if="shouldShowVisibilityWarning"> + <gl-button + ref="visibilityWarningButton" + class="gl-p-1! gl-bg-transparent! gl-mr-3" + category="tertiary" + icon="warning" + :aria-label="$options.i18n.popoverTitle" + @click.stop + /> + <gl-popover + :target="() => $refs.visibilityWarningButton.$el" + :title="$options.i18n.popoverTitle" + triggers="hover focus" + > + {{ $options.i18n.popoverBody }} + <div class="gl-mt-3"> + <gl-link + class="gl-font-sm" + :href="$options.shareProjectsWithGroupsHelpPagePath" + >{{ $options.i18n.learnMore }}</gl-link + > + </div> + </gl-popover> + </template> + <user-access-role-badge v-if="group.permission" class="gl-mr-3"> {{ group.permission }} </user-access-role-badge> - <compliance-framework-label + <gl-label v-if="hasComplianceFramework" - class="gl-mt-3" - :name="complianceFramework.name" - :color="complianceFramework.color" + :title="complianceFramework.name" + :background-color="complianceFramework.color" :description="complianceFramework.description" + size="sm" /> </div> <div v-if="group.description" class="description"> diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index f9bd8701199..983535d3e9c 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -133,6 +133,8 @@ export default { signal: this.activeApiRequestAbortController.signal, }); + this.apiLoading = false; + if (exists) { if (suggests.length) { return Promise.resolve({ exists, suggests }); @@ -148,14 +150,14 @@ export default { return Promise.resolve({ exists, suggests }); } catch (error) { if (!axios.isCancel(error)) { + this.apiLoading = false; + createAlert({ message: this.$options.i18n.apiErrorMessage, }); } return Promise.reject(); - } finally { - this.apiLoading = false; } }, handlePathInput(value) { diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index cacba2dfd23..29981d09155 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -28,28 +28,32 @@ export const ITEM_TYPE = { GROUP: 'group', }; +export const VISIBILITY_PUBLIC = 'public'; +export const VISIBILITY_INTERNAL = 'internal'; +export const VISIBILITY_PRIVATE = 'private'; + export const GROUP_VISIBILITY_TYPE = { - public: __( + [VISIBILITY_PUBLIC]: __( 'Public - The group and any public projects can be viewed without any authentication.', ), - internal: __( + [VISIBILITY_INTERNAL]: __( 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', ), - private: __('Private - The group and its projects can only be viewed by members.'), + [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'), }; export const PROJECT_VISIBILITY_TYPE = { - public: __('Public - The project can be accessed without any authentication.'), - internal: __( + [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'), + [VISIBILITY_INTERNAL]: __( 'Internal - The project can be accessed by any logged in user except external users.', ), - private: __( + [VISIBILITY_PRIVATE]: __( 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', ), }; export const VISIBILITY_TYPE_ICON = { - public: 'earth', - internal: 'shield', - private: 'lock', + [VISIBILITY_PUBLIC]: 'earth', + [VISIBILITY_INTERNAL]: 'shield', + [VISIBILITY_PRIVATE]: 'lock', }; diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index dfcee80aec7..a502fcd31ad 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -55,6 +55,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { renderEmptyState, canCreateSubgroups, canCreateProjects, + currentGroupVisibility, }, } = this.$options.el; @@ -67,6 +68,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { renderEmptyState: parseBoolean(renderEmptyState), canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), + currentGroupVisibility, }; }, data() { diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 360a8d3bf8d..9b6113c7444 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import Vue from 'vue'; import { highCountTrim } from '~/lib/utils/text_utility'; import Tracking from '~/tracking'; @@ -12,12 +11,18 @@ import Translate from '~/vue_shared/translate'; * @param {String} count */ export default function initTodoToggle() { - $(document).on('todo:toggle', (e, count) => { - const updatedCount = count || e?.detail?.count || 0; - const $todoPendingCount = $('.js-todos-count'); + document.addEventListener('todo:toggle', (e) => { + const updatedCount = e.detail.count || 0; + const todoPendingCount = document.querySelector('.js-todos-count'); - $todoPendingCount.text(highCountTrim(updatedCount)); - $todoPendingCount.toggleClass('hidden', updatedCount === 0); + if (todoPendingCount) { + todoPendingCount.textContent = highCountTrim(updatedCount); + if (updatedCount === 0) { + todoPendingCount.classList.add('hidden'); + } else { + todoPendingCount.classList.remove('hidden'); + } + } }); } @@ -85,7 +90,7 @@ function initStatusTriggers() { function trackShowUserDropdownLink(trackEvent, elToTrack, el) { const { trackLabel, trackProperty } = elToTrack.dataset; - $(el).on('shown.bs.dropdown', () => { + el.addEventListener('shown.bs.dropdown', () => { Tracking.event(document.body.dataset.page, trackEvent, { label: trackLabel, property: trackProperty, diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index adf304aebc7..0c4f9640972 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,8 +1,17 @@ <script> -import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { + GlSearchBoxByType, + GlOutsideDirective as Outside, + GlIcon, + GlToken, + GlSafeHtmlDirective as SafeHtml, + GlTooltipDirective, + GlResizeObserverDirective, +} from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { debounce } from 'lodash'; import { visitUrl } from '~/lib/utils/url_utility'; +import { truncate } from '~/lib/utils/text_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; @@ -12,6 +21,8 @@ import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, + SCOPE_TOKEN_MAX_LENGTH, + INPUT_FIELD_PADDING, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -34,14 +45,22 @@ export default { 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.', ), searchResultsLoading: s__('GlobalSearch|Search results are loading'), + searchResultsScope: s__('GlobalSearch|in %{scope}'), + kbdHelp: sprintf( + s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), }, - directives: { Outside }, + directives: { SafeHtml, Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, HeaderSearchDefaultItems, HeaderSearchScopedItems, HeaderSearchAutocompleteItems, DropdownKeyboardNavigation, + GlIcon, + GlToken, }, data() { return { @@ -50,8 +69,8 @@ export default { }; }, computed: { - ...mapState(['search', 'loading']), - ...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']), + ...mapState(['search', 'loading', 'searchContext']), + ...mapGetters(['searchQuery', 'searchOptions']), searchText: { get() { return this.search; @@ -70,16 +89,17 @@ export default { return Boolean(gon?.current_username); }, showSearchDropdown() { - const hasResultsUnderMinCharacters = - this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true; + if (!this.showDropdown || !this.isLoggedIn) { + return false; + } - return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters; + return this.searchOptions?.length > 0; }, showDefaultItems() { return !this.searchText; }, - showShortcuts() { - return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS; + showScopes() { + return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, defaultIndex() { if (this.showDefaultItems) { @@ -88,11 +108,11 @@ export default { return FIRST_DROPDOWN_INDEX; }, + searchInputDescribeBy() { if (this.isLoggedIn) { return this.$options.i18n.searchInputDescribeByWithDropdown; } - return this.$options.i18n.searchInputDescribeByNoDropdown; }, dropdownResultsDescription() { @@ -112,8 +132,26 @@ export default { count: this.searchOptions.length, }); }, - headerSearchActivityDescriptor() { - return this.showDropdown ? 'is-active' : 'is-not-active'; + searchBarStateIndicator() { + const hasIcon = + this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon'; + const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching'; + const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active'; + return `${isActive} ${isSearching} ${hasIcon}`; + }, + searchBarItem() { + return this.searchOptions?.[0]; + }, + infieldHelpContent() { + return this.searchBarItem?.scope || this.searchBarItem?.description; + }, + infieldHelpIcon() { + return this.searchBarItem?.icon; + }, + scopeTokenTitle() { + return sprintf(this.$options.i18n.searchResultsScope, { + scope: this.infieldHelpContent, + }); }, }, methods: { @@ -127,6 +165,9 @@ export default { this.$emit('toggleDropdown', this.showDropdown); }, submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { + return null; + } return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { @@ -136,8 +177,19 @@ export default { this.fetchAutocompleteOptions(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + getTruncatedScope(scope) { + return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); + }, + observeTokenWidth({ contentRect: { width } }) { + const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input'); + if (!inputField) { + return; + } + inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; + }, }, SEARCH_BOX_INDEX, + FIRST_DROPDOWN_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, }; @@ -149,10 +201,12 @@ export default { role="search" :aria-label="$options.i18n.searchGitlab" class="header-search gl-relative gl-rounded-base gl-w-full" - :class="headerSearchActivityDescriptor" + :class="searchBarStateIndicator" + data-testid="header-search-form" > <gl-search-box-by-type id="search" + ref="searchInputBox" v-model="searchText" role="searchbox" class="gl-z-index-1" @@ -165,7 +219,34 @@ export default { @click="openDropdown" @input="getAutocompleteOptions" @keydown.enter.stop.prevent="submitSearch" + @keydown.esc.stop.prevent="closeDropdown" /> + <gl-token + v-if="showScopes" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help" + :view-only="true" + :title="scopeTokenTitle" + ><gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" + />{{ + getTruncatedScope( + sprintf($options.i18n.searchResultsScope, { + scope: infieldHelpContent, + }), + ) + }} + </gl-token> + <kbd + v-gl-tooltip.bottom.hover.html + class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" + :title="$options.i18n.kbdHelp" + >/</kbd + > <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ searchInputDescribeBy }}</span> @@ -187,7 +268,7 @@ export default { <dropdown-keyboard-navigation v-model="currentFocusIndex" :max="searchOptions.length - 1" - :min="$options.SEARCH_BOX_INDEX" + :min="$options.FIRST_DROPDOWN_INDEX" :default-index="defaultIndex" @tab="closeDropdown" /> @@ -197,7 +278,7 @@ export default { /> <template v-else> <header-search-scoped-items - v-if="showShortcuts" + v-if="showScopes" :current-focused-option="currentFocusedOption" /> <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 34d1bd71399..f5be1bcb786 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,13 +1,16 @@ <script> -import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; -import { __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import { truncate } from '~/lib/utils/text_utility'; +import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; export default { name: 'HeaderSearchScopedItems', components: { GlDropdownItem, - GlDropdownDivider, + GlIcon, + GlToken, }, props: { currentFocusedOption: { @@ -25,12 +28,21 @@ export default { return this.currentFocusedOption?.html_id === option.html_id; }, ariaLabel(option) { - return sprintf(__('%{search} %{description} %{scope}'), { + return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), { search: this.search, - description: option.description, + description: option.description || option.icon, scope: option.scope || '', }); }, + titleLabel(option) { + return sprintf(s__('GlobalSearch|in %{scope}'), { + search: this.search, + scope: option.scope || option.description, + }); + }, + getTruncatedScope(scope) { + return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); + }, }, }; </script> @@ -42,18 +54,30 @@ export default { :id="option.html_id" :ref="option.html_id" :key="option.html_id" + class="gl-max-w-full" :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" :aria-selected="isOptionFocused(option)" :aria-label="ariaLabel(option)" tabindex="-1" :href="option.url" + :title="titleLabel(option)" > - <span aria-hidden="true"> - "<span class="gl-font-weight-bold">{{ search }}</span - >" {{ option.description }} - <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + <span + ref="token-text-content" + class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full" + > + <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" /> + <span class="gl-flex-grow-1 gl-relative"> + <gl-token + class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!" + :view-only="true" + > + <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(option)) }}</span> + </gl-token> + {{ search }} + </span> </span> </gl-dropdown-item> - <gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" /> </div> </template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 045a552efb0..a026386b2bd 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -10,15 +10,21 @@ export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a re export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created"); -export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab'); +export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab'); -export const MSG_IN_GROUP = s__('GlobalSearch|in group'); +export const MSG_IN_GROUP = s__('GlobalSearch|group'); -export const MSG_IN_PROJECT = s__('GlobalSearch|in project'); +export const MSG_IN_PROJECT = s__('GlobalSearch|project'); -export const GROUPS_CATEGORY = 'Groups'; +export const ICON_PROJECT = 'project'; -export const PROJECTS_CATEGORY = 'Projects'; +export const ICON_GROUP = 'group'; + +export const ICON_SUBGROUP = 'subgroup'; + +export const GROUPS_CATEGORY = s__('GlobalSearch|Groups'); + +export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects'); export const ISSUES_CATEGORY = 'Recent issues'; @@ -39,3 +45,9 @@ export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; + +export const SCOPE_TOKEN_MAX_LENGTH = 36; + +export const INPUT_FIELD_PADDING = 52; + +export const HEADER_INIT_EVENTS = ['input', 'focus']; diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js new file mode 100644 index 00000000000..4e9404007ec --- /dev/null +++ b/app/assets/javascripts/header_search/init.js @@ -0,0 +1,53 @@ +import * as Sentry from '@sentry/browser'; +import { HEADER_INIT_EVENTS } from './constants'; + +async function eventHandler(callback = () => {}) { + if (this.newHeaderSearchFeatureFlag) { + const { initHeaderSearchApp } = await import( + /* webpackChunkName: 'globalSearch' */ '~/header_search' + ).catch((error) => Sentry.captureException(error)); + + // In case the user started searching before we bootstrapped, + // let's pass the search along. + const initialSearchValue = this.searchInputBox.value; + initHeaderSearchApp(initialSearchValue); + + // this is new #search input element. We need to re-find it. + // And re-focus in it. + document.querySelector('#search').focus(); + callback(); + return; + } + + const { default: initSearchAutocomplete } = await import( + /* webpackChunkName: 'globalSearch' */ '../search_autocomplete' + ).catch((error) => Sentry.captureException(error)); + + const searchDropdown = initSearchAutocomplete(); + searchDropdown.onSearchInputFocus(); + callback(); +} + +function cleanEventListeners() { + HEADER_INIT_EVENTS.forEach((eventType) => { + document.querySelector('#search').removeEventListener(eventType, eventHandler); + }); +} + +function initHeaderSearch() { + const searchInputBox = document.querySelector('#search'); + + HEADER_INIT_EVENTS.forEach((eventType) => { + searchInputBox?.addEventListener( + eventType, + eventHandler.bind( + { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch }, + cleanEventListeners, + ), + { once: true }, + ); + }); +} + +export default initHeaderSearch; +export { eventHandler, cleanEventListeners }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index 7d08aa859fb..da7bccd35c0 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -7,9 +7,13 @@ import { MSG_MR_ASSIGNED_TO_ME, MSG_MR_IM_REVIEWER, MSG_MR_IVE_CREATED, - MSG_IN_PROJECT, - MSG_IN_GROUP, + ICON_GROUP, + ICON_SUBGROUP, + ICON_PROJECT, MSG_IN_ALL_GITLAB, + PROJECTS_CATEGORY, + GROUPS_CATEGORY, + SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '../constants'; export const searchQuery = (state) => { @@ -149,7 +153,8 @@ export const scopedSearchOptions = (state, getters) => { options.push({ html_id: 'scoped-in-project', scope: state.searchContext.project?.name || '', - description: MSG_IN_PROJECT, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, url: getters.projectUrl, }); } @@ -158,7 +163,8 @@ export const scopedSearchOptions = (state, getters) => { options.push({ html_id: 'scoped-in-group', scope: state.searchContext.group?.name || '', - description: MSG_IN_GROUP, + scopeCategory: GROUPS_CATEGORY, + icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, url: getters.groupUrl, }); } @@ -190,6 +196,7 @@ export const autocompleteGroupedSearchOptions = (state) => { results.push(groupedOptions[option.category]); } }); + return results; }; @@ -205,5 +212,9 @@ export const searchOptions = (state, getters) => { [], ); + if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return sortedAutocompleteOptions; + } + return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); }; diff --git a/app/assets/javascripts/helpers/help_page_helper.js b/app/assets/javascripts/helpers/help_page_helper.js index 0e824548646..21d27b5fea9 100644 --- a/app/assets/javascripts/helpers/help_page_helper.js +++ b/app/assets/javascripts/helpers/help_page_helper.js @@ -7,9 +7,10 @@ const HELP_PAGE_URL_ROOT = '/help/'; * * This is designed to mirror the Ruby `help_page_path` helper function, so that * the two can be used interchangeably. - * @param {String} path - Path to doc file relative to the doc/ directory in the GitLab repository. - * Optionally, including `.md` or `.html` prefix - * @param {String} options.anchor - Name of the anchor to scroll to on the documentation page. + * @param {string} path - Path to doc file relative to the doc/ directory in the GitLab repository. + * Optionally, including `.md` or `.html` prefix + * @param {object} [options] + * @param {string} [options.anchor] - Name of the anchor to scroll to on the documentation page. */ export const helpPagePath = (path, { anchor = '' } = {}) => { let helpPath = joinPaths(gon.relative_url_root || '/', HELP_PAGE_URL_ROOT, path); diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 2df998d7518..6998f8ef0c4 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -54,25 +54,25 @@ export default { <ide-tree-list @tree-ready="$emit('tree-ready')"> <template #header> {{ __('Edit') }} - <div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions"> + <div class="ide-tree-actions gl-ml-auto gl-display-flex" data-testid="ide-root-actions"> <new-entry-button :label="__('New file')" :show-label="false" - class="d-flex border-0 p-0 mr-3" + class="gl-display-flex gl-border-0 gl-p-0 gl-mr-5" icon="doc-new" data-qa-selector="new_file_button" @click="createNewFile()" /> <upload :show-label="false" - class="d-flex mr-3" - button-css-classes="border-0 p-0" + class="gl-display-flex gl-mr-5" + button-css-classes="gl-border-0 gl-p-0" @create="createTempEntry" /> <new-entry-button :label="__('New directory')" :show-label="false" - class="d-flex border-0 p-0" + class="gl-display-flex gl-border-0 gl-p-0" icon="folder-new" data-qa-selector="new_directory_button" @click="createNewFolder()" diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index e3c230f7660..d6207d4a557 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -68,6 +68,10 @@ export default { }, methods: { ...mapActions(['createTempEntry', 'renameEntry']), + submitAndClose() { + this.submitForm(); + this.close(); + }, submitForm() { this.entryName = trimPathComponents(this.entryName); @@ -161,15 +165,17 @@ export default { <div class="form-group row"> <label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label> <div class="col-sm-10"> - <input - ref="fieldName" - v-model.trim="entryName" - type="text" - class="form-control" - data-testid="file-name-field" - data-qa-selector="file_name_field" - :placeholder="placeholder" - /> + <form data-testid="file-name-form" @submit.prevent="submitAndClose"> + <input + ref="fieldName" + v-model.trim="entryName" + type="text" + class="form-control" + data-testid="file-name-field" + data-qa-selector="file_name_field" + :placeholder="placeholder" + /> + </form> <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list"> <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <gl-button diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index d71ac766933..a1396995a3b 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,4 +1,5 @@ <script> +import { GlTabs, GlTab } from '@gitlab/ui'; import { debounce } from 'lodash'; import { mapState, mapGetters, mapActions } from 'vuex'; import { @@ -45,6 +46,8 @@ const MARKDOWN_FILE_TYPE = 'markdown'; export default { name: 'RepoEditor', components: { + GlTabs, + GlTab, FileAlert, ContentViewer, DiffViewer, @@ -121,16 +124,6 @@ export default { isPreviewViewMode() { return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW; }, - editTabCSS() { - return { - active: this.isEditorViewMode, - }; - }, - previewTabCSS() { - return { - active: this.isPreviewViewMode, - }; - }, showEditor() { return !this.shouldHideEditor && this.isEditorViewMode; }, @@ -487,28 +480,18 @@ export default { <template> <div id="ide" class="blob-viewer-container blob-editor-container"> - <div v-if="showTabs" class="ide-mode-tabs clearfix"> - <ul class="nav-links float-left border-bottom-0"> - <li :class="editTabCSS"> - <a - href="javascript:void(0);" - role="button" - data-testid="edit-tab" - @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" - >{{ __('Edit') }}</a - > - </li> - <li :class="previewTabCSS"> - <a - href="javascript:void(0);" - role="button" - data-testid="preview-tab" - @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" - >{{ previewMode.previewTitle }}</a - > - </li> - </ul> - </div> + <gl-tabs v-if="showTabs" content-class="gl-display-none"> + <gl-tab + :title="__('Edit')" + data-testid="edit-tab" + @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" + /> + <gl-tab + :title="previewMode.previewTitle" + data-testid="preview-tab" + @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" + /> + </gl-tabs> <file-alert v-if="alertKey" :alert-key="alertKey" /> <file-templates-bar v-else-if="showFileTemplatesBar(file.name)" /> <div 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 6b96fa7c45c..98ee858ca91 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 @@ -78,6 +78,11 @@ export default { type: String, required: true, }, + defaultTargetNamespace: { + type: Number, + required: false, + default: null, + }, }, data() { @@ -433,7 +438,15 @@ export default { return this.importTargets[group.id]; } - const defaultTargetNamespace = this.availableNamespaces[0] ?? ROOT_NAMESPACE; + // If we've reached this Vue application we have at least one potential import destination + const defaultTargetNamespace = + // first option: namespace id was explicitly provided + this.availableNamespaces.find((ns) => ns.id === this.defaultTargetNamespace) ?? + // second option: first available namespace + this.availableNamespaces[0] ?? + // last resort: if no namespaces are available - suggest creating new namespace at root + ROOT_NAMESPACE; + let importTarget; if (group.lastImportTarget) { const targetNamespace = [ROOT_NAMESPACE, ...this.availableNamespaces].find( diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index 02af0db7f9a..5d7e7911f5a 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -15,9 +15,10 @@ export function mountImportGroupsApp(mountElement) { availableNamespacesPath, createBulkImportPath, jobsPath, + historyPath, + defaultTargetNamespace, sourceUrl, groupPathRegex, - historyPath, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ @@ -40,6 +41,7 @@ export function mountImportGroupsApp(mountElement) { jobsPath, groupPathRegex: new RegExp(`^(${groupPathRegex})$`), historyPath, + defaultTargetNamespace: parseInt(defaultTargetNamespace, 10) || null, }, }); }, diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js index 98bfa48740c..6c6cadedf00 100644 --- a/app/assets/javascripts/init_confirm_danger.js +++ b/app/assets/javascripts/init_confirm_danger.js @@ -12,11 +12,11 @@ export default () => { phrase, buttonText, buttonClass = '', - buttonTestid = null, - buttonVariant = null, + buttonTestid, + buttonVariant, confirmDangerMessage, confirmButtonText = null, - disabled = false, + disabled, additionalInformation, htmlConfirmationMessage, } = el.dataset; diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index e4f6e931ec0..437bcc39886 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -18,7 +18,7 @@ export const overrideDropdownDescriptions = { }; export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__( - 'Integrations|Connection failed. Please check your settings.', + 'Integrations|Connection failed. Check your integration settings.', ); export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.'); export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.'); @@ -83,3 +83,11 @@ export const billingPlanNames = { [billingPlans.PREMIUM]: s__('BillingPlans|Premium'), [billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'), }; + +const INTEGRATION_TYPE_SLACK = 'slack'; +const INTEGRATION_TYPE_MATTERMOST = 'mattermost'; + +export const placeholderForType = { + [INTEGRATION_TYPE_SLACK]: __('#general, #development'), + [INTEGRATION_TYPE_MATTERMOST]: __('my-channel'), +}; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 9307d7c2d3d..f1f574c6424 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -140,15 +140,24 @@ export default { this.isTesting = true; testIntegrationSettings(this.propsSource.testPath, this.getFormData()) - .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => { - if (error) { - this.setIsValidated(); - this.$toast.show(message); - return; - } + .then( + ({ + data: { + error, + message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + service_response: serviceResponse, + }, + }) => { + if (error) { + const errorMessage = serviceResponse ? [message, serviceResponse].join(' ') : message; + this.setIsValidated(); + this.$toast.show(errorMessage); + return; + } - this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE); - }) + this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE); + }, + ) .catch((error) => { this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE); Sentry.captureException(error); @@ -284,6 +293,7 @@ export default { :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" + :data-qa-selector="`${field.name}_div`" /> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue index 9e1ad24ae9f..b8fd8995744 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue @@ -33,6 +33,7 @@ export default { :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" + :data-qa-selector="`${field.name}_div`" /> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 92042a5c981..67647cadf19 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -1,17 +1,7 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { __ } from '~/locale'; - -const typeWithPlaceholder = { - SLACK: 'slack', - MATTERMOST: 'mattermost', -}; - -const placeholderForType = { - [typeWithPlaceholder.SLACK]: __('#general, #development'), - [typeWithPlaceholder.MATTERMOST]: __('my-channel'), -}; +import { placeholderForType } from 'jh_else_ce/integrations/constants'; export default { name: 'TriggerFields', diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index fc14b2eba6a..e7f5211dc25 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -136,6 +136,7 @@ export default { v-for="group in groups" :key="group.id" :name="group.name" + data-qa-selector="group_select_dropdown_item" @click="selectGroup(group)" > <gl-avatar-labeled diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index fb6c376cfe6..31b7fd4cc42 100644 --- a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -1,21 +1,20 @@ <script> -import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { importProjectMembers } from '~/api/projects_api'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; +import eventHub from '../event_hub'; import ProjectSelect from './project_select.vue'; export default { + name: 'ImportProjectMembersModal', components: { - GlButton, GlFormGroup, GlModal, GlSprintf, ProjectSelect, }, - directives: { - GlModal: GlModalDirective, - }, props: { projectId: { type: String, @@ -45,8 +44,33 @@ export default { validationState() { return this.invalidFeedbackMessage === '' ? null : false; }, + actionPrimary() { + return { + text: this.$options.i18n.modalPrimaryButton, + attributes: { + variant: 'confirm', + disabled: this.importDisabled, + loading: this.isLoading, + }, + }; + }, + actionCancel() { + return { text: this.$options.i18n.modalCancelButton }; + }, + }, + mounted() { + eventHub.$on('openProjectMembersModal', () => { + this.openModal(); + }); }, methods: { + openModal() { + this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId); + }, + resetFields() { + this.invalidFeedbackMessage = ''; + this.projectToBeImported = {}; + }, submitImport() { this.isLoading = true; return importProjectMembers(this.projectId, this.projectToBeImported.id) @@ -57,11 +81,6 @@ export default { this.projectToBeImported = {}; }); }, - closeModal() { - this.invalidFeedbackMessage = ''; - - this.$refs.modal.hide(); - }, showToastMessage() { this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions); @@ -79,7 +98,6 @@ export default { }; }, i18n: { - buttonText: s__('ImportAProjectModal|Import from a project'), projectLabel: __('Project'), modalTitle: s__('ImportAProjectModal|Import members from another project'), modalIntro: s__( @@ -95,63 +113,37 @@ export default { }, projectSelectLabelId: 'project-select', modalId: uniqueId('import-a-project-modal-'), - formClasses: 'gl-mt-3 gl-sm-w-auto gl-w-full', - buttonClasses: 'gl-w-full', }; </script> <template> - <form :class="$options.formClasses"> - <gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{ - $options.i18n.buttonText - }}</gl-button> - - <gl-modal - ref="modal" - :modal-id="$options.modalId" - size="sm" - :title="$options.i18n.modalTitle" - ok-variant="danger" - footer-class="gl-bg-gray-10 gl-p-5" + <gl-modal + ref="modal" + :modal-id="$options.modalId" + size="sm" + :title="$options.i18n.modalTitle" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + @primary="submitImport" + @hidden="resetFields" + > + <p ref="modalIntro"> + <gl-sprintf :message="modalIntro"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <gl-form-group + :invalid-feedback="invalidFeedbackMessage" + :state="validationState" + data-testid="form-group" > - <div> - <p ref="modalIntro"> - <gl-sprintf :message="modalIntro"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <gl-form-group - :invalid-feedback="invalidFeedbackMessage" - :state="validationState" - data-testid="form-group" - > - <label :id="$options.projectSelectLabelId" class="col-form-label">{{ - $options.i18n.projectLabel - }}</label> - <project-select v-model="projectToBeImported" /> - </gl-form-group> - <p>{{ $options.i18n.modalHelpText }}</p> - </div> - <template #modal-footer> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0" - > - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.i18n.modalCancelButton }} - </gl-button> - <div class="gl-mr-3"></div> - <gl-button - :disabled="importDisabled" - :loading="isLoading" - variant="confirm" - data-testid="import-button" - @click="submitImport" - >{{ $options.i18n.modalPrimaryButton }}</gl-button - > - </div> - </template> - </gl-modal> - </form> + <label :id="$options.projectSelectLabelId" class="col-form-label">{{ + $options.i18n.projectLabel + }}</label> + <project-select v-model="projectToBeImported" /> + </gl-form-group> + <p>{{ $options.i18n.modalHelpText }}</p> + </gl-modal> </template> diff --git a/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue new file mode 100644 index 00000000000..5781abb41b7 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue @@ -0,0 +1,34 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('ImportAProjectModal|Import from a project'), + }, + classes: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openProjectMembersModal'); + }, + }, +}; +</script> + +<template> + <gl-button :class="classes" @click="openModal"> + {{ displayText }} + </gl-button> +</template> 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 d597c7e53bb..b71cfbb6112 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -7,12 +7,13 @@ import { GlSprintf, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString, uniqueId } from 'lodash'; +import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; +import { n__ } from '~/locale'; import { CLOSE_TO_LIMIT_COUNT, USERS_FILTER_ALL, @@ -21,7 +22,8 @@ import { LEARN_GITLAB, } from '../constants'; import eventHub from '../event_hub'; -import { responseMessageFromSuccess } from '../utils/response_message_parser'; +import { responseFromSuccess } from '../utils/response_message_parser'; +import { memberName } from '../utils/member_utils'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import ModalConfetti from './confetti.vue'; import MembersTokenSelect from './members_token_select.vue'; @@ -101,6 +103,7 @@ export default { isLoading: false, modalId: uniqueId('invite-members-modal-'), newUsersToInvite: [], + invalidMembers: {}, selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], source: 'unknown', @@ -125,6 +128,16 @@ export default { inviteDisabled() { return this.newUsersToInvite.length === 0; }, + hasInvalidMembers() { + return !isEmpty(this.invalidMembers); + }, + memberErrorTitle() { + return n__( + "InviteMembersModal|The following member couldn't be invited", + "InviteMembersModal|The following %d members couldn't be invited", + Object.keys(this.invalidMembers).length, + ); + }, tasksToBeDoneEnabled() { return ( (getParameterValues('open_modal')[0] === 'invite_members_for_task' || @@ -218,7 +231,7 @@ export default { }, sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; - this.invalidFeedbackMessage = ''; + this.clearValidation(); const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); @@ -242,12 +255,10 @@ export default { ...userId, }) .then((response) => { - const message = responseMessageFromSuccess(response); + const { error, message } = responseFromSuccess(response); - if (message) { - this.showInvalidFeedbackMessage({ - response: { data: { message } }, - }); + if (error) { + this.showMemberErrors(message); } else { this.showSuccessMessage(); } @@ -257,6 +268,13 @@ export default { this.isLoading = false; }); }, + showMemberErrors(message) { + this.invalidMembers = message; + }, + tokenName(username) { + // initial token creation hits this and nothing is found... so safe navigation + return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; + }, trackinviteMembersForTask() { const label = 'selected_tasks_to_be_done'; const property = this.selectedTasksToBeDone.join(','); @@ -264,8 +282,8 @@ export default { tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, resetFields() { + this.clearValidation(); this.isLoading = false; - this.invalidFeedbackMessage = ''; this.newUsersToInvite = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; @@ -287,6 +305,11 @@ export default { }, clearValidation() { this.invalidFeedbackMessage = ''; + this.invalidMembers = {}; + }, + removeToken(token) { + delete this.invalidMembers[memberName(token)]; + this.invalidMembers = { ...this.invalidMembers }; }, }, labels: MEMBER_MODAL_LABELS, @@ -324,23 +347,40 @@ export default { <modal-confetti v-if="isCelebration" /> </template> - <template #user-limit-notification> + <template #alert> + <gl-alert + v-if="hasInvalidMembers" + variant="danger" + :dismissible="false" + :title="memberErrorTitle" + data-testid="alert-member-error" + > + {{ $options.labels.memberErrorListText }} + <ul class="gl-pl-5"> + <li v-for="(error, member) in invalidMembers" :key="member"> + <strong>{{ tokenName(member) }}:</strong> {{ error }} + </li> + </ul> + </gl-alert> <user-limit-notification + v-else :close-to-limit="closeToLimit" :reached-limit="reachedLimit" :users-limit-dataset="usersLimitDataset" /> </template> - <template #select="{ validationState, labelId }"> + <template #select="{ exceptionState, labelId }"> <members-token-select v-model="newUsersToInvite" class="gl-mb-2" - :validation-state="validationState" + :exception-state="exceptionState" :aria-labelledby="labelId" :users-filter="usersFilter" :filter-id="filterId" + :invalid-members="invalidMembers" @clear="clearValidation" + @token-remove="removeToken" /> </template> <template #form-after> 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 90d266c3155..f917ebc35c2 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -159,7 +159,7 @@ export default { introText() { return sprintf(this.labelIntroText, { name: this.name }); }, - validationState() { + exceptionState() { return this.invalidFeedbackMessage ? false : null; }, selectLabelId() { @@ -306,11 +306,11 @@ export default { <slot name="intro-text-after"></slot> </div> - <slot name="user-limit-notification"></slot> + <slot name="alert"></slot> <gl-form-group :invalid-feedback="invalidFeedbackMessage" - :state="validationState" + :state="exceptionState" data-testid="members-form-group" > <template #description> @@ -320,7 +320,7 @@ export default { <label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label> <gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled /> - <slot v-else name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot> + <slot v-else name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot> </gl-form-group> <template v-if="!reachedLimit"> 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 30c9294344e..b2bcb9a5906 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -3,6 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@ import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; +import { memberName } from '../utils/member_utils'; import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants'; export default { @@ -23,7 +24,7 @@ export default { type: String, required: true, }, - validationState: { + exceptionState: { type: Boolean, required: false, default: false, @@ -38,6 +39,10 @@ export default { required: false, default: null, }, + invalidMembers: { + type: Object, + required: true, + }, }, data() { return { @@ -109,13 +114,18 @@ export default { this.hasBeenFocused = true; }, - handleTokenRemove() { + handleTokenRemove(value) { if (this.selectedTokens.length) { + this.$emit('token-remove', value); + return; } this.$emit('clear'); }, + hasError(token) { + return Object.keys(this.invalidMembers).includes(memberName(token)); + }, }, defaultQueryOptions: { without_project_bots: true, active: true }, i18n: { @@ -127,7 +137,7 @@ export default { <template> <gl-token-selector v-model="selectedTokens" - :state="validationState" + :state="exceptionState" :dropdown-items="users" :loading="loading" :allow-user-defined-tokens="emailIsValid" @@ -145,8 +155,19 @@ export default { @token-remove="handleTokenRemove" > <template #token-content="{ token }"> - <gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" /> - <gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" /> + <gl-icon + v-if="hasError(token)" + name="error" + :size="16" + class="gl-mr-2" + :data-testid="`error-icon-${token.id}`" + /> + <gl-avatar + v-else-if="token.avatar_url" + :src="token.avatar_url" + :size="16" + data-testid="token-avatar" + /> {{ token.name }} </template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index beb8f5b5aab..6141e5e9e0b 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -74,6 +74,9 @@ export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage member export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); +export const MEMBER_ERROR_LIST_TEXT = s__( + 'InviteMembersModal|Review the invite errors and try again:', +); export const MEMBER_MODAL_LABELS = { modal: { @@ -109,6 +112,7 @@ export const MEMBER_MODAL_LABELS = { title: MEMBERS_TASKS_PROJECTS_TITLE, }, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, + memberErrorListText: MEMBER_ERROR_LIST_TEXT, }; export const GROUP_MODAL_LABELS = { diff --git a/app/assets/javascripts/invite_members/init_import_a_project_modal.js b/app/assets/javascripts/invite_members/init_import_a_project_modal.js deleted file mode 100644 index 954347467de..00000000000 --- a/app/assets/javascripts/invite_members/init_import_a_project_modal.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue'; - -export default function initImportAProjectModal() { - const el = document.querySelector('.js-import-a-project-modal'); - - if (!el) { - return false; - } - - const { projectId, projectName } = el.dataset; - - return new Vue({ - el, - render: (createElement) => - createElement(ImportAProjectModal, { - props: { - projectId, - projectName, - }, - }), - }); -} diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js new file mode 100644 index 00000000000..daaa1315884 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; + +export default function initImportProjectMembersModal() { + const el = document.querySelector('.js-import-project-members-modal'); + + if (!el) { + return false; + } + + const { projectId, projectName } = el.dataset; + + return new Vue({ + el, + render: (createElement) => + createElement(ImportProjectMembersModal, { + props: { + projectId, + projectName, + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_import_project_members_trigger.js b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js new file mode 100644 index 00000000000..66a9bf118d2 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue'; + +export default function initImportProjectMembersTrigger() { + const el = document.querySelector('.js-import-project-members-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: (createElement) => + createElement(ImportProjectMembersTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js new file mode 100644 index 00000000000..d85162626f1 --- /dev/null +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -0,0 +1,4 @@ +export function memberName(member) { + // user defined tokens(invites by email) will have email in `name` and will not contain `username` + return member.username || member.name; +} diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js index db8ac303dc4..6e6431b89d9 100644 --- a/app/assets/javascripts/invite_members/utils/response_message_parser.js +++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js @@ -1,15 +1,4 @@ -import { isString } from 'lodash'; - -function responseKeyedMessageParsed(keyedMessage) { - try { - const keys = Object.keys(keyedMessage); - const msg = keyedMessage[keys[0]]; - - return msg; - } catch { - return ''; - } -} +import { isString, isArray } from 'lodash'; export function responseMessageFromError(response) { if (!response?.response?.data) { @@ -23,9 +12,9 @@ export function responseMessageFromError(response) { return data.error || data.message?.error || data.message || ''; } -export function responseMessageFromSuccess(response) { +export function responseFromSuccess(response) { if (!response?.data) { - return ''; + return { error: false }; } const { data } = response; @@ -34,11 +23,19 @@ export function responseMessageFromSuccess(response) { const { message } = data; if (isString(message)) { - return message; + return { message, error: true }; + } + + if (isArray(message)) { + return { message: message[0], error: true }; } + // we assume object now with our keyed format + return { message: { ...message }, error: true }; + } - return responseKeyedMessageParsed(message); + if (data.error) { + return { message: data.error, error: true }; } - return data.error || ''; + return { error: false }; } diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js index d46354e240a..8a55176fed0 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -54,24 +54,23 @@ export default class IssuableBulkUpdateSidebar { new MilestoneSelect(); subscriptionSelect(); + // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy + // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at + // runtime this block won't execute. if (IS_EE) { - import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle') + import('ee_else_ce/vue_shared/components/sidebar/health_status_select/health_status_bundle') .then(({ default: HealthStatusSelect }) => { HealthStatusSelect(); }) .catch(() => {}); - } - if (IS_EE) { - import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle') + import('ee_else_ce/vue_shared/components/sidebar/epics_select/epics_select_bundle') .then(({ default: EpicSelect }) => { EpicSelect(); }) .catch(() => {}); - } - if (IS_EE) { - import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle') + import('ee_else_ce/vue_shared/components/sidebar/iterations_dropdown_bundle') .then(({ default: iterationsDropdown }) => { iterationsDropdown(); }) diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index e6379b35f7a..a505a988360 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -84,7 +84,7 @@ export default { <gl-icon v-if="hasState" ref="iconElementXL" - class="mr-2 d-block" + class="gl-mr-3" :class="iconClasses" :name="iconName" :title="stateTitle" diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 38453072af8..cc2608b5c62 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -68,8 +68,7 @@ export default class IssuableForm { this.gfmAutoComplete = new GfmAutoComplete( gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources, ).setup(); - const autoAssignToMe = form.get(0).id === 'new_merge_request'; - this.usersSelect = new UsersSelect(undefined, undefined, { autoAssignToMe }); + this.usersSelect = new UsersSelect(); this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search'); this.zenMode = new ZenMode(); @@ -82,7 +81,7 @@ export default class IssuableForm { this.initAutosave(); this.form.on('submit', this.handleSubmit); - this.form.on('click', '.btn-cancel', this.resetAutosave); + this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave); this.form.find('.js-unwrap-on-load').unwrap(); this.initWip(); diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index edf3789e6dc..92ff7f21eff 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -169,28 +169,27 @@ export default class CreateMergeRequestDropdown { } createMergeRequest() { - return new Promise(() => { - this.isCreatingMergeRequest = true; - return this.createBranch(false) - .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue')) - .then(() => { - let path = canCreateConfidentialMergeRequest() - ? this.createMrPath.replace( - this.projectPath, - confidentialMergeRequestState.selectedProject.pathWithNamespace, - ) - : this.createMrPath; - path = mergeUrlParams( - { - 'merge_request[target_branch]': this.refInput.value, - 'merge_request[source_branch]': this.branchInput.value, - }, - path, - ); - - window.location.href = path; - }); - }); + this.isCreatingMergeRequest = true; + + return this.createBranch(false) + .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue')) + .then(() => { + let path = canCreateConfidentialMergeRequest() + ? this.createMrPath.replace( + this.projectPath, + confidentialMergeRequestState.selectedProject.pathWithNamespace, + ) + : this.createMrPath; + path = mergeUrlParams( + { + 'merge_request[target_branch]': this.refInput.value, + 'merge_request[source_branch]': this.branchInput.value, + }, + path, + ); + + window.location.href = path; + }); } disable() { diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 67c6c723dcc..380bb5f5346 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -23,6 +23,7 @@ import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; +import initLinkedResources from '~/linked_resources'; import FilteredSearchServiceDesk from './filtered_search_service_desk'; export function initFilteredSearchServiceDesk() { @@ -59,6 +60,7 @@ export function initShow() { if (issueType === IssueType.Incident) { initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }); initHeaderActions(store, IssueType.Incident); + initLinkedResources(); initRelatedIssues(IssueType.Incident); } else { initIssueApp(issuableData, store); 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 fa56c0183b2..f567b0f1d68 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -70,6 +70,7 @@ import { UPDATED_DESC, urlSortParams, } from '../constants'; + import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; @@ -98,6 +99,10 @@ const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); const ReleaseToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); +const CrmContactToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'); +const CrmOrganizationToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'); export default { i18n, @@ -168,6 +173,7 @@ export default { showBulkEditSidebar: false, sortKey: CREATED_DESC, state: IssuableStates.Opened, + pageSize: PAGE_SIZE, }; }, apollo: { @@ -383,7 +389,11 @@ export default { type: TOKEN_TYPE_CONTACT, title: TOKEN_TITLE_CONTACT, icon: 'user', - token: GlFilteredSearchToken, + token: CrmContactToken, + fullPath: this.fullPath, + isProject: this.isProject, + defaultContacts: DEFAULT_NONE_ANY, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`, operators: OPERATOR_IS_ONLY, unique: true, }); @@ -394,7 +404,11 @@ export default { type: TOKEN_TYPE_ORGANIZATION, title: TOKEN_TITLE_ORGANIZATION, icon: 'users', - token: GlFilteredSearchToken, + token: CrmOrganizationToken, + fullPath: this.fullPath, + isProject: this.isProject, + defaultOrganizations: DEFAULT_NONE_ANY, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`, operators: OPERATOR_IS_ONLY, unique: true, }); @@ -411,6 +425,10 @@ export default { showPaginationControls() { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); }, + showPageSizeControls() { + /** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */ + return this.currentTabCount > PAGE_SIZE; + }, sortOptions() { return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); }, @@ -433,8 +451,8 @@ export default { ...this.urlFilterParams, first_page_size: this.pageParams.firstPageSize, last_page_size: this.pageParams.lastPageSize, - page_after: this.pageParams.afterCursor, - page_before: this.pageParams.beforeCursor, + page_after: this.pageParams.afterCursor ?? undefined, + page_before: this.pageParams.beforeCursor ?? undefined, }; }, }, @@ -543,7 +561,7 @@ export default { }, handleClickTab(state) { if (this.state !== state) { - this.pageParams = getInitialPageParams(this.sortKey); + this.pageParams = getInitialPageParams(this.pageSize); } this.state = state; @@ -558,7 +576,7 @@ export default { return; } - this.pageParams = getInitialPageParams(this.sortKey); + this.pageParams = getInitialPageParams(this.pageSize); this.filterTokens = filter; this.$router.push({ query: this.urlParams }); @@ -566,7 +584,7 @@ export default { handleNextPage() { this.pageParams = { afterCursor: this.pageInfo.endCursor, - firstPageSize: PAGE_SIZE, + firstPageSize: this.pageSize, }; scrollUp(); @@ -575,7 +593,7 @@ export default { handlePreviousPage() { this.pageParams = { beforeCursor: this.pageInfo.startCursor, - lastPageSize: PAGE_SIZE, + lastPageSize: this.pageSize, }; scrollUp(); @@ -624,7 +642,7 @@ export default { } if (this.sortKey !== sortKey) { - this.pageParams = getInitialPageParams(sortKey); + this.pageParams = getInitialPageParams(this.pageSize); } this.sortKey = sortKey; @@ -664,6 +682,17 @@ export default { toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, + handlePageSizeChange(newPageSize) { + /** make sure the page number is preserved so that the current context is not lost* */ + const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); + const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize'; + /** depending upon what page or page size we are dynamically set pageParams * */ + this.pageParams[pageNumberSize] = newPageSize; + this.pageSize = newPageSize; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, updateData(sortValue) { const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE); const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); @@ -696,7 +725,7 @@ export default { this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search); this.pageParams = getInitialPageParams( - sortKey, + this.pageSize, isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined, pageAfter, @@ -732,8 +761,10 @@ export default { :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" :show-pagination-controls="showPaginationControls" + :default-page-size="pageSize" sync-filter-and-sort use-keyset-pagination + :show-page-size-change-controls="showPageSizeControls" :has-next-page="pageInfo.hasNextPage" :has-previous-page="pageInfo.hasPreviousPage" @click-tab="handleClickTab" @@ -744,6 +775,7 @@ export default { @reorder="handleReorder" @sort="handleSort" @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" + @page-size-change="handlePageSizeChange" > <template #nav-actions> <gl-button diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 74f801f685c..a921eb62e26 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -90,6 +90,8 @@ export const UPDATED_ASC = 'UPDATED_ASC'; export const UPDATED_DESC = 'UPDATED_DESC'; export const WEIGHT_ASC = 'WEIGHT_ASC'; export const WEIGHT_DESC = 'WEIGHT_DESC'; +export const CLOSED_ASC = 'CLOSED_AT_ASC'; +export const CLOSED_DESC = 'CLOSED_AT_DESC'; export const urlSortParams = { [PRIORITY_ASC]: 'priority', @@ -98,6 +100,8 @@ export const urlSortParams = { [CREATED_DESC]: 'created_date', [UPDATED_ASC]: 'updated_asc', [UPDATED_DESC]: 'updated_desc', + [CLOSED_ASC]: 'closed_asc', + [CLOSED_DESC]: 'closed_desc', [MILESTONE_DUE_ASC]: 'milestone', [MILESTONE_DUE_DESC]: 'milestone_due_desc', [DUE_DATE_ASC]: 'due_date', diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 73a13cea94a..35762120f71 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -13,6 +13,7 @@ fragment IssueFragment on Issue { state title updatedAt + closedAt upvotes userDiscussionsCount @include(if: $isSignedIn) webPath diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index dfdc6e27f0d..f02c7a23f51 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -21,7 +21,6 @@ import { MILESTONE_DUE_DESC, NORMAL_FILTER, PAGE_SIZE, - PAGE_SIZE_MANUAL, PARAM_ASSIGNEE_ID, POPULARITY_ASC, POPULARITY_DESC, @@ -44,11 +43,13 @@ import { urlSortParams, WEIGHT_ASC, WEIGHT_DESC, + CLOSED_ASC, + CLOSED_DESC, } from './constants'; export const getInitialPageParams = ( - sortKey, - firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, + pageSize, + firstPageSize = pageSize ?? PAGE_SIZE, lastPageSize, afterCursor, beforeCursor, @@ -92,6 +93,14 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, { id: 4, + title: __('Closed date'), + sortDirection: { + ascending: CLOSED_ASC, + descending: CLOSED_DESC, + }, + }, + { + id: 5, title: __('Milestone due date'), sortDirection: { ascending: MILESTONE_DUE_ASC, @@ -99,7 +108,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, }, { - id: 5, + id: 6, title: __('Due date'), sortDirection: { ascending: DUE_DATE_ASC, @@ -107,7 +116,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, }, { - id: 6, + id: 7, title: __('Popularity'), sortDirection: { ascending: POPULARITY_ASC, @@ -115,7 +124,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, }, { - id: 7, + id: 8, title: __('Label priority'), sortDirection: { ascending: LABEL_PRIORITY_ASC, @@ -123,7 +132,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, }, { - id: 8, + id: 9, title: __('Manual'), sortDirection: { ascending: RELATIVE_POSITION_ASC, @@ -131,7 +140,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, }, { - id: 9, + id: 10, title: __('Title'), sortDirection: { ascending: TITLE_ASC, diff --git a/app/assets/javascripts/issues/new/components/type_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue index a70e79b70f9..9c43e527f8b 100644 --- a/app/assets/javascripts/issues/new/components/type_popover.vue +++ b/app/assets/javascripts/issues/new/components/type_popover.vue @@ -18,8 +18,9 @@ export default { </script> <template> - <span id="popovercontainer"> - <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> + <span id="popovercontainer" class="gl-ml-2"> + <gl-icon id="issue-type-info" name="question-o" class="gl-text-blue-600" /> + <gl-popover target="issue-type-info" container="popovercontainer" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 892c631f8ea..449da394841 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,11 +1,5 @@ <script> -import { - GlSafeHtmlDirective as SafeHtml, - GlModal, - GlToast, - GlTooltip, - GlModalDirective, -} from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; @@ -20,11 +14,16 @@ import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; import Tracking from '~/tracking'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; +import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; -import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; -import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; +import { + TRACKING_CATEGORY_SHOW, + TASK_TYPE_NAME, + WIDGET_TYPE_DESCRIPTION, +} from '~/work_items/constants'; import animateMixin from '../mixins/animate'; import { convertDescriptionWithNewSort } from '../utils'; @@ -40,12 +39,11 @@ export default { GlModal: GlModalDirective, }, components: { - GlModal, - CreateWorkItem, GlTooltip, WorkItemDetailModal, }, mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()], + inject: ['fullPath'], props: { canUpdate: { type: Boolean, @@ -103,6 +101,7 @@ export default { workItemId: isPositiveInteger(workItemId) ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) : undefined, + workItemTypes: [], }; }, apollo: { @@ -117,11 +116,28 @@ export default { return !this.workItemId || !this.workItemsEnabled; }, }, + workItemTypes: { + query: projectWorkItemTypesQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return data.workspace?.workItemTypes?.nodes; + }, + skip() { + return !this.workItemsEnabled; + }, + }, }, computed: { workItemsEnabled() { return this.glFeatures.workItems; }, + taskWorkItemType() { + return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; + }, issueGid() { return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null; }, @@ -344,8 +360,8 @@ export default { <use href="${gon.sprite_icons}#doc-new"></use> </svg> `; - button.setAttribute('aria-label', s__('WorkItem|Convert to work item')); - button.addEventListener('click', () => this.openCreateTaskModal(button)); + button.setAttribute('aria-label', s__('WorkItem|Create task')); + button.addEventListener('click', () => this.handleCreateTask(button)); this.insertButtonNextToTaskText(item, button); }); }, @@ -386,17 +402,11 @@ export default { lineNumberEnd: lineNumbers[1], }; }, - openCreateTaskModal(el) { - this.setActiveTask(el); - this.$refs.modal.show(); - }, - closeCreateTaskModal() { - this.$refs.modal.hide(); - }, openWorkItemDetailModal(el) { if (!el) { return; } + this.setActiveTask(el); this.$refs.detailsModal.show(); }, @@ -404,13 +414,58 @@ export default { this.workItemId = undefined; this.updateWorkItemIdUrlQuery(undefined); }, - handleCreateTask(description) { - this.$emit('updateDescription', description); - this.closeCreateTaskModal(); + async handleCreateTask(el) { + this.setActiveTask(el); + try { + const { data } = await this.$apollo.mutate({ + mutation: createWorkItemFromTaskMutation, + variables: { + input: { + id: this.issueGid, + workItemData: { + lockVersion: this.lockVersion, + title: this.activeTask.title, + lineNumberStart: Number(this.activeTask.lineNumberStart), + lineNumberEnd: Number(this.activeTask.lineNumberEnd), + workItemTypeId: this.taskWorkItemType, + }, + }, + }, + update(store, { data: { workItemCreateFromTask } }) { + const { newWorkItem } = workItemCreateFromTask; + + store.writeQuery({ + query: workItemQuery, + variables: { + id: newWorkItem.id, + }, + data: { + workItem: newWorkItem, + }, + }); + }, + }); + + const { workItem, newWorkItem } = data.workItemCreateFromTask; + + const updatedDescription = workItem?.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_DESCRIPTION, + )?.descriptionHtml; + + this.$emit('updateDescription', updatedDescription); + this.workItemId = newWorkItem.id; + this.openWorkItemDetailModal(el); + } catch (error) { + createFlash({ + message: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + error, + captureError: true, + }); + } }, handleDeleteTask(description) { this.$emit('updateDescription', description); - this.$toast.show(s__('WorkItem|Work item deleted')); + this.$toast.show(s__('WorkItem|Task deleted')); }, updateWorkItemIdUrlQuery(workItemId) { updateHistory({ @@ -452,19 +507,6 @@ export default { data-testid="textarea" > </textarea> - - <gl-modal ref="modal" size="lg" modal-id="create-task-modal" hide-footer body-class="gl-p-0!"> - <create-work-item - is-modal - :initial-title="activeTask.title" - :issue-gid="issueGid" - :lock-version="lockVersion" - :line-number-start="activeTask.lineNumberStart" - :line-number-end="activeTask.lineNumberEnd" - @closeModal="closeCreateTaskModal" - @onCreate="handleCreateTask" - /> - </gl-modal> <work-item-detail-modal ref="detailsModal" :can-update="canUpdate" @@ -478,7 +520,7 @@ export default { /> <template v-if="workItemsEnabled"> <gl-tooltip v-for="item in taskButtons" :key="item" :target="item"> - {{ s__('WorkItem|Convert to work item') }} + {{ s__('WorkItem|Create task') }} </gl-tooltip> </template> </div> diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 9b31014c1ba..358b53bd131 100644 --- a/app/assets/javascripts/issues/show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -105,7 +105,7 @@ export default { :disabled="formState.updateLoading || !isSubmitEnabled" category="primary" variant="confirm" - class="qa-save-button gl-mr-3" + class="gl-mr-3" data-testid="issuable-save-button" type="submit" @click.prevent="updateIssuable" @@ -123,7 +123,6 @@ export default { :disabled="deleteLoading" category="secondary" variant="danger" - class="qa-delete-button" data-testid="issuable-delete-button" @click="track('click_button')" > diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 0bb5e7cb2ee..f45af47374a 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -59,7 +59,8 @@ export default { id="issue-description" ref="textarea" :value="value" - class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-qa-selector="description_field" dir="auto" data-supports-quick-actions="true" :aria-label="__('Description')" diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue index 594d1a65700..58d32256da4 100644 --- a/app/assets/javascripts/issues/show/components/fields/title.vue +++ b/app/assets/javascripts/issues/show/components/fields/title.vue @@ -19,7 +19,7 @@ export default { id="issuable-title" ref="input" :value="value" - class="form-control qa-title-input gl-border-gray-200" + class="form-control gl-border-gray-200" dir="auto" type="text" :placeholder="__('Title')" diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js new file mode 100644 index 00000000000..9fc5027d457 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -0,0 +1,26 @@ +import { s__ } from '~/locale'; + +export const timelineTabI18n = Object.freeze({ + title: s__('Incident|Timeline'), + emptyDescription: s__('Incident|No timeline items have been added yet.'), + addEventButton: s__('Incident|Add new timeline event'), +}); + +export const timelineFormI18n = Object.freeze({ + createError: s__('Incident|Error creating incident timeline event: %{error}'), + createErrorGeneric: s__( + 'Incident|Something went wrong while creating the incident timeline event.', + ), + areaPlaceholder: s__('Incident|Timeline text...'), + saveAndAdd: s__('Incident|Save and add another event'), + areaLabel: s__('Incident|Timeline text'), +}); + +export const timelineListI18n = Object.freeze({ + deleteButton: s__('Incident|Delete event'), + deleteError: s__('Incident|Error deleting incident timeline event: %{error}'), + deleteErrorGeneric: s__( + 'Incident|Something went wrong while deleting the incident timeline event.', + ), + deleteModal: s__('Incident|Are you sure you want to delete this event?'), +}); diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql new file mode 100644 index 00000000000..f1fc27dcb2a --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql @@ -0,0 +1,13 @@ +mutation CreateTimelineEvent($input: TimelineEventCreateInput!) { + timelineEventCreate(input: $input) { + timelineEvent { + id + note + noteHtml + action + occurredAt + createdAt + } + errors + } +} diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql new file mode 100644 index 00000000000..78babf9d62e --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql @@ -0,0 +1,8 @@ +mutation DestroyTimelineEvent($input: TimelineEventDestroyInput!) { + timelineEventDestroy(input: $input) { + timelineEvent { + id + } + errors + } +} diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql index 7e049d98c1a..bc4e8414bfc 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql @@ -4,17 +4,11 @@ query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) { incidentManagementTimelineEvents(incidentId: $incidentId) { nodes { id - author { - id - name - username - } note noteHtml action occurredAt createdAt - updatedAt } } } diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 6fdce6045f2..dd84a1d7d67 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -101,7 +101,7 @@ export default { > <gl-tab :title="s__('Incident|Summary')"> <highlight-bar :alert="alert" /> - <description-component v-bind="$attrs" /> + <description-component v-bind="$attrs" v-on="$listeners" /> </gl-tab> <incident-metric-tab /> <gl-tab diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue new file mode 100644 index 00000000000..36ec6362a22 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -0,0 +1,266 @@ +<script> +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui'; +import { produce } from 'immer'; +import { sortBy } from 'lodash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { createAlert } from '~/flash'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { sprintf } from '~/locale'; +import { getUtcShiftedDateNow } from './utils'; +import { timelineFormI18n } from './constants'; + +import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql'; +import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; + +export default { + name: 'IncidentTimelineEventForm', + restrictedToolBarItems: [ + 'quote', + 'strikethrough', + 'bullet-list', + 'numbered-list', + 'task-list', + 'collapsible-section', + 'table', + 'full-screen', + ], + components: { + MarkdownField, + GlDatepicker, + GlFormInput, + GlFormGroup, + GlButton, + GlIcon, + }, + i18n: timelineFormI18n, + directives: { + autofocusonshow, + }, + inject: ['fullPath', 'issuableId'], + props: { + hasTimelineEvents: { + type: Boolean, + required: true, + }, + }, + data() { + // Create shifted date to force the datepicker to format in UTC + const utcShiftedDate = getUtcShiftedDateNow(); + return { + currentDate: utcShiftedDate, + currentHour: utcShiftedDate.getHours(), + currentMinute: utcShiftedDate.getMinutes(), + timelineText: '', + createTimelineEventActive: false, + datepickerTextInput: null, + }; + }, + methods: { + clear() { + const utcShiftedDate = getUtcShiftedDateNow(); + this.currentDate = utcShiftedDate; + this.currentHour = utcShiftedDate.getHours(); + this.currentMinute = utcShiftedDate.getMinutes(); + }, + hideIncidentTimelineEventForm() { + this.$emit('hide-incident-timeline-event-form'); + }, + focusDate() { + this.$refs.datepicker.$el.focus(); + }, + updateCache(store, { data }) { + const { timelineEvent: event, errors } = data?.timelineEventCreate || {}; + + if (errors.length) { + return; + } + + const variables = { + incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + fullPath: this.fullPath, + }; + + const sourceData = store.readQuery({ + query: getTimelineEvents, + variables, + }); + + const newData = produce(sourceData, (draftData) => { + const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents; + draftEventList.push(event); + // ISOStrings sort correctly in lexical order + const sortedEvents = sortBy(draftEventList, 'occurredAt'); + draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents; + }); + + store.writeQuery({ + query: getTimelineEvents, + variables, + data: newData, + }); + }, + createIncidentTimelineEvent(addOneEvent) { + this.createTimelineEventActive = true; + return this.$apollo + .mutate({ + mutation: CreateTimelineEvent, + variables: { + input: { + incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + note: this.timelineText, + occurredAt: this.createDateString(), + }, + }, + update: this.updateCache, + }) + .then(({ data = {} }) => { + const errors = data.timelineEventCreate?.errors; + if (errors.length) { + createAlert({ + message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false), + }); + } + }) + .catch((error) => { + createAlert({ + message: this.$options.i18n.createErrorGeneric, + captureError: true, + error, + }); + }) + .finally(() => { + this.createTimelineEventActive = false; + this.timelineText = ''; + if (addOneEvent) { + this.hideIncidentTimelineEventForm(); + } + }); + }, + createDateString() { + const [years, months, days] = this.datepickerTextInput.split('-'); + const utcDate = new Date( + Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute), + ); + return utcDate.toISOString(); + }, + }, +}; +</script> + +<template> + <div + class="gl-relative gl-display-flex gl-align-items-center" + :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" + > + <div + v-if="hasTimelineEvents" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + > + <gl-icon name="comment" class="note-icon" /> + </div> + <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }"> + <div + class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker" + > + <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> + <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate"> + <gl-form-input + id="incident-date" + ref="datepicker" + v-model="datepickerTextInput" + data-testid="input-datepicker" + class="gl-datepicker-input gl-pr-7!" + :value="formattedDate" + :placeholder="__('YYYY-MM-DD')" + @keydown.enter="onKeydown" + /> + </gl-datepicker> + </gl-form-group> + <div class="gl-display-flex gl-mt-5"> + <gl-form-group :label="__('Time')"> + <div class="gl-display-flex"> + <label label-for="timeline-input-hours" class="sr-only"></label> + <gl-form-input + id="timeline-input-hours" + v-model="currentHour" + data-testid="input-hours" + size="xs" + type="number" + min="00" + max="23" + /> + <label label-for="timeline-input-minutes" class="sr-only"></label> + <gl-form-input + id="timeline-input-minutes" + v-model="currentMinute" + class="gl-ml-3" + data-testid="input-minutes" + size="xs" + type="number" + min="00" + max="59" + /> + </div> + </gl-form-group> + <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> + </div> + </div> + <div class="common-note-form"> + <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> + <markdown-field + :can-attach-file="false" + :add-spacing-classes="false" + :show-comment-tool-bar="false" + :textarea-value="timelineText" + :restricted-tool-bar-items="$options.restrictedToolBarItems" + markdown-docs-path="" + :enable-preview="false" + class="bordered-box gl-mt-0" + > + <template #textarea> + <textarea + v-model="timelineText" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + :aria-label="__('Description')" + :placeholder="$options.i18n.areaPlaceholder" + > + </textarea> + </template> + </markdown-field> + </gl-form-group> + </div> + <gl-form-group class="gl-mb-0"> + <gl-button + variant="confirm" + category="primary" + class="gl-mr-3" + :loading="createTimelineEventActive" + @click="createIncidentTimelineEvent(true)" + > + {{ __('Save') }} + </gl-button> + <gl-button + variant="confirm" + category="secondary" + class="gl-mr-3 gl-ml-n2" + :loading="createTimelineEventActive" + @click="createIncidentTimelineEvent(false)" + > + {{ $options.i18n.saveAndAdd }} + </gl-button> + <gl-button + class="gl-ml-n2" + :disabled="createTimelineEventActive" + @click="hideIncidentTimelineEventForm" + > + {{ __('Cancel') }} + </gl-button> + <div class="gl-border-b gl-pt-5"></div> + </gl-form-group> + </form> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index a6e58ee0bdc..519c0d402a0 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -1,9 +1,16 @@ <script> import { formatDate } from '~/lib/utils/datetime_utility'; +import { createAlert } from '~/flash'; +import { sprintf } from '~/locale'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import IncidentTimelineEventListItem from './timeline_events_list_item.vue'; +import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql'; +import { timelineListI18n } from './constants'; export default { name: 'IncidentTimelineEventList', + i18n: timelineListI18n, components: { IncidentTimelineEventListItem, }, @@ -43,6 +50,41 @@ export default { } return eventIndex === events.length - 1; }, + handleDelete: ignoreWhilePending(async function handleDelete(event) { + const msg = this.$options.i18n.deleteModal; + + const confirmed = await confirmAction(msg, { + primaryBtnVariant: 'danger', + primaryBtnText: this.$options.i18n.deleteButton, + }); + + if (!confirmed) { + return; + } + + try { + const result = await this.$apollo.mutate({ + mutation: deleteTimelineEvent, + variables: { + input: { + id: event.id, + }, + }, + update: (cache) => { + const cacheId = cache.identify(event); + cache.evict({ id: cacheId }); + }, + }); + const { errors } = result.data.timelineEventDestroy; + if (errors?.length) { + createAlert({ + message: sprintf(this.$options.i18n.deleteError, { error: errors.join('. ') }, false), + }); + } + } catch (error) { + createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error }); + } + }), }, }; </script> @@ -65,7 +107,7 @@ export default { :occurred-at="event.occurredAt" :note-html="event.noteHtml" :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)" - data-testid="timeline-event" + @delete="handleDelete(event)" /> </ul> </div> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue index fef9bf713b7..62ccd696ef6 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import { getEventIcon } from './utils'; @@ -7,15 +7,20 @@ import { getEventIcon } from './utils'; export default { name: 'IncidentTimelineEventListItem', i18n: { + delete: __('Delete'), + moreActions: __('More actions'), timeUTC: __('%{time} UTC'), }, components: { + GlDropdown, + GlDropdownItem, GlIcon, GlSprintf, }, directives: { SafeHtml: GlSafeHtmlDirective, }, + inject: ['canUpdate'], props: { isLastItem: { type: Boolean, @@ -55,16 +60,32 @@ export default { <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> <div - class="timeline-event-note gl-w-full" + class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row" :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }" data-testid="event-text-container" > - <strong class="gl-font-lg" data-testid="event-time"> - <gl-sprintf :message="$options.i18n.timeUTC"> - <template #time>{{ time }}</template> - </gl-sprintf> - </strong> - <div v-safe-html="noteHtml"></div> + <div> + <strong class="gl-font-lg" data-testid="event-time"> + <gl-sprintf :message="$options.i18n.timeUTC"> + <template #time>{{ time }}</template> + </gl-sprintf> + </strong> + <div v-safe-html="noteHtml"></div> + </div> + <gl-dropdown + v-if="canUpdate" + right + class="event-note-actions gl-ml-auto gl-align-self-center" + icon="ellipsis_v" + text-sr-only + :text="$options.i18n.moreActions" + category="tertiary" + no-caret + > + <gl-dropdown-item @click="$emit('delete')"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> </div> </div> </li> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 400e1f0b725..e1946ef4d07 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,23 +1,29 @@ <script> -import { GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; import { displayAndLogError } from './utils'; +import { timelineTabI18n } from './constants'; +import IncidentTimelineEventForm from './timeline_events_form.vue'; import IncidentTimelineEventsList from './timeline_events_list.vue'; export default { components: { + GlButton, GlEmptyState, GlLoadingIcon, GlTab, + IncidentTimelineEventForm, IncidentTimelineEventsList, }, - inject: ['fullPath', 'issuableId'], + i18n: timelineTabI18n, + inject: ['canUpdate', 'fullPath', 'issuableId'], data() { return { + isEventFormVisible: false, timelineEvents: [], }; }, @@ -50,21 +56,43 @@ export default { return !this.timelineEventLoading && !this.hasTimelineEvents; }, }, + methods: { + hideEventForm() { + this.isEventFormVisible = false; + }, + async showEventForm() { + this.$refs.eventForm.clear(); + this.isEventFormVisible = true; + await this.$nextTick(); + this.$refs.eventForm.focusDate(); + }, + }, }; </script> <template> - <gl-tab :title="s__('Incident|Timeline')"> + <gl-tab :title="$options.i18n.title"> <gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" /> <gl-empty-state v-else-if="showEmptyState" :compact="true" - :description="s__('Incident|No timeline items have been added yet.')" + :description="$options.i18n.emptyDescription" /> <incident-timeline-events-list v-if="hasTimelineEvents" :timeline-event-loading="timelineEventLoading" :timeline-events="timelineEvents" /> + <incident-timeline-event-form + v-show="isEventFormVisible" + ref="eventForm" + :has-timeline-events="hasTimelineEvents" + class="timeline-event-note timeline-event-note-form" + :class="{ 'gl-pl-0': !hasTimelineEvents }" + @hide-incident-timeline-event-form="hideEventForm" + /> + <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm"> + {{ $options.i18n.addEventButton }} + </gl-button> </gl-tab> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index 8b5a2ec4031..256e3025f19 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -10,9 +10,23 @@ export const displayAndLogError = (error) => const EVENT_ICONS = { comment: 'comment', + issues: 'issues', + status: 'status', default: 'comment', }; export const getEventIcon = (actionName) => { return EVENT_ICONS[actionName] ?? EVENT_ICONS.default; }; + +/** + * Returns a date shifted by the current timezone offset. Allows + * date.getHours() and similar to return UTC values. + * + * @returns {Date} + */ +export const getUtcShiftedDateNow = () => { + const date = new Date(); + date.setMinutes(date.getMinutes() + date.getTimezoneOffset()); + return date; +}; diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 7f67b31b122..307d9f9f69a 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -74,14 +74,15 @@ export default { 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="title qa-title gl-font-size-h-display" + class="title gl-font-size-h-display" + data-qa-selector="title_content" dir="auto" ></h1> <gl-button v-if="showInlineEditButton && canUpdate" v-gl-tooltip.bottom icon="pencil" - class="btn-edit js-issuable-edit qa-edit-button" + class="btn-edit js-issuable-edit" :title="$options.i18n.editTitleAndDescription" :aria-label="$options.i18n.editTitleAndDescription" @click="edit" diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 5bdad010af7..459a3804837 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -63,6 +63,7 @@ export function initIncidentApp(issueData = {}) { return createElement(IssueApp, { props: { ...issueData, + issueId: Number(issuableId), issuableStatus: state, descriptionComponent: IncidentTabs, showTitleBorder: false, diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue deleted file mode 100644 index c639e49083b..00000000000 --- a/app/assets/javascripts/jobs/bridge/app.vue +++ /dev/null @@ -1,118 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __, sprintf } from '~/locale'; -import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import getPipelineQuery from './graphql/queries/pipeline.query.graphql'; -import BridgeEmptyState from './components/empty_state.vue'; -import BridgeSidebar from './components/sidebar.vue'; -import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './components/constants'; - -export default { - name: 'BridgePageApp', - components: { - BridgeEmptyState, - BridgeSidebar, - CiHeader, - GlLoadingIcon, - }, - inject: ['buildId', 'projectFullPath', 'pipelineIid'], - apollo: { - pipeline: { - query: getPipelineQuery, - variables() { - return { - fullPath: this.projectFullPath, - iid: this.pipelineIid, - }; - }, - update(data) { - if (!data?.project?.pipeline) { - return null; - } - - const { pipeline } = data.project; - const stages = pipeline?.stages.edges.map((edge) => edge.node) || []; - const jobs = stages.map((stage) => stage.jobs.nodes).flat(); - - return { - ...pipeline, - commit: { - ...pipeline.commit, - commit_path: pipeline.commit.webPath, - short_id: pipeline.commit.shortId, - }, - id: getIdFromGraphQLId(pipeline.id), - jobs, - stages, - }; - }, - }, - }, - data() { - return { - isSidebarExpanded: true, - pipeline: {}, - }; - }, - computed: { - bridgeJob() { - return ( - this.pipeline.jobs?.filter( - (job) => getIdFromGraphQLId(job.id) === Number(this.buildId), - )[0] || {} - ); - }, - bridgeName() { - return sprintf(__('Job %{jobName}'), { jobName: this.bridgeJob.name }); - }, - isPipelineLoading() { - return this.$apollo.queries.pipeline.loading; - }, - }, - created() { - window.addEventListener('resize', this.onResize); - }, - mounted() { - this.onResize(); - }, - methods: { - toggleSidebar() { - this.isSidebarExpanded = !this.isSidebarExpanded; - }, - onResize() { - const breakpoint = bp.getBreakpointSize(); - if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) { - this.isSidebarExpanded = false; - } else if (!this.isSidebarExpanded) { - this.isSidebarExpanded = true; - } - }, - }, -}; -</script> -<template> - <div> - <gl-loading-icon v-if="isPipelineLoading" size="lg" class="gl-mt-4" /> - <div v-else> - <ci-header - class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" - :status="bridgeJob.detailedStatus" - :time="bridgeJob.createdAt" - :user="pipeline.user" - :has-sidebar-button="true" - :item-name="bridgeName" - @clickedSidebarButton="toggleSidebar" - /> - <bridge-empty-state :downstream-pipeline-path="bridgeJob.downstreamPipeline.path" /> - <bridge-sidebar - v-if="isSidebarExpanded" - :bridge-job="bridgeJob" - :commit="pipeline.commit" - :is-sidebar-expanded="isSidebarExpanded" - @toggleSidebar="toggleSidebar" - /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js deleted file mode 100644 index 33310b3157a..00000000000 --- a/app/assets/javascripts/jobs/bridge/components/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm']; diff --git a/app/assets/javascripts/jobs/bridge/components/empty_state.vue b/app/assets/javascripts/jobs/bridge/components/empty_state.vue deleted file mode 100644 index bd07d863719..00000000000 --- a/app/assets/javascripts/jobs/bridge/components/empty_state.vue +++ /dev/null @@ -1,45 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - name: 'BridgeEmptyState', - i18n: { - title: __('This job triggers a downstream pipeline'), - linkBtnText: __('View downstream pipeline'), - }, - components: { - GlButton, - }, - inject: { - emptyStateIllustrationPath: { - type: String, - require: true, - }, - }, - props: { - downstreamPipelinePath: { - type: String, - required: false, - default: undefined, - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> - <img :src="emptyStateIllustrationPath" /> - <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> - <gl-button - v-if="downstreamPipelinePath" - class="gl-mt-3" - category="secondary" - variant="confirm" - size="medium" - :href="downstreamPipelinePath" - > - {{ $options.i18n.linkBtnText }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue deleted file mode 100644 index 3ba07cf55d1..00000000000 --- a/app/assets/javascripts/jobs/bridge/components/sidebar.vue +++ /dev/null @@ -1,105 +0,0 @@ -<script> -import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR } from '../../constants'; -import CommitBlock from '../../components/commit_block.vue'; - -export default { - styles: { - width: '290px', - }, - name: 'BridgeSidebar', - i18n: { - ...JOB_SIDEBAR, - retryButton: __('Retry'), - retryTriggerJob: __('Retry the trigger job'), - retryDownstreamPipeline: __('Retry the downstream pipeline'), - }, - sectionClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100', 'gl-py-5'], - components: { - CommitBlock, - GlButton, - GlDropdown, - GlDropdownItem, - TooltipOnTruncate, - }, - mixins: [glFeatureFlagsMixin()], - props: { - bridgeJob: { - type: Object, - required: true, - }, - commit: { - type: Object, - required: true, - }, - }, - data() { - return { - topPosition: 0, - }; - }, - computed: { - rootStyle() { - return { ...this.$options.styles, top: `${this.topPosition}px` }; - }, - }, - mounted() { - this.setTopPosition(); - }, - methods: { - onSidebarButtonClick() { - this.$emit('toggleSidebar'); - }, - setTopPosition() { - const navbarEl = document.querySelector('.js-navbar'); - - if (navbarEl) { - this.topPosition = navbarEl.getBoundingClientRect().bottom; - } - }, - }, -}; -</script> -<template> - <aside - class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden" - :style="rootStyle" - > - <div class="gl-py-5 gl-display-flex gl-align-items-center"> - <tooltip-on-truncate :title="bridgeJob.name" truncate-target="child" - ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate"> - {{ bridgeJob.name }} - </h4> - </tooltip-on-truncate> - <!-- TODO: implement retry actions --> - <div - v-if="glFeatures.triggerJobRetryAction" - class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right" - > - <gl-dropdown - :text="$options.i18n.retryButton" - category="primary" - variant="confirm" - right - size="medium" - > - <gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item> - <gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item> - </gl-dropdown> - </div> - <gl-button - :aria-label="$options.i18n.toggleSidebar" - data-testid="sidebar-expansion-toggle" - category="tertiary" - class="gl-md-display-none gl-ml-2" - icon="chevron-double-lg-right" - @click="onSidebarButtonClick" - /> - </div> - <commit-block :commit="commit" :class="$options.sectionClass" /> - <!-- TODO: show stage dropdown, jobs list --> - </aside> -</template> diff --git a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql deleted file mode 100644 index 338ca9f16c7..00000000000 --- a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql +++ /dev/null @@ -1,70 +0,0 @@ -query getPipelineData($fullPath: ID!, $iid: ID!) { - project(fullPath: $fullPath) { - id - pipeline(iid: $iid) { - id - iid - path - sha - ref - refPath - commit { - id - shortId - title - webPath - } - detailedStatus { - id - icon - group - } - stages { - edges { - node { - id - name - jobs { - nodes { - id - createdAt - name - scheduledAt - startedAt - status - triggered - detailedStatus { - id - detailsPath - icon - group - text - tooltip - } - downstreamPipeline { - id - path - } - stage { - id - name - } - } - } - } - } - } - user { - id - avatarUrl - name - username - webPath - webUrl - status { - message - } - } - } - } -} diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 396b015ad83..f9e6c64aad1 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -68,6 +68,11 @@ export default { default: null, }, }, + data() { + return { + searchResults: [], + }; + }, computed: { ...mapState([ 'isLoading', @@ -184,6 +189,9 @@ export default { this.throttled(); }, + setSearchResults(searchResults) { + this.searchResults = searchResults; + }, }, }; </script> @@ -279,10 +287,12 @@ export default { :is-scroll-top-disabled="isScrollTopDisabled" :is-job-log-size-visible="isJobLogSizeVisible" :is-scrolling-down="isScrollingDown" + :job-log="jobLog" @scrollJobLogTop="scrollTop" @scrollJobLogBottom="scrollBottom" + @searchResults="setSearchResults" /> - <log :job-log="jobLog" :is-complete="isJobLogComplete" /> + <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" /> </div> <!-- EO job log --> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index eb6a284dfaf..5e89dd5acc2 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -1,21 +1,34 @@ <script> -import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui'; +import { scrollToElement } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __, s__, sprintf } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { i18n: { scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToTopButtonLabel: s__('Job|Scroll to top'), showRawButtonLabel: s__('Job|Show complete raw'), + searchPlaceholder: s__('Job|Search job log'), + noResults: s__('Job|No search results found'), + searchPopoverTitle: s__('Job|Job log search'), + searchPopoverDescription: s__( + 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.', + ), + logLineNumberNotFound: s__('Job|We could not find this element'), }, components: { GlLink, GlButton, + GlSearchBoxByClick, + HelpPopover, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], props: { size: { type: Number, @@ -42,6 +55,16 @@ export default { type: Boolean, required: true, }, + jobLog: { + type: Array, + required: true, + }, + }, + data() { + return { + searchTerm: '', + searchResults: [], + }; }, computed: { jobLogSize() { @@ -49,6 +72,9 @@ export default { size: numberToHumanSize(this.size), }); }, + showJobLogSearch() { + return this.glFeatures.jobLogSearch; + }, }, methods: { handleScrollToTop() { @@ -57,6 +83,54 @@ export default { handleScrollToBottom() { this.$emit('scrollJobLogBottom'); }, + searchJobLog() { + this.searchResults = []; + + if (!this.searchTerm) return; + + const compactedLog = []; + + this.jobLog.forEach((obj) => { + if (obj.lines && obj.lines.length > 0) { + compactedLog.push(...obj.lines); + } + + if (!obj.lines && obj.content.length > 0) { + compactedLog.push(obj); + } + }); + + compactedLog.forEach((line) => { + const lineText = line.content[0].text; + + if (lineText.toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase())) { + this.searchResults.push(line); + } + }); + + if (this.searchResults.length > 0) { + this.$emit('searchResults', this.searchResults); + + // BE returns zero based index, we need to add one to match the line numbers in the DOM + const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`; + const logLine = document.querySelector(`.js-line ${firstSearchResult}`); + + if (logLine) { + setTimeout(() => scrollToElement(logLine)); + + const message = sprintf(s__('Job|%{searchLength} results found for %{searchTerm}'), { + searchLength: this.searchResults.length, + searchTerm: this.searchTerm, + }); + + this.$toast.show(message); + } else { + this.$toast.show(this.$options.i18n.logLineNumberNotFound); + } + } else { + this.$toast.show(this.$options.i18n.noResults); + } + }, }, }; </script> @@ -81,6 +155,25 @@ export default { <!-- eo truncate information --> <div class="controllers gl-float-right"> + <template v-if="showJobLogSearch"> + <gl-search-box-by-click + v-model="searchTerm" + class="gl-mr-3" + :placeholder="$options.i18n.searchPlaceholder" + data-testid="job-log-search-box" + @clear="$emit('searchResults', [])" + @submit="searchJobLog" + /> + + <help-popover class="gl-mr-3"> + <template #title>{{ $options.i18n.searchPopoverTitle }}</template> + + <p class="gl-mb-0"> + {{ $options.i18n.searchPopoverDescription }} + </p> + </help-popover> + </template> + <!-- links --> <gl-button v-if="rawPath" diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue index 757b2e458e9..13716b4d391 100644 --- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue +++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue @@ -1,6 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants'; import LogLine from './line.vue'; import LogLineHeader from './line_header.vue'; @@ -9,9 +7,7 @@ export default { components: { LogLine, LogLineHeader, - CollapsibleLogSection: () => import('./collapsible_section.vue'), }, - mixins: [glFeatureFlagsMixin()], props: { section: { type: Object, @@ -21,14 +17,16 @@ export default { type: String, required: true, }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, }, computed: { badgeDuration() { return this.section.line && this.section.line.section_duration; }, - infinitelyCollapsibleSectionsFlag() { - return this.glFeatures?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]; - }, }, methods: { handleOnClickCollapsibleLine(section) { @@ -47,26 +45,13 @@ export default { @toggleLine="handleOnClickCollapsibleLine(section)" /> <template v-if="!section.isClosed"> - <template v-if="infinitelyCollapsibleSectionsFlag"> - <template v-for="line in section.lines"> - <collapsible-log-section - v-if="line.isHeader" - :key="line.line.offset" - :section="line" - :job-log-endpoint="jobLogEndpoint" - @onClickCollapsibleLine="handleOnClickCollapsibleLine" - /> - <log-line v-else :key="line.offset" :line="line" :path="jobLogEndpoint" /> - </template> - </template> - <template v-else> - <log-line - v-for="line in section.lines" - :key="line.offset" - :line="line" - :path="jobLogEndpoint" - /> - </template> + <log-line + v-for="line in section.lines" + :key="line.offset" + :line="line" + :path="jobLogEndpoint" + :search-results="searchResults" + /> </template> </div> </template> diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index 2d9714cd06b..36b350f4d64 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -14,9 +14,14 @@ export default { type: String, required: true, }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, }, render(h, { props }) { - const { line, path } = props; + const { line, path, searchResults } = props; const chars = line.content.map((content) => { return h( @@ -46,15 +51,33 @@ export default { ); }); - return h('div', { class: 'js-line log-line' }, [ - h(LineNumber, { - props: { - lineNumber: line.lineNumber, - path, - }, - }), - ...chars, - ]); + let applyHighlight = false; + + if (searchResults.length > 0) { + const linesToHighlight = searchResults.map((searchResultLine) => searchResultLine.lineNumber); + + linesToHighlight.forEach((num) => { + if (num === line.lineNumber) { + applyHighlight = true; + } + }); + } + + return h( + 'div', + { + class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-500' : ''], + }, + [ + h(LineNumber, { + props: { + lineNumber: line.lineNumber, + path, + }, + }), + ...chars, + ], + ); }, }; </script> diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue index c8ceac2c7ff..7ca9154d2fe 100644 --- a/app/assets/javascripts/jobs/components/log/line_number.vue +++ b/app/assets/javascripts/jobs/components/log/line_number.vue @@ -1,6 +1,4 @@ <script> -import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants'; - export default { functional: true, props: { @@ -16,9 +14,7 @@ export default { render(h, { props }) { const { lineNumber, path } = props; - const parsedLineNumber = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF] - ? lineNumber - : lineNumber + 1; + const parsedLineNumber = lineNumber + 1; const lineId = `L${parsedLineNumber}`; const lineHref = `${path}#${lineId}`; diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index ef95d79b8ab..9647582b81d 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -8,6 +8,13 @@ export default { CollapsibleLogSection, LogLine, }, + props: { + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, computed: { ...mapState([ 'jobLogEndpoint', @@ -56,9 +63,16 @@ export default { :key="`collapsible-${index}`" :section="section" :job-log-endpoint="jobLogEndpoint" + :search-results="searchResults" @onClickCollapsibleLine="handleOnClickCollapsibleLine" /> - <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" /> + <log-line + v-else + :key="section.offset" + :line="section" + :path="jobLogEndpoint" + :search-results="searchResults" + /> </template> <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3"> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index cc099dba72f..a42e45ee7e4 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -89,7 +89,7 @@ export default { <div class="blocks-container"> <div class="gl-py-5 gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="my-0 mr-2 gl-text-truncate"> + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> {{ job.name }} </h4> </tooltip-on-truncate> diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 2ba531c9e95..15c4e503685 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -42,14 +42,11 @@ export default { this.job.duration || this.job.finished_at || this.job.erased_at || - this.job.queued || + this.job.queued_duration || this.job.runner || this.job.coverage, ); }, - queued() { - return timeIntervalInWords(this.job.queued); - }, runnerHelpUrl() { return helpPagePath('ci/runners/configure_runners.html', { anchor: 'set-maximum-job-timeout-for-a-runner', @@ -60,6 +57,9 @@ export default { return `#${id} (${token}) ${description}`; }, + queuedDuration() { + return timeIntervalInWords(this.job.queued_duration); + }, shouldRenderBlock() { return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags); }, @@ -98,7 +98,7 @@ export default { :title="$options.i18n.FINISHED" /> <detail-row v-if="job.erased_at" :value="erasedAt" :title="$options.i18n.ERASED" /> - <detail-row v-if="job.queued" :value="queued" :title="$options.i18n.QUEUED" /> + <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" /> <detail-row v-if="hasTimeout" :help-url="runnerHelpUrl" diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 02aeb46a22b..6f351d91165 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -222,7 +222,7 @@ export default { /> <gl-button v-else-if="isRetryable" - icon="repeat" + icon="retry" :title="$options.ACTIONS_RETRY" :aria-label="$options.ACTIONS_RETRY" :method="currentJobMethod" diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index f3ca958b3ca..5b1032c6448 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -1,8 +1,8 @@ -query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) { +query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) { project(fullPath: $fullPath) { id __typename - jobs(after: $after, first: 30, statuses: $statuses) { + jobs(after: $after, first: $first, statuses: $statuses) { count pageInfo { endCursor diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index f513d2090fa..d8c5c292f52 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -45,6 +45,7 @@ export default { :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" :empty-text="$options.i18n.emptyText" + data-testid="jobs-table" show-empty stacked="lg" fixed diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 1ac1a2d68e2..b3db5a94ac5 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -2,7 +2,6 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import createFlash from '~/flash'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; import eventHub from './event_hub'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; @@ -28,7 +27,6 @@ export default { GlIntersectionObserver, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], inject: { fullPath: { default: '', @@ -93,7 +91,7 @@ export default { return this.loading && !this.showLoadingSpinner; }, showFilteredSearch() { - return this.glFeatures?.jobsTableVueSearch && !this.scope; + return !this.scope; }, jobsCount() { return this.jobs.count; diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue index 27e3b8028b7..68c6c669a1a 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; export default { components: { @@ -29,7 +30,7 @@ export default { return [ { text: s__('Jobs|All'), - count: this.allJobsCount, + count: limitedCounterWithDelimiter(this.allJobsCount), scope: null, testId: 'jobs-all-tab', showBadge: true, diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index 97f31eee57c..3040d4e2379 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -24,5 +24,3 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { }; export const SUCCESS_STATUS = 'SUCCESS'; - -export const INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF = 'infinitelyCollapsibleSections'; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 26dd38bbe08..5c63ad96ad0 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import BridgeApp from './bridge/app.vue'; import JobApp from './components/job_app.vue'; import createStore from './store'; +Vue.use(GlToast); + const initializeJobPage = (element) => { const store = createStore(); @@ -51,43 +51,7 @@ const initializeJobPage = (element) => { }); }; -const initializeBridgePage = (el) => { - const { - buildId, - downstreamPipelinePath, - emptyStateIllustrationPath, - pipelineIid, - projectFullPath, - } = el.dataset; - - Vue.use(VueApollo); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - - return new Vue({ - el, - apolloProvider, - provide: { - buildId, - downstreamPipelinePath, - emptyStateIllustrationPath, - pipelineIid, - projectFullPath, - }, - render(h) { - return h(BridgeApp); - }, - }); -}; - export default () => { const jobElement = document.getElementById('js-job-page'); - const bridgeElement = document.getElementById('js-bridge-page'); - - if (jobElement) { - initializeJobPage(jobElement); - } else { - initializeBridgePage(bridgeElement); - } + initializeJobPage(jobElement); }; diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index eda2ee0349a..87c00ad4d70 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -1,7 +1,6 @@ import Vue from 'vue'; -import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants'; import * as types from './mutation_types'; -import { logLinesParser, logLinesParserLegacy, updateIncrementalJobLog } from './utils'; +import { logLinesParser, updateIncrementalJobLog } from './utils'; export default { [types.SET_JOB_ENDPOINT](state, endpoint) { @@ -21,26 +20,12 @@ export default { }, [types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) { - const infinitelyCollapsibleSectionsFlag = - gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]; if (log.state) { state.jobLogState = log.state; } if (log.append) { - if (infinitelyCollapsibleSectionsFlag) { - if (log.lines) { - const parsedResult = logLinesParser( - log.lines, - state.auxiliaryPartialJobLogHelpers, - state.jobLog, - ); - state.jobLog = parsedResult.parsedLines; - state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers; - } - } else { - state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog; - } + state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog; state.jobLogSize += log.size; } else { @@ -49,13 +34,7 @@ export default { // html or size. We keep the old value otherwise these // will be set to `null` - if (infinitelyCollapsibleSectionsFlag) { - const parsedResult = logLinesParser(log.lines); - state.jobLog = parsedResult.parsedLines; - state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers; - } else { - state.jobLog = log.lines ? logLinesParserLegacy(log.lines) : state.jobLog; - } + state.jobLog = log.lines ? logLinesParser(log.lines) : state.jobLog; state.jobLogSize = log.size || state.jobLogSize; } diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index a1ba64aa71e..dfff65c364d 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -30,7 +30,4 @@ export default () => ({ selectedStage: '', stages: [], jobs: [], - - // to parse partial logs - auxiliaryPartialJobLogHelpers: {}, }); diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 7dfe24afa23..a7b95154c1b 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -104,7 +104,7 @@ export const getIncrementalLineNumber = (acc) => { * @param Array accumulator * @returns Array parsed log lines */ -export const logLinesParserLegacy = (lines = [], accumulator = []) => +export const logLinesParser = (lines = [], accumulator = []) => lines.reduce( (acc, line, index) => { const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index; @@ -131,82 +131,6 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) => [...accumulator], ); -export const logLinesParser = (lines = [], previousJobLogState = {}, prevParsedLines = []) => { - let currentLineCount = previousJobLogState?.prevLineCount ?? 0; - let currentHeader = previousJobLogState?.currentHeader; - let isPreviousLineHeader = previousJobLogState?.isPreviousLineHeader ?? false; - const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : []; - const sectionsQueue = previousJobLogState?.sectionsQueue ?? []; - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - // First run we can use the current index, later runs we have to retrieve the last number of lines - currentLineCount = previousJobLogState?.prevLineCount ? currentLineCount + 1 : i + 1; - - if (line.section_header && !isPreviousLineHeader) { - // If there's no previous line header that means we're at the root of the log - - isPreviousLineHeader = true; - parsedLines.push(parseHeaderLine(line, currentLineCount)); - currentHeader = { index: parsedLines.length - 1 }; - } else if (line.section_header && isPreviousLineHeader) { - // If there's a current section, we can't push to the parsedLines array - sectionsQueue.push(currentHeader); - currentHeader = parseHeaderLine(line, currentLineCount); // Let's parse the incoming header line - } else if (line.section && !line.section_duration) { - // We're inside a collapsible section and want to parse a standard line - if (currentHeader?.index) { - // If the current section header is only an index, add the line as part of the lines - // array of the current collapsible section - parsedLines[currentHeader.index].lines.push(parseLine(line, currentLineCount)); - } else { - // Otherwise add it to the innermost collapsible section lines array - currentHeader.lines.push(parseLine(line, currentLineCount)); - } - } else if (line.section && line.section_duration) { - // NOTE: This marks the end of a section_header - const previousSection = sectionsQueue.pop(); - - // Add the duration to section header - // If at the root, just push the end to the current parsedLine, - // otherwise, push it to the previous sections queue - if (currentHeader?.index) { - parsedLines[currentHeader.index].line.section_duration = line.section_duration; - isPreviousLineHeader = false; - currentHeader = null; - } else if (currentHeader?.isHeader) { - currentHeader.line.section_duration = line.section_duration; - - if (previousSection && previousSection?.index) { - // Is the previous section on root? - parsedLines[previousSection.index].lines.push(currentHeader); - } else if (previousSection && !previousSection?.index) { - previousSection.lines.push(currentHeader); - } - - currentHeader = previousSection; - } else { - // On older job logs, there's no `section_header: true` response, it's just an object - // with the `section_duration` and `section` props, so we just parse it - // as a standard line - parsedLines.push(parseLine(line, currentLineCount)); - } - } else { - parsedLines.push(parseLine(line, currentLineCount)); - } - } - - return { - parsedLines, - auxiliaryPartialJobLogHelpers: { - isPreviousLineHeader, - currentHeader, - sectionsQueue, - prevLineCount: currentLineCount, - }, - }; -}; - /** * Finds the repeated offset, removes the old one * @@ -253,5 +177,5 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => { const parsedLog = findOffsetAndRemove(newLog, oldParsed); - return logLinesParserLegacy(newLog, parsedLog); + return logLinesParser(newLog, parsedLog); }; diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 4959550e273..a01c6df0003 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -8,6 +8,7 @@ const defaultConfig = { // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421 FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'], FORBID_TAGS: ['style', 'mstyle'], + ALLOW_UNKNOWN_PROTOCOLS: true, }; // Only icons urls from `gon` are allowed diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index b4f941294de..92118c8929f 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -1,30 +1,34 @@ +import { pick } from 'lodash'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; import remarkGfm from 'remark-gfm'; import remarkRehype, { all } from 'remark-rehype'; import rehypeRaw from 'rehype-raw'; -const createParser = () => { +const skipRenderingHandlers = { + footnoteReference: (h, node) => + h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []), + footnoteDefinition: (h, node) => + h( + node.position, + 'footnoteDefinition', + { identifier: node.identifier, label: node.label }, + all(h, node), + ), + code: (h, node) => + h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [ + { type: 'text', value: node.value }, + ]), +}; + +const createParser = ({ skipRendering = [] }) => { return unified() .use(remarkParse) .use(remarkGfm) .use(remarkRehype, { allowDangerousHtml: true, handlers: { - footnoteReference: (h, node) => - h( - node.position, - 'footnoteReference', - { identifier: node.identifier, label: node.label }, - [], - ), - footnoteDefinition: (h, node) => - h( - node.position, - 'footnoteDefinition', - { identifier: node.identifier, label: node.label }, - all(h, node), - ), + ...pick(skipRenderingHandlers, skipRendering), }, }) .use(rehypeRaw); @@ -54,8 +58,10 @@ const compilerFactory = (renderer) => * @returns {Promise<any>} Returns a promise with the result of rendering * the MDast tree */ -export const render = async ({ markdown, renderer }) => { - const { result } = await createParser().use(compilerFactory(renderer)).process(markdown); +export const render = async ({ markdown, renderer, skipRendering = [] }) => { + const { result } = await createParser({ skipRendering }) + .use(compilerFactory(renderer)) + .process(markdown); return result; }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 1ed0cc3130b..7925a10344a 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -11,6 +11,8 @@ import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; +export const NO_SCROLL_TO_HASH_CLASS = 'js-no-scroll-to-hash'; + export const getPagePath = (index = 0) => { const { page = '' } = document.body.dataset; return page.split(':')[index]; @@ -68,6 +70,10 @@ export const handleLocationHash = () => { hash = decodeURIComponent(hash); const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`); + + // Allow targets to opt out of scroll behavior + if (target?.classList.contains(NO_SCROLL_TO_HASH_CLASS)) return; + const fixedTabs = document.querySelector('.js-tabs-affix'); const fixedDiffStats = document.querySelector('.js-diff-files-changed'); const fixedNav = document.querySelector('.navbar-gitlab'); @@ -585,8 +591,7 @@ export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => { * @param {Number} precision */ export const roundOffFloat = (number, precision = 0) => { - // eslint-disable-next-line no-restricted-properties - const multiplier = Math.pow(10, precision); + const multiplier = 10 ** precision; return Math.round(number * multiplier) / multiplier; }; @@ -616,8 +621,7 @@ export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2; * @param {Number} precision */ export const roundDownFloat = (number, precision = 0) => { - // eslint-disable-next-line no-restricted-properties - const multiplier = Math.pow(10, precision); + const multiplier = 10 ** precision; return Math.floor(number * multiplier) / multiplier; }; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index dad9cbcb6f6..7b00995b2e5 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -498,3 +498,17 @@ export const markdownConfig = { * escaped to `'fix-'\''bug-behavior'\'''`. */ export const escapeShellString = (str) => `'${str.replace(allSingleQuotes, () => "'\\''")}'`; + +/** + * Adds plus character as delimiter for count + * if count is greater than limit of 1000 + * FE creation of `app/helpers/numbers_helper.rb` + * + * @param {Number} count + * @return {Number|String} + */ +export const limitedCounterWithDelimiter = (count) => { + const limit = 1000; + + return count > limit ? '1,000+' : count; +}; diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js new file mode 100644 index 00000000000..244adca86c9 --- /dev/null +++ b/app/assets/javascripts/linked_resources/index.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default function initLinkedResources() { + const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root'); + + if (linkedResourcesRootElement) { + const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: linkedResourcesRootElement, + name: 'LinkedResourcesRoot', + components: { + resourceLinksBlock: ResourceLinksBlock, + }, + render: (createElement) => + createElement('resource-links-block', { + props: { + issuableId, + helpPath, + canAddResourceLinks: parseBoolean(canAddResourceLinks), + }, + }), + }); + } +} diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue deleted file mode 100644 index 609592edc3b..00000000000 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ /dev/null @@ -1,280 +0,0 @@ -<script> -import { - GlSprintf, - GlAlert, - GlLink, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlInfiniteScroll, -} from '@gitlab/ui'; -import { throttle } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; - -import { timeRangeFromUrl } from '~/monitoring/utils'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import { formatDate } from '../utils'; -import LogAdvancedFilters from './log_advanced_filters.vue'; -import LogControlButtons from './log_control_buttons.vue'; -import LogSimpleFilters from './log_simple_filters.vue'; - -export default { - components: { - GlSprintf, - GlLink, - GlAlert, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlInfiniteScroll, - LogSimpleFilters, - LogAdvancedFilters, - LogControlButtons, - }, - props: { - environmentName: { - type: String, - required: false, - default: '', - }, - currentPodName: { - type: [String, null], - required: false, - default: null, - }, - environmentsPath: { - type: String, - required: false, - default: '', - }, - clusterApplicationsDocumentationPath: { - type: String, - required: true, - }, - clustersPath: { - type: String, - required: true, - }, - }, - data() { - return { - isElasticStackCalloutDismissed: false, - scrollDownButtonDisabled: true, - isDeprecationNoticeDismissed: false, - }; - }, - computed: { - ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']), - ...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']), - - showLoader() { - return this.logs.isLoading; - }, - shouldShowElasticStackCallout() { - return !( - this.environments.isLoading || - this.isElasticStackCalloutDismissed || - this.showAdvancedFilters - ); - }, - }, - mounted() { - this.setInitData({ - timeRange: timeRangeFromUrl() || defaultTimeRange, - environmentName: this.environmentName, - podName: this.currentPodName, - }); - - this.fetchEnvironments(this.environmentsPath); - }, - methods: { - ...mapActions('environmentLogs', [ - 'setInitData', - 'showEnvironment', - 'fetchEnvironments', - 'refreshPodLogs', - 'fetchMoreLogsPrepend', - 'dismissRequestEnvironmentsError', - 'dismissInvalidTimeRangeWarning', - 'dismissRequestLogsError', - ]), - - isCurrentEnvironment(envName) { - return envName === this.environments.current; - }, - topReached() { - if (!this.logs.isLoading) { - this.fetchMoreLogsPrepend(); - } - }, - scrollDown() { - this.$refs.infiniteScroll.scrollDown(); - }, - scroll: throttle(function scrollThrottled({ target = {} }) { - const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target; - this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight; - }, 200), - formatDate, - }, -}; -</script> -<template> - <div class="environment-logs-viewer d-flex flex-column py-3"> - <gl-alert - v-if="shouldShowElasticStackCallout" - ref="elasticsearchNotice" - class="mb-3" - @dismiss="isElasticStackCalloutDismissed = true" - > - {{ - s__( - 'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.', - ) - }} - <a :href="clusterApplicationsDocumentationPath"> - <strong> - {{ __('View Documentation') }} - </strong> - </a> - </gl-alert> - <gl-alert - v-if="environments.fetchError" - class="mb-3" - variant="danger" - @dismiss="dismissRequestEnvironmentsError" - > - {{ s__('Metrics|There was an error fetching the environments data, please try again') }} - </gl-alert> - <gl-alert - v-if="timeRange.invalidWarning" - class="mb-3" - variant="warning" - @dismiss="dismissInvalidTimeRangeWarning" - > - {{ s__('Metrics|Invalid time range, please verify.') }} - </gl-alert> - <gl-alert - v-if="!isDeprecationNoticeDismissed" - :title="s__('Deprecations|Feature deprecation and removal')" - class="mb-3" - variant="danger" - @dismiss="isDeprecationNoticeDismissed = true" - > - <gl-sprintf - :message=" - s__( - 'Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.', - ) - " - > - <template #epic="{ content }"> - <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - - <gl-sprintf - :message=" - s__( - 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.', - ) - " - > - <template #epic="{ content }"> - <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> - <gl-alert - v-if="logs.fetchError" - class="mb-3" - variant="danger" - @dismiss="dismissRequestLogsError" - > - {{ s__('Environments|There was an error fetching the logs. Please try again.') }} - </gl-alert> - - <div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2"> - <div class="flex-grow-0"> - <gl-dropdown - id="environments-dropdown" - :text="environments.current" - :disabled="environments.isLoading" - class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown" - > - <gl-dropdown-section-header> - {{ s__('Environments|Environments') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="env in environments.options" - :key="env.id" - :is-check-item="true" - :is-checked="isCurrentEnvironment(env.name)" - @click="showEnvironment(env.name)" - > - {{ env.name }} - </gl-dropdown-item> - </gl-dropdown> - </div> - - <log-advanced-filters - v-if="showAdvancedFilters" - ref="log-advanced-filters" - class="d-md-flex flex-grow-1 min-width-0" - :disabled="environments.isLoading" - /> - <log-simple-filters - v-else - ref="log-simple-filters" - class="d-md-flex flex-grow-1 min-width-0" - :disabled="environments.isLoading" - /> - - <log-control-buttons - ref="scrollButtons" - class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex" - :scroll-down-button-disabled="scrollDownButtonDisabled" - @refresh="refreshPodLogs()" - @scrollDown="scrollDown" - /> - </div> - - <gl-infinite-scroll - ref="infiniteScroll" - class="log-lines overflow-auto flex-grow-1 min-height-0" - :fetched-items="logs.lines.length" - @topReached="topReached" - @scroll="scroll" - > - <template #items> - <pre - ref="logTrace" - class="build-log" - ><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation"> - <div class="dot"></div> - <div class="dot"></div> - <div class="dot"></div> - </div>{{trace}} - </code></pre> - </template> - <template #default><div></div></template> - </gl-infinite-scroll> - - <div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900"> - <gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')"> - <template #start>{{ formatDate(timeRange.current.start) }}</template> - <template #end>{{ formatDate(timeRange.current.end) }}</template> - </gl-sprintf> - <gl-sprintf - v-if="!logs.isComplete" - :message="s__('Environments|Currently showing %{fetched} results.')" - > - <template #fetched>{{ logs.lines.length }}</template> - </gl-sprintf> - <template v-else> {{ s__('Environments|Currently showing all results.') }}</template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/logs/components/log_advanced_filters.vue b/app/assets/javascripts/logs/components/log_advanced_filters.vue deleted file mode 100644 index c6d7c9ad1dc..00000000000 --- a/app/assets/javascripts/logs/components/log_advanced_filters.vue +++ /dev/null @@ -1,99 +0,0 @@ -<script> -import { GlFilteredSearch } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; -import { timeRanges } from '~/vue_shared/constants'; -import { TOKEN_TYPE_POD_NAME } from '../constants'; -import TokenWithLoadingState from './tokens/token_with_loading_state.vue'; - -export default { - components: { - GlFilteredSearch, - DateTimePicker, - }, - props: { - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - timeRanges, - }; - }, - computed: { - ...mapState('environmentLogs', ['timeRange', 'pods', 'logs']), - - timeRangeModel: { - get() { - return this.timeRange.selected; - }, - set(val) { - this.setTimeRange(val); - }, - }, - /** - * Token options. - * - * Returns null when no pods are present, so suggestions are displayed in the token - */ - podOptions() { - if (this.pods.options.length) { - return this.pods.options.map((podName) => ({ value: podName, title: podName })); - } - return null; - }, - - tokens() { - return [ - { - icon: 'pod', - type: TOKEN_TYPE_POD_NAME, - title: s__('Environments|Pod name'), - token: TokenWithLoadingState, - operators: OPERATOR_IS_ONLY, - unique: true, - options: this.podOptions, - loading: this.logs.isLoading, - noOptionsText: s__('Environments|No pods to display'), - }, - ]; - }, - }, - methods: { - ...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']), - - filteredSearchSubmit(filters) { - this.showFilteredLogs(filters); - }, - }, -}; -</script> -<template> - <div> - <div class="mb-2 pr-2 flex-grow-1 min-width-0"> - <gl-filtered-search - :placeholder="__('Search')" - :clear-button-title="__('Clear')" - :close-button-title="__('Close')" - class="gl-h-32" - :disabled="disabled || logs.isLoading" - :available-tokens="tokens" - @submit="filteredSearchSubmit" - /> - </div> - - <date-time-picker - ref="dateTimePicker" - v-model="timeRangeModel" - :disabled="disabled" - :options="timeRanges" - class="mb-2 gl-h-32 pr-2 d-block date-time-picker-wrapper" - right - /> - </div> -</template> diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue deleted file mode 100644 index e44b5394fa1..00000000000 --- a/app/assets/javascripts/logs/components/log_control_buttons.vue +++ /dev/null @@ -1,95 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; - -export default { - components: { - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - scrollUpButtonDisabled: { - type: Boolean, - required: false, - default: false, - }, - scrollDownButtonDisabled: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - scrollUpAvailable: Boolean(this.$listeners.scrollUp), - scrollDownAvailable: Boolean(this.$listeners.scrollDown), - }; - }, - methods: { - handleRefreshClick() { - this.$emit('refresh'); - }, - handleScrollUp() { - this.$emit('scrollUp'); - }, - handleScrollDown() { - this.$emit('scrollDown'); - }, - }, -}; -</script> - -<template> - <div> - <div - v-if="scrollUpAvailable" - v-gl-tooltip - class="controllers-buttons" - :title="__('Scroll to top')" - aria-labelledby="scroll-to-top" - > - <gl-button - id="scroll-to-top" - class="js-scroll-to-top gl-mr-2 btn-blank" - :aria-label="__('Scroll to top')" - :disabled="scrollUpButtonDisabled" - icon="scroll_up" - category="primary" - variant="default" - @click="handleScrollUp()" - /> - </div> - <div - v-if="scrollDownAvailable" - v-gl-tooltip - :disabled="scrollUpButtonDisabled" - class="controllers-buttons" - :title="__('Scroll to bottom')" - aria-labelledby="scroll-to-bottom" - > - <gl-button - id="scroll-to-bottom" - class="js-scroll-to-bottom gl-mr-2 btn-blank" - :aria-label="__('Scroll to bottom')" - :v-if="scrollDownAvailable" - :disabled="scrollDownButtonDisabled" - icon="scroll_down" - category="primary" - variant="default" - @click="handleScrollDown()" - /> - </div> - <gl-button - id="refresh-log" - v-gl-tooltip - class="js-refresh-log" - :title="__('Refresh')" - :aria-label="__('Refresh')" - icon="retry" - category="primary" - variant="default" - @click="handleRefreshClick" - /> - </div> -</template> diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue deleted file mode 100644 index 55bdd5f0088..00000000000 --- a/app/assets/javascripts/logs/components/log_simple_filters.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> -import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; - -export default { - components: { - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - }, - props: { - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - searchQuery: '', - }; - }, - computed: { - ...mapState('environmentLogs', ['pods']), - - podDropdownText() { - return this.pods.current || s__('Environments|No pod selected'); - }, - }, - methods: { - ...mapActions('environmentLogs', ['showPodLogs']), - isCurrentPod(podName) { - return podName === this.pods.current; - }, - }, -}; -</script> -<template> - <div> - <gl-dropdown - ref="podsDropdown" - :text="podDropdownText" - :disabled="disabled" - class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block qa-pods-dropdown" - > - <gl-dropdown-section-header> - {{ s__('Environments|Select pod') }} - </gl-dropdown-section-header> - - <gl-dropdown-item v-if="!pods.options.length" disabled> - <span ref="noPodsMsg" class="text-muted"> - {{ s__('Environments|No pods to display') }} - </span> - </gl-dropdown-item> - <gl-dropdown-item - v-for="podName in pods.options" - :key="podName" - :is-check-item="true" - :is-checked="isCurrentPod(podName)" - class="text-nowrap" - @click="showPodLogs(podName)" - > - {{ podName }} - </gl-dropdown-item> - </gl-dropdown> - </div> -</template> diff --git a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue deleted file mode 100644 index 4e672c1d121..00000000000 --- a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue +++ /dev/null @@ -1,30 +0,0 @@ -<script> -import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui'; - -export default { - components: { - GlFilteredSearchToken, - GlLoadingIcon, - }, - inheritAttrs: false, - props: { - config: { - type: Object, - required: true, - }, - }, -}; -</script> - -<template> - <gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners"> - <template #suggestions> - <div class="m-1"> - <gl-loading-icon v-if="config.loading" size="sm" /> - <div v-else class="py-1 px-2 text-muted"> - {{ config.noOptionsText }} - </div> - </div> - </template> - </gl-filtered-search-token> -</template> diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js deleted file mode 100644 index abc4d6679a0..00000000000 --- a/app/assets/javascripts/logs/constants.js +++ /dev/null @@ -1,16 +0,0 @@ -export const dateFormatMask = 'mmm dd HH:MM:ss.l'; - -export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME'; - -export const tracking = { - USED_SEARCH_BAR: 'used_search_bar', - POD_LOG_CHANGED: 'pod_log_changed', - TIME_RANGE_SET: 'time_range_set', - ENVIRONMENT_SELECTED: 'environment_selected', - REFRESH_POD_LOGS: 'refresh_pod_logs', - MANAGED_APP_SELECTED: 'managed_app_selected', -}; - -export const logExplorerOptions = { - environments: 'environments', -}; diff --git a/app/assets/javascripts/logs/index.js b/app/assets/javascripts/logs/index.js deleted file mode 100644 index 70dbffdc3dd..00000000000 --- a/app/assets/javascripts/logs/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue'; -import { getParameterValues } from '~/lib/utils/url_utility'; -import LogViewer from './components/environment_logs.vue'; -import store from './stores'; - -export default (props = {}) => { - const el = document.getElementById('environment-logs'); - const [currentPodName] = getParameterValues('pod_name'); - - // eslint-disable-next-line no-new - new Vue({ - el, - store, - render(createElement) { - return createElement(LogViewer, { - props: { - ...el.dataset, - currentPodName, - ...props, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/logs/logs_tracking_helper.js b/app/assets/javascripts/logs/logs_tracking_helper.js deleted file mode 100644 index 26043d646b0..00000000000 --- a/app/assets/javascripts/logs/logs_tracking_helper.js +++ /dev/null @@ -1,18 +0,0 @@ -import Tracking from '~/tracking'; - -/** - * The value of 1 in count, means there was one action performed - * related to the tracked action, in either of the following categories - * 1. Refreshing the logs - * 2. Select an environment - * 3. Change the time range - * 4. Use the search bar - */ -const trackLogs = (label) => - Tracking.event(document.body.dataset.page, 'logs_view', { - label, - property: 'count', - value: 1, - }); - -export default trackLogs; diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js deleted file mode 100644 index 56b832de9b8..00000000000 --- a/app/assets/javascripts/logs/stores/actions.js +++ /dev/null @@ -1,174 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import httpStatusCodes from '~/lib/utils/http_status'; -import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants'; -import trackLogs from '../logs_tracking_helper'; - -import * as types from './mutation_types'; - -const requestUntilData = (url, params) => - backOff((next, stop) => { - axios - .get(url, { params }) - .then((res) => { - if (res.status === httpStatusCodes.ACCEPTED) { - next(); - return; - } - stop(res); - }) - .catch((err) => { - stop(err); - }); - }); - -const requestLogsUntilData = ({ commit, state }) => { - const params = {}; - const type = logExplorerOptions.environments; - const selectedObj = state[type].options.find(({ name }) => name === state[type].current); - const path = selectedObj.logs_api_path; - - if (state.pods.current) { - params.pod_name = state.pods.current; - } - if (state.search) { - params.search = state.search; - } - if (state.timeRange.current) { - try { - const { start, end } = convertToFixedRange(state.timeRange.current); - params.start_time = start; - params.end_time = end; - } catch { - commit(types.SHOW_TIME_RANGE_INVALID_WARNING); - } - } - if (state.logs.cursor) { - params.cursor = state.logs.cursor; - } - - return requestUntilData(path, params); -}; - -/** - * Converts filters emitted by the component, e.g. a filterered-search - * to parameters to be applied to the filters of the store - * @param {Array} filters - List of strings or objects to filter by. - * @returns {Object} - An object with `search` and `podName` keys. - */ -const filtersToParams = (filters = []) => { - // Strings become part of the `search` - const search = filters - .filter((f) => typeof f === 'string') - .join(' ') - .trim(); - - // null podName to show all pods - const podName = filters.find((f) => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null; - - return { search, podName }; -}; - -export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => { - commit(types.SET_TIME_RANGE, timeRange); - commit(types.SET_PROJECT_ENVIRONMENT, environmentName); - commit(types.SET_CURRENT_POD_NAME, podName); -}; - -export const showFilteredLogs = ({ dispatch, commit }, filters = []) => { - const { podName, search } = filtersToParams(filters); - - commit(types.SET_CURRENT_POD_NAME, podName); - commit(types.SET_SEARCH, search); - - dispatch('fetchLogs', tracking.USED_SEARCH_BAR); -}; - -export const showPodLogs = ({ dispatch, commit }, podName) => { - commit(types.SET_CURRENT_POD_NAME, podName); - dispatch('fetchLogs', tracking.POD_LOG_CHANGED); -}; - -export const setTimeRange = ({ dispatch, commit }, timeRange) => { - commit(types.SET_TIME_RANGE, timeRange); - dispatch('fetchLogs', tracking.TIME_RANGE_SET); -}; - -export const showEnvironment = ({ dispatch, commit }, environmentName) => { - commit(types.SET_PROJECT_ENVIRONMENT, environmentName); - dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); -}; - -export const refreshPodLogs = ({ dispatch, commit }) => { - commit(types.REFRESH_POD_LOGS); - dispatch('fetchLogs', tracking.REFRESH_POD_LOGS); -}; - -/** - * Fetch environments data and initial logs - * @param {Object} store - * @param {String} environmentsPath - */ -export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { - commit(types.REQUEST_ENVIRONMENTS_DATA); - - return axios - .get(environmentsPath) - .then(({ data }) => { - commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); - dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); - }) - .catch(() => { - commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR); - }); -}; - -export const fetchLogs = ({ commit, state }, trackingLabel) => { - commit(types.REQUEST_LOGS_DATA); - - return requestLogsUntilData({ commit, state }) - .then(({ data }) => { - const { pod_name, pods, logs, cursor } = data; - if (logs && logs.length > 0) { - trackLogs(trackingLabel); - } - commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor }); - commit(types.SET_CURRENT_POD_NAME, pod_name); - commit(types.RECEIVE_PODS_DATA_SUCCESS, pods); - }) - .catch(() => { - commit(types.RECEIVE_PODS_DATA_ERROR); - commit(types.RECEIVE_LOGS_DATA_ERROR); - }); -}; - -export const fetchMoreLogsPrepend = ({ commit, state }) => { - if (state.logs.isComplete) { - // return when all logs are loaded - return Promise.resolve(); - } - - commit(types.REQUEST_LOGS_DATA_PREPEND); - - return requestLogsUntilData({ commit, state }) - .then(({ data }) => { - const { logs, cursor } = data; - commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor }); - }) - .catch(() => { - commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR); - }); -}; - -export const dismissRequestEnvironmentsError = ({ commit }) => { - commit(types.HIDE_REQUEST_ENVIRONMENTS_ERROR); -}; - -export const dismissRequestLogsError = ({ commit }) => { - commit(types.HIDE_REQUEST_LOGS_ERROR); -}; - -export const dismissInvalidTimeRangeWarning = ({ commit }) => { - commit(types.HIDE_TIME_RANGE_INVALID_WARNING); -}; diff --git a/app/assets/javascripts/logs/stores/getters.js b/app/assets/javascripts/logs/stores/getters.js deleted file mode 100644 index bf71cfd8eb2..00000000000 --- a/app/assets/javascripts/logs/stores/getters.js +++ /dev/null @@ -1,14 +0,0 @@ -import { formatDate } from '../utils'; - -const mapTrace = ({ timestamp = null, pod = '', message = '' }) => - [timestamp ? formatDate(timestamp) : '', pod, message].join(' | '); - -export const trace = (state) => state.logs.lines.map(mapTrace).join('\n'); - -export const showAdvancedFilters = (state) => { - const environment = state.environments.options.find( - ({ name }) => name === state.environments.current, - ); - - return Boolean(environment?.enable_advanced_logs_querying); -}; diff --git a/app/assets/javascripts/logs/stores/index.js b/app/assets/javascripts/logs/stores/index.js deleted file mode 100644 index d16941ddf93..00000000000 --- a/app/assets/javascripts/logs/stores/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export const createStore = () => - new Vuex.Store({ - modules: { - environmentLogs: { - namespaced: true, - actions, - mutations, - state: state(), - getters, - }, - }, - }); - -export default createStore; diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js deleted file mode 100644 index c1ed65ff48b..00000000000 --- a/app/assets/javascripts/logs/stores/mutation_types.js +++ /dev/null @@ -1,26 +0,0 @@ -export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT'; -export const SET_SEARCH = 'SET_SEARCH'; -export const SET_MANAGED_APP = 'SET_MANAGED_APP'; - -export const SET_TIME_RANGE = 'SET_TIME_RANGE'; -export const SHOW_TIME_RANGE_INVALID_WARNING = 'SHOW_TIME_RANGE_INVALID_WARNING'; -export const HIDE_TIME_RANGE_INVALID_WARNING = 'HIDE_TIME_RANGE_INVALID_WARNING'; - -export const SET_CURRENT_POD_NAME = 'SET_CURRENT_POD_NAME'; - -export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; -export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; -export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'; -export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR'; - -export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA'; -export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS'; -export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR'; -export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND'; -export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS'; -export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR'; -export const HIDE_REQUEST_LOGS_ERROR = 'HIDE_REQUEST_LOGS_ERROR'; -export const REFRESH_POD_LOGS = 'REFRESH_POD_LOGS'; - -export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS'; -export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR'; diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js deleted file mode 100644 index 6736d7204b4..00000000000 --- a/app/assets/javascripts/logs/stores/mutations.js +++ /dev/null @@ -1,110 +0,0 @@ -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import * as types from './mutation_types'; - -const mapLine = ({ timestamp, pod, message }) => ({ - timestamp, - pod, - message, -}); - -export default { - // Search Data - [types.SET_SEARCH](state, searchQuery) { - state.search = searchQuery; - }, - - // Time Range Data - [types.SET_TIME_RANGE](state, timeRange) { - state.timeRange.selected = timeRange; - state.timeRange.current = convertToFixedRange(timeRange); - }, - [types.SHOW_TIME_RANGE_INVALID_WARNING](state) { - state.timeRange.invalidWarning = true; - }, - [types.HIDE_TIME_RANGE_INVALID_WARNING](state) { - state.timeRange.invalidWarning = false; - }, - - // Environments Data - [types.SET_PROJECT_ENVIRONMENT](state, environmentName) { - state.environments.current = environmentName; - - // Clear current pod options - state.pods.current = null; - state.pods.options = []; - }, - [types.REQUEST_ENVIRONMENTS_DATA](state) { - state.environments.options = []; - state.environments.isLoading = true; - }, - [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environmentOptions) { - state.environments.options = environmentOptions; - state.environments.isLoading = false; - }, - [types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state) { - state.environments.options = []; - state.environments.isLoading = false; - state.environments.fetchError = true; - }, - [types.HIDE_REQUEST_ENVIRONMENTS_ERROR](state) { - state.environments.fetchError = false; - }, - - // Logs data - [types.REQUEST_LOGS_DATA](state) { - state.timeRange.current = convertToFixedRange(state.timeRange.selected); - - state.logs.lines = []; - state.logs.isLoading = true; - - // start pagination from the beginning - state.logs.cursor = null; - state.logs.isComplete = false; - }, - [types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) { - state.logs.lines = logs.map(mapLine); - state.logs.isLoading = false; - state.logs.cursor = cursor; - - if (!cursor) { - state.logs.isComplete = true; - } - }, - [types.RECEIVE_LOGS_DATA_ERROR](state) { - state.logs.lines = []; - state.logs.isLoading = false; - state.logs.fetchError = true; - }, - - [types.REQUEST_LOGS_DATA_PREPEND](state) { - state.logs.isLoading = true; - }, - [types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) { - const lines = logs.map(mapLine); - state.logs.lines = lines.concat(state.logs.lines); - state.logs.isLoading = false; - state.logs.cursor = cursor; - - if (!cursor) { - state.logs.isComplete = true; - } - }, - [types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) { - state.logs.isLoading = false; - state.logs.fetchError = true; - }, - [types.HIDE_REQUEST_LOGS_ERROR](state) { - state.logs.fetchError = false; - }, - - // Pods data - [types.SET_CURRENT_POD_NAME](state, podName) { - state.pods.current = podName; - }, - [types.RECEIVE_PODS_DATA_SUCCESS](state, podOptions) { - state.pods.options = podOptions; - }, - [types.RECEIVE_PODS_DATA_ERROR](state) { - state.pods.options = []; - }, -}; diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js deleted file mode 100644 index ee17e8ecef2..00000000000 --- a/app/assets/javascripts/logs/stores/state.js +++ /dev/null @@ -1,56 +0,0 @@ -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { timeRanges, defaultTimeRange } from '~/vue_shared/constants'; - -export default () => ({ - /** - * Full text search - */ - search: '', - - /** - * Time range (Show last) - */ - timeRange: { - options: timeRanges, - // Selected time range, can be fixed or relative - selected: defaultTimeRange, - // Current time range, must be fixed - current: convertToFixedRange(defaultTimeRange), - - invalidWarning: false, - }, - - /** - * Environments list information - */ - environments: { - options: [], - isLoading: false, - current: null, - fetchError: false, - }, - - /** - * Jobs with logs - */ - logs: { - lines: [], - isLoading: false, - /** - * Logs `cursor` represents the current pagination position, - * Should be sent in next batch (page) of logs to be fetched - */ - cursor: null, - isComplete: false, - - fetchError: false, - }, - - /** - * Pods list information - */ - pods: { - options: [], - current: null, - }, -}); diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js deleted file mode 100644 index 74c2f8a68f8..00000000000 --- a/app/assets/javascripts/logs/utils.js +++ /dev/null @@ -1,4 +0,0 @@ -import dateFormat from 'dateformat'; -import { dateFormatMask } from './constants'; - -export const formatDate = (timestamp) => dateFormat(timestamp, dateFormatMask); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e3e8efdd771..349a28ace52 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -36,6 +36,7 @@ import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; import { initCopyCodeButton } from './behaviors/copy_code'; +import initHeaderSearch from './header_search/init'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; @@ -53,7 +54,7 @@ window.gl = window.gl || {}; // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon?.test_env) { - import(/* webpackMode: "eager" */ './test_utils/'); + import(/* webpackMode: "eager" */ './test_utils'); } document.addEventListener('beforeunload', () => { @@ -115,34 +116,6 @@ function deferredInitialisation() { ); } - const searchInputBox = document.querySelector('#search'); - if (searchInputBox) { - searchInputBox.addEventListener( - 'focus', - () => { - if (gon.features?.newHeaderSearch) { - import(/* webpackChunkName: 'globalSearch' */ '~/header_search') - .then(async ({ initHeaderSearchApp }) => { - // In case the user started searching before we bootstrapped, let's pass the search along. - const initialSearchValue = searchInputBox.value; - await initHeaderSearchApp(initialSearchValue); - // this is new #search input element. We need to re-find it. - document.querySelector('#search').focus(); - }) - .catch(() => {}); - } else { - import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete') - .then(({ default: initSearchAutocomplete }) => { - const searchDropdown = initSearchAutocomplete(); - searchDropdown.onSearchInputFocus(); - }) - .catch(() => {}); - } - }, - { once: true }, - ); - } - addSelectOnFocusBehaviour('.js-select-on-focus'); const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); @@ -169,6 +142,11 @@ function deferredInitialisation() { } } +// header search vue component bootstrap +// loading this inside requestIdleCallback is causing issues +// see https://gitlab.com/gitlab-org/gitlab/-/issues/365746 +initHeaderSearch(); + const $body = $('body'); const $document = $(document); const bootstrapBreakpoint = bp.getBreakpointSize(); diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 98995730df4..b824a013f3b 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -1,40 +1,48 @@ <script> import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { + MEMBER_TYPES, + ACTIVE_TAB_QUERY_PARAM_NAME, + TAB_QUERY_PARAM_VALUES, + EE_TABS, +} from 'ee_else_ce/members/constants'; import MembersApp from './app.vue'; const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0; +export const TABS = [ + { + namespace: MEMBER_TYPES.user, + title: __('Members'), + }, + { + namespace: MEMBER_TYPES.group, + title: __('Groups'), + attrs: { 'data-qa-selector': 'groups_list_tab' }, + queryParamValue: TAB_QUERY_PARAM_VALUES.group, + }, + { + namespace: MEMBER_TYPES.invite, + title: __('Invited'), + canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.invite, + }, + { + namespace: MEMBER_TYPES.accessRequest, + title: __('Access requests'), + canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, + }, + ...EE_TABS, +]; + export default { name: 'MembersTabs', ACTIVE_TAB_QUERY_PARAM_NAME, - TABS: [ - { - namespace: MEMBER_TYPES.user, - title: __('Members'), - }, - { - namespace: MEMBER_TYPES.group, - title: __('Groups'), - attrs: { 'data-qa-selector': 'groups_list_tab' }, - queryParamValue: TAB_QUERY_PARAM_VALUES.group, - }, - { - namespace: MEMBER_TYPES.invite, - title: __('Invited'), - canManageMembersPermissionsRequired: true, - queryParamValue: TAB_QUERY_PARAM_VALUES.invite, - }, - { - namespace: MEMBER_TYPES.accessRequest, - title: __('Access requests'), - canManageMembersPermissionsRequired: true, - queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, - }, - ], + TABS, components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton }, inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'], data() { @@ -43,20 +51,17 @@ export default { }; }, computed: { - ...mapState({ - userCount(state) { - return countComputed(state, MEMBER_TYPES.user); - }, - groupCount(state) { - return countComputed(state, MEMBER_TYPES.group); - }, - inviteCount(state) { - return countComputed(state, MEMBER_TYPES.invite); - }, - accessRequestCount(state) { - return countComputed(state, MEMBER_TYPES.accessRequest); - }, - }), + ...mapState( + Object.values(MEMBER_TYPES).reduce((getters, memberType) => { + return { + ...getters, + // eslint-disable-next-line @gitlab/require-i18n-strings + [`${memberType}Count`](state) { + return countComputed(state, memberType); + }, + }; + }, {}), + ), urlParams() { return Object.keys(queryToObject(window.location.search, { gatherArrays: true })); }, diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue index 92b757ffcba..966eb90e402 100644 --- a/app/assets/javascripts/members/components/table/member_avatar.vue +++ b/app/assets/javascripts/members/components/table/member_avatar.vue @@ -6,7 +6,13 @@ import UserAvatar from '../avatars/user_avatar.vue'; export default { name: 'MemberAvatar', - components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, + components: { + UserAvatar, + InviteAvatar, + GroupAvatar, + AccessRequestAvatar: UserAvatar, + BannedAvatar: UserAvatar, + }, props: { memberType: { type: String, diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 3436bcab2fc..51eff428d63 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -1,5 +1,5 @@ <script> -import { MEMBER_TYPES } from '../../constants'; +import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import { isGroup, isDirectMember, diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 8c40cc3f29d..2fe816c7ea2 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -3,6 +3,12 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +// Overridden in EE +export const EE_APP_OPTIONS = {}; + +// Overridden in EE +export const EE_TABS = []; + export const FIELD_KEY_ACCOUNT = 'account'; export const FIELD_KEY_SOURCE = 'source'; export const FIELD_KEY_GRANTED = 'granted'; diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 0df876cabd7..34660f8f499 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -2,8 +2,8 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { parseDataAttributes } from '~/members/utils'; +import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import MembersTabs from './components/members_tabs.vue'; -import { MEMBER_TYPES } from './constants'; import membersStore from './store'; export const initMembersApp = (el, options) => { diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 94041d77bb0..ed2e6a5af58 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -177,7 +177,7 @@ export default class MergeRequestTabs { this.peek = document.getElementById('js-peek'); this.sidebar = document.querySelector('.js-right-sidebar'); this.pageLayout = document.querySelector('.layout-page'); - this.expandSidebar = document.querySelector('.js-expand-sidebar'); + this.expandSidebar = document.querySelectorAll('.js-expand-sidebar, .js-sidebar-toggle'); this.paddingTop = 16; this.scrollPositions = {}; @@ -282,7 +282,11 @@ export default class MergeRequestTabs { const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`); if (tab) tab.classList.add('active'); - this.expandSidebar?.classList.toggle('gl-display-none!', action !== 'show'); + if (window.gon?.features?.movedMrSidebar) { + this.expandSidebar?.forEach((el) => + el.classList.toggle('gl-display-none!', action !== 'show'), + ); + } if (action === 'commits') { this.loadCommits(href); diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue index 34f9fe778ea..3a13c123d77 100644 --- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlSprintf, GlModal } from '@gitlab/ui'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -10,9 +10,7 @@ import eventHub from '../event_hub'; export default { components: { GlModal, - }, - directives: { - SafeHtml, + GlSprintf, }, props: { issueCount: { @@ -38,20 +36,10 @@ export default { }, computed: { text() { - const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', { - milestoneTitle: this.milestoneTitle, - }); - if (this.issueCount === 0 && this.mergeRequestCount === 0) { - return sprintf( - s__(`Milestones| + return s__(`Milestones| You’re about to permanently delete the milestone %{milestoneTitle}. -This milestone is not currently used in any issues or merge requests.`), - { - milestoneTitle, - }, - false, - ); +This milestone is not currently used in any issues or merge requests.`); } return sprintf( @@ -59,7 +47,6 @@ This milestone is not currently used in any issues or merge requests.`), You’re about to permanently delete the milestone %{milestoneTitle} and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}. Once deleted, it cannot be undone or recovered.`), { - milestoneTitle, issuesWithCount: n__('%d issue', '%d issues', this.issueCount), mergeRequestsWithCount: n__( '%d merge request', @@ -98,13 +85,13 @@ Once deleted, it cannot be undone or recovered.`), }); if (error.response && error.response.status === 404) { - createFlash({ + createAlert({ message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle, }), }); } else { - createFlash({ + createAlert({ message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { milestoneTitle: this.milestoneTitle, }), @@ -132,6 +119,10 @@ Once deleted, it cannot be undone or recovered.`), :action-cancel="$options.cancelProps" @primary="onSubmit" > - <p v-safe-html="text"></p> + <gl-sprintf :message="text"> + <template #milestoneTitle> + <strong>{{ milestoneTitle }}</strong> + </template> + </gl-sprintf> </gl-modal> </template> diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js index 05102f73f92..8f2721c2a5b 100644 --- a/app/assets/javascripts/milestones/milestone.js +++ b/app/assets/javascripts/milestones/milestone.js @@ -1,33 +1,27 @@ import createFlash from '~/flash'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; -import { historyPushState } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; +import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } from '~/tabs'; export default class Milestone { constructor() { this.tabsEl = document.querySelector('.js-milestone-tabs'); - this.glTabs = new GlTabsBehavior(this.tabsEl); this.loadedTabs = new WeakSet(); this.bindTabsSwitching(); - this.loadInitialTab(); + // eslint-disable-next-line no-new + new GlTabsBehavior(this.tabsEl, { history: HISTORY_TYPE_HASH }); } bindTabsSwitching() { this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => { const tab = event.target; const { activeTabPanel } = event.detail; - historyPushState(tab.getAttribute('href')); this.loadTab(tab, activeTabPanel); }); } - loadInitialTab() { - const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`); - this.glTabs.activateTab(tab || this.glTabs.activeTab); - } loadTab(tab, tabPanel) { const { endpoint } = tab.dataset; diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index e375435436e..eb7c43034a4 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -163,7 +163,7 @@ export default class SSHMirror { const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list'); let fingerprints = ''; sshHostKeys.fingerprints.forEach((fingerprint) => { - const escFingerprints = escape(fingerprint.fingerprint); + const escFingerprints = escape(fingerprint.fingerprint_sha256 || fingerprint.fingerprint); fingerprints += `<code>${escFingerprints}</code>`; }); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 70e253508ce..250d4b3c55f 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -358,10 +358,6 @@ export default { actionToRun = 'onExpandFromKeyboardShortcut'; break; - case keyboardShortcutKeys.VISIT_LOGS: - actionToRun = 'visitLogsPageFromKeyboardShortcut'; - break; - case keyboardShortcutKeys.SHOW_ALERT: actionToRun = 'showAlertModalFromKeyboardShortcut'; break; diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index f18290e7048..3338635bf96 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -170,7 +170,7 @@ export default { <template> <div ref="prometheusGraphsHeader"> - <div class="mb-2 mr-2 d-flex d-sm-block"> + <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"> <dashboards-dropdown id="monitor-dashboards-dropdown" data-qa-selector="dashboards_filter_dropdown" @@ -240,7 +240,7 @@ export default { <div class="flex-grow-1"></div> <div class="d-sm-flex"> - <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> + <div v-if="showRearrangePanelsBtn" class="gl-mb-3 gl-mr-3 gl-display-flex"> <gl-button :pressed="isRearrangingPanels" variant="default" @@ -253,7 +253,7 @@ export default { <div v-if="externalDashboardUrl && externalDashboardUrl.length" - class="mb-2 mr-2 d-flex d-sm-block" + class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block" > <gl-button class="flex-grow-1 js-external-dashboard-link" @@ -280,7 +280,7 @@ export default { <template v-if="shouldShowSettingsButton"> <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> - <div class="mb-2 mr-2 d-flex d-sm-block"> + <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> <gl-button v-gl-tooltip data-testid="metrics-settings-button" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index ff8ccded83b..7e7dcef7639 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -15,15 +15,13 @@ import { } from '@gitlab/ui'; import { mapState } from 'vuex'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import invalidUrl from '~/lib/utils/invalid_url'; -import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; +import { isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { panelTypes } from '../constants'; import { graphDataToCsv } from '../csv_export'; -import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorColumnChart from './charts/column.vue'; @@ -58,7 +56,6 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, - mixins: [glFeatureFlagsMixin()], props: { clipboardText: { type: String, @@ -106,9 +103,6 @@ export default { projectPath(state) { return state[this.namespace].projectPath; }, - logsPath(state) { - return state[this.namespace].logsPath; - }, timeRange(state) { return state[this.namespace].timeRange; }, @@ -142,17 +136,6 @@ export default { const metrics = this.graphData?.metrics || []; return metrics.some(({ loading }) => loading); }, - logsPathWithTimeRange() { - if (!this.glFeatures.monitorLogging) { - return null; - } - const timeRange = this.zoomedTimeRange || this.timeRange; - - if (this.logsPath && this.logsPath !== invalidUrl && timeRange) { - return timeRangeToUrl(timeRange, this.logsPath); - } - return null; - }, csvText() { if (this.graphData) { return graphDataToCsv(this.graphData); @@ -278,16 +261,6 @@ export default { safeUrl(url) { return isSafeURL(url) ? url : '#'; }, - visitLogsPage() { - if (this.logsPathWithTimeRange) { - visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL())); - } - }, - visitLogsPageFromKeyboardShortcut() { - if (this.isContextualMenuShown) { - this.visitLogsPage(); - } - }, downloadCsvFromKeyboardShortcut() { if (this.csvText && this.isContextualMenuShown) { this.$refs.downloadCsvLink.$el.firstChild.click(); @@ -351,13 +324,6 @@ export default { > {{ editCustomMetricLinkText }} </gl-dropdown-item> - <gl-dropdown-item - v-if="logsPathWithTimeRange" - ref="viewLogsLink" - :href="logsPathWithTimeRange" - > - {{ s__('Metrics|View logs') }} - </gl-dropdown-item> <gl-dropdown-item v-if="csvText" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 060ed896d7c..1b506c6564b 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -172,7 +172,6 @@ export const endpointKeys = [ 'dashboardsEndpoint', 'currentDashboard', 'projectPath', - 'logsPath', ]; /** @@ -271,7 +270,6 @@ export const VARIABLE_PREFIX = 'var-'; export const keyboardShortcutKeys = { EXPAND: 'e', - VISIT_LOGS: 'l', SHOW_ALERT: 'a', DOWNLOAD_CSV: 'd', CHART_COPY: 'c', diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 3883fa3380d..e513b575475 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -83,7 +83,6 @@ export default () => ({ externalDashboardUrl: '', projectPath: null, operationsSettingsPath: '', - logsPath: invalidUrl, addDashboardDocumentationPath: '', // static paths diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 221f28e923b..fd8749625da 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -29,7 +29,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => { canAccessOperationsSettings, operationsSettingsPath, projectPath, - logsPath, externalDashboardUrl, currentEnvironmentName, customDashboardBasePath, @@ -53,7 +52,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => { canAccessOperationsSettings, operationsSettingsPath, projectPath, - logsPath, externalDashboardUrl, currentEnvironmentName, customDashboardBasePath, diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js index 4a0602ad512..7527c685c71 100644 --- a/app/assets/javascripts/mr_notes/stores/index.js +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -7,14 +7,16 @@ import mrPageModule from './modules'; Vue.use(Vuex); +export const createModules = () => ({ + page: mrPageModule(), + notes: notesModule(), + diffs: diffsModule(), + batchComments: batchCommentsModule(), +}); + export const createStore = () => new Vuex.Store({ - modules: { - page: mrPageModule(), - notes: notesModule(), - diffs: diffsModule(), - batchComments: batchCommentsModule(), - }, + modules: createModules(), }); export default createStore(); diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index d93db7399e6..ef36e58374c 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-return-assign, @gitlab/require-i18n-strings */ +/* eslint-disable func-names, no-return-assign, @gitlab/require-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -6,9 +6,9 @@ import RefSelectDropdown from './ref_select_dropdown'; export default class NewBranchForm { constructor(form, availableRefs) { this.validate = this.validate.bind(this); - this.branchNameError = form.find('.js-branch-name-error'); - this.name = form.find('.js-branch-name'); - this.ref = form.find('#ref'); + this.branchNameError = form.querySelector('.js-branch-name-error'); + this.name = form.querySelector('.js-branch-name'); + this.ref = form.querySelector('#ref'); new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new this.setupRestrictions(); this.addBinding(); @@ -16,12 +16,13 @@ export default class NewBranchForm { } addBinding() { - return this.name.on('blur', this.validate); + this.name.addEventListener('blur', this.validate); } init() { - if (this.name.length && this.name.val().length > 0) { - return this.name.trigger('blur'); + if (this.name != null && this.name.value.length > 0) { + const event = new CustomEvent('blur'); + this.name.dispatchEvent(event); } } @@ -52,7 +53,7 @@ export default class NewBranchForm { validate() { const { indexOf } = []; - this.branchNameError.empty(); + this.branchNameError.innerHTML = ''; const unique = function (values, value) { if (indexOf.call(values, value) === -1) { values.push(value); @@ -73,7 +74,7 @@ export default class NewBranchForm { return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`; }; const validator = (errors, restriction) => { - const matched = this.name.val().match(restriction.pattern); + const matched = this.name.value.match(restriction.pattern); if (matched) { return errors.concat(formatter(matched.reduce(unique, []), restriction)); } @@ -81,8 +82,7 @@ export default class NewBranchForm { }; const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { - const errorMessage = $('<span/>').text(errors.join(', ')); - return this.branchNameError.append(errorMessage); + this.branchNameError.textContent = errors.join(', '); } } } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index e7ac27c5e3e..bd5945a951b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -172,9 +172,6 @@ export default { trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); }, - internalNotesEnabled() { - return Boolean(this.glFeatures.confidentialNotes); - }, disableSubmitButton() { return this.note.length === 0 || this.isSubmitting; }, @@ -414,7 +411,7 @@ export default { </template> <template v-else> <gl-form-checkbox - v-if="internalNotesEnabled && canSetInternalNote" + v-if="canSetInternalNote" v-model="noteIsInternal" class="gl-mb-2" data-testid="internal-note-checkbox" diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index 83326279423..61af0b06535 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -39,8 +39,8 @@ export default { </template> </gl-sprintf> </div> - <div class="discussion-filter-actions mt-2"> - <gl-button variant="default" @click="selectFilter(0)"> + <div class="discussion-filter-actions gl-mt-3 gl-display-flex"> + <gl-button variant="default" class="gl-mr-3" @click="selectFilter(0)"> {{ __('Show all activity') }} </gl-button> <gl-button variant="default" @click="selectFilter(1)"> diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue index 7e8bb75902b..c1e39f31bbb 100644 --- a/app/assets/javascripts/notes/components/discussion_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_navigator.vue @@ -25,7 +25,7 @@ export default { eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index af0c1e9619e..095ab5ddb0f 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSprintf, GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import $ from 'jquery'; import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; @@ -11,7 +11,6 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '~/locale'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -31,11 +30,12 @@ export default { name: 'NoteableNote', components: { GlSprintf, - userAvatarLink, noteHeader, noteActions, NoteBody, TimelineEntryItem, + GlAvatarLink, + GlAvatar, }, directives: { SafeHtml, @@ -196,13 +196,11 @@ export default { return fileResolvedFromAvailableSource || null; }, - avatarSize() { - // Use a different size if shown on a Merge Request Diff - if (this.line && !this.isOverviewTab) { - return 24; - } - - return 40; + isMRDiffView() { + return this.line && !this.isOverviewTab; + }, + authorAvatarAdaptiveSize() { + return { default: 24, md: 32 }; }, }, created() { @@ -261,7 +259,7 @@ export default { }); const confirmed = await confirmAction(msg, { primaryBtnVariant: 'danger', - primaryBtnText: this.note.confidential ? __('Delete Internal Note') : __('Delete Comment'), + primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'), }); if (confirmed) { @@ -428,19 +426,33 @@ export default { </template> </gl-sprintf> </div> - <div class="timeline-icon"> - <user-avatar-link - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="avatarSize" - lazy - > - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </user-avatar-link> + + <div v-if="isMRDiffView" class="gl-float-left gl-mt-n1 gl-mr-3"> + <gl-avatar-link :href="author.path"> + <gl-avatar + :src="author.avatar_url" + :entity-name="author.username" + :alt="author.name" + :size="24" + /> + + <slot name="avatar-badge"></slot> + </gl-avatar-link> + </div> + + <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2"> + <gl-avatar-link :href="author.path"> + <gl-avatar + :src="author.avatar_url" + :entity-name="author.username" + :alt="author.name" + :size="authorAvatarAdaptiveSize" + /> + + <slot name="avatar-badge"></slot> + </gl-avatar-link> </div> + <div class="timeline-content"> <div class="note-header"> <note-header diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 8cd4477a3bb..2bd3488ae1b 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -1,13 +1,20 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { uniqBy } from 'lodash'; +import { s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { + i18n: { + collapseReplies: s__('Notes|Collapse replies'), + expandReplies: s__('Notes|Expand replies'), + lastReplyBy: s__('Notes|Last reply by %{name}'), + }, components: { GlButton, - GlIcon, + GlLink, + GlSprintf, UserAvatarLink, TimeAgoTooltip, }, @@ -28,63 +35,83 @@ export default { uniqueAuthors() { const authors = this.replies.map((reply) => reply.author || {}); - return uniqBy(authors, (author) => author.username); + return uniqBy(authors, 'username'); + }, + liClasses() { + return this.collapsed + ? 'gl-text-gray-500 gl-rounded-bottom-left-base gl-rounded-bottom-right-base' + : 'gl-border-b'; }, - className() { - return this.collapsed ? 'collapsed' : 'expanded'; + buttonIcon() { + return this.collapsed ? 'chevron-right' : 'chevron-down'; + }, + buttonLabel() { + return this.collapsed ? this.$options.i18n.expandReplies : this.$options.i18n.collapseReplies; }, }, methods: { toggle() { + this.$refs.toggle.$el.focus(); this.$emit('toggle'); }, }, - ICON_CLASS: 'gl-mr-3 gl-cursor-pointer', }; </script> <template> <li - :class="className" - class="replies-toggle js-toggle-replies gl-display-flex! gl-align-items-center gl-flex-wrap" + :class="liClasses" + class="gl-display-flex! gl-align-items-center gl-flex-wrap gl-bg-gray-10 gl-py-3 gl-px-5 gl-border-t" > + <gl-button + ref="toggle" + class="gl-my-2 gl-mr-3 gl-p-0!" + category="tertiary" + :icon="buttonIcon" + :aria-label="buttonLabel" + @click="toggle" + /> <template v-if="collapsed"> - <gl-icon :class="$options.ICON_CLASS" name="chevron-right" @click.native="toggle" /> - <div> - <user-avatar-link - v-for="author in uniqueAuthors" - :key="author.username" - :link-href="author.path" - :img-alt="author.name" - :img-src="author.avatar_url" - :img-size="24" - :tooltip-text="author.name" - tooltip-placement="bottom" - /> - </div> + <user-avatar-link + v-for="author in uniqueAuthors" + :key="author.username" + class="gl-mr-3" + :link-href="author.path" + :img-alt="author.name" + img-css-classes="gl-mr-0!" + :img-src="author.avatar_url" + :img-size="24" + :tooltip-text="author.name" + tooltip-placement="bottom" + /> <gl-button - class="js-replies-text gl-mr-2" - category="tertiary" + class="gl-mr-2" variant="link" data-qa-selector="expand_replies_button" @click="toggle" > - {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} + {{ n__('%d reply', '%d replies', replies.length) }} </gl-button> - {{ __('Last reply by') }} - <a :href="lastReply.author.path" class="btn btn-link author-link gl-mx-2 gl-button"> - {{ lastReply.author.name }} - </a> + <gl-sprintf :message="$options.i18n.lastReplyBy"> + <template #name> + <gl-link + :href="lastReply.author.path" + class="gl-text-body! gl-text-decoration-none! gl-mx-2" + > + {{ lastReply.author.name }} + </gl-link> + </template> + </gl-sprintf> <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> </template> - <div + <gl-button v-else - class="collapse-replies-btn js-collapse-replies gl-display-flex align-items-center" + class="gl-text-body! gl-text-decoration-none!" + variant="link" data-qa-selector="collapse_replies_button" @click="toggle" > - <gl-icon :class="$options.ICON_CLASS" name="chevron-down" /> - <span class="gl-cursor-pointer">{{ s__('Notes|Collapse replies') }}</span> - </div> + {{ $options.i18n.collapseReplies }} + </gl-button> </li> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index b8575016762..3317f4e2383 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -10,8 +10,8 @@ export const OPENED = 'opened'; export const REOPENED = 'reopened'; export const CLOSED = 'closed'; export const MERGED = 'merged'; -export const ISSUE_NOTEABLE_TYPE = 'issue'; -export const EPIC_NOTEABLE_TYPE = 'epic'; +export const ISSUE_NOTEABLE_TYPE = 'Issue'; +export const EPIC_NOTEABLE_TYPE = 'Epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 754a534e055..45df91796fc 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -41,8 +41,6 @@ function updateUrlWithNoteId(noteId) { // Unmask the note's ID note?.setAttribute('id', `note_${noteId}`); - } else if (noteId) { - updateHistory(newHistoryEntry); } } diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 57bb9e295f9..82417c9134b 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -83,14 +83,17 @@ export const setExpandDiscussions = ({ commit }, { discussionIds, expanded }) => commit(types.SET_EXPAND_DISCUSSIONS, { discussionIds, expanded }); }; -export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) => { +export const fetchDiscussions = ( + { commit, dispatch, getters }, + { path, filter, persistFilter }, +) => { const config = filter !== undefined ? { params: { notes_filter: filter, persist_filter: persistFilter } } : null; if ( - window.gon?.features?.paginatedIssueDiscussions || + getters.noteableType === constants.ISSUE_NOTEABLE_TYPE || window.gon?.features?.paginatedMrDiscussions ) { return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 }); @@ -114,7 +117,7 @@ export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, curs return axios.get(path, { params }).then(({ data, headers }) => { commit(types.ADD_OR_UPDATE_DISCUSSIONS, data); - if (headers['x-next-page-cursor']) { + if (headers && headers['x-next-page-cursor']) { const nextConfig = { ...config }; if (config?.params?.persist_filter) { diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index ab0418388cd..5d77ff9dc0d 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -20,7 +20,6 @@ import { UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS, - ROOT_IMAGE_TEXT, ROOT_IMAGE_TOOLTIP, } from '../../constants/index'; @@ -100,7 +99,7 @@ export default { return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : ''; }, imageName() { - return this.imageDetails.name || ROOT_IMAGE_TEXT; + return this.imageDetails.name || this.imageDetails.project?.path; }, formattedSize() { const { size } = this.imageDetails; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue index 56da8e88b7a..bfa99c01c3f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue @@ -1,4 +1,5 @@ <script> +import { uniqueId } from 'lodash'; import { GlIcon, GlPopover, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { timeTilRun } from '../../utils'; @@ -43,6 +44,11 @@ export default { CLEANUP_STATUS_UNFINISHED, PARTIAL_CLEANUP_CONTINUE_MESSAGE, }, + data() { + return { + iconId: uniqueId('status-info-'), + }; + }, computed: { showStatus() { return this.status !== UNSCHEDULED_STATUS; @@ -85,14 +91,14 @@ export default { </span> <gl-icon v-if="failedDelete" - id="status-info" + :id="iconId" :size="14" class="gl-text-gray-500" data-testid="extra-info" name="information-o" /> <gl-popover - target="status-info" + :target="iconId" container="status-popover-container" v-bind="$options.statusPopoverOptions" > diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index e67d77210bb..aecc0bf92ea 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -1,10 +1,12 @@ <script> -import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { n__ } from '~/locale'; - +import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { joinPaths } from '~/lib/utils/url_utility'; import { LIST_DELETE_BUTTON_DISABLED, LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, @@ -13,8 +15,10 @@ import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, IMAGE_MIGRATING_STATE, - ROOT_IMAGE_TEXT, COPY_IMAGE_PATH_TITLE, + IMAGE_FULL_PATH_LABEL, + TRACKING_ACTION_CLICK_SHOW_FULL_PATH, + TRACKING_LABEL_REGISTRY_IMAGE_LIST, } from '../../constants/index'; import DeleteButton from '../delete_button.vue'; import CleanupStatus from './cleanup_status.vue'; @@ -25,6 +29,7 @@ export default { ClipboardButton, DeleteButton, GlSprintf, + GlButton, GlIcon, ListItem, GlSkeletonLoader, @@ -33,6 +38,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin(), glFeatureFlagsMixin()], inject: ['config'], props: { item: { @@ -54,6 +60,12 @@ export default { REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, COPY_IMAGE_PATH_TITLE, + IMAGE_FULL_PATH_LABEL, + }, + data() { + return { + showFullPath: false, + }; }, computed: { disabledDelete() { @@ -79,7 +91,17 @@ export default { ); }, imageName() { - return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`; + if (this.glFeatures.containerRegistryShowShortenedPath) { + if (this.showFullPath) { + return this.item.path; + } + const projectPath = this.item?.project?.path ?? ''; + if (this.item.name) { + return joinPaths(projectPath, this.item.name); + } + return projectPath; + } + return this.item.path; }, routerLinkEvent() { return this.deleting ? '' : 'click'; @@ -90,6 +112,15 @@ export default { : LIST_DELETE_BUTTON_DISABLED; }, }, + methods: { + hideButton() { + this.showFullPath = true; + this.$refs.imageName.$el.focus(); + this.track(TRACKING_ACTION_CLICK_SHOW_FULL_PATH, { + label: TRACKING_LABEL_REGISTRY_IMAGE_LIST, + }); + }, + }, }; </script> @@ -104,7 +135,20 @@ export default { :disabled="deleting" > <template #left-primary> + <gl-button + v-if="glFeatures.containerRegistryShowShortenedPath && !showFullPath" + v-gl-tooltip="{ + placement: 'top', + title: $options.i18n.IMAGE_FULL_PATH_LABEL, + }" + icon="ellipsis_h" + size="small" + class="gl-mr-2" + :aria-label="$options.i18n.IMAGE_FULL_PATH_LABEL" + @click="hideButton" + /> <router-link + ref="imageName" class="gl-text-body gl-font-weight-bold" data-testid="details-link" data-qa-selector="registry_image_content" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js index 17adaec7a7d..67ad281b835 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js @@ -1,6 +1,5 @@ -import { s__, __ } from '~/locale'; +import { __ } from '~/locale'; -export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); export const MORE_ACTIONS_TEXT = __('More actions'); export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') }; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js index c6a7591e0d9..020d78ad364 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js @@ -43,6 +43,13 @@ export const EMPTY_RESULT_MESSAGE = s__( export const COPY_IMAGE_PATH_TITLE = s__('ContainerRegistry|Copy image path'); +export const IMAGE_FULL_PATH_LABEL = s__('ContainerRegistry|Show full path'); + +// Tracking + +export const TRACKING_LABEL_REGISTRY_IMAGE_LIST = 'registry_image_list'; +export const TRACKING_ACTION_CLICK_SHOW_FULL_PATH = 'click_show_full_path'; + // Parameters export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED'; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index 71a85d8885e..9ebbdfa920d 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -24,7 +24,6 @@ import { FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, - ROOT_IMAGE_TEXT, GRAPHQL_PAGE_SIZE, MISSING_OR_DELETED_IMAGE_TITLE, MISSING_OR_DELETED_IMAGE_MESSAGE, @@ -111,7 +110,7 @@ export default { methods: { updateBreadcrumb() { const name = this.containerRepository?.id - ? this.containerRepository?.name || ROOT_IMAGE_TEXT + ? this.containerRepository?.name || this.containerRepository?.project?.path : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue index a25839be7e1..b91af19d623 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue @@ -2,6 +2,12 @@ import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +import Tracking from '~/tracking'; +import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; +import { + TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA, + TRACKING_LABEL_PACKAGE_ASSET, +} from '~/packages_and_registries/package_registry/constants'; export default { name: 'FileSha', @@ -9,6 +15,7 @@ export default { DetailsRow, ClipboardButton, }, + mixins: [Tracking.mixin()], props: { sha: { type: String, @@ -22,6 +29,18 @@ export default { i18n: { copyButtonTitle: s__('PackageRegistry|Copy SHA'), }, + computed: { + tracking() { + return { + category: packageTypeToTrackCategory(this.packageType), + }; + }, + }, + methods: { + copySha() { + this.track(TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA, { label: TRACKING_LABEL_PACKAGE_ASSET }); + }, + }, }; </script> @@ -35,6 +54,7 @@ export default { :title="$options.i18n.copyButtonTitle" category="tertiary" size="small" + @click="copySha" /> </div> </details-row> 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 9e700a5236f..a049b0eff8d 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 @@ -5,8 +5,13 @@ import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue'; import Tracking from '~/tracking'; +import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { + TRACKING_LABEL_PACKAGE_ASSET, + TRACKING_ACTION_EXPAND_PACKAGE_ASSET, +} from '~/packages_and_registries/package_registry/constants'; export default { name: 'PackageFiles', @@ -76,6 +81,11 @@ export default { }, ].filter((c) => !c.hide); }, + tracking() { + return { + category: packageTypeToTrackCategory(this.packageType), + }; + }, }, methods: { formatSize(size) { @@ -84,6 +94,11 @@ export default { hasDetails(item) { return item.fileSha256 || item.fileMd5 || item.fileSha1; }, + trackToggleDetails(detailsShowing) { + if (!detailsShowing) { + this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET }); + } + }, }, i18n: { deleteFile: __('Delete file'), @@ -106,7 +121,10 @@ export default { :aria-label="detailsShowing ? __('Collapse') : __('Expand')" category="tertiary" size="small" - @click="toggleDetails" + @click=" + toggleDetails(); + trackToggleDetails(detailsShowing); + " /> <gl-link :href="item.downloadPath" @@ -129,8 +147,8 @@ export default { :href="item.pipeline.commitPath" class="gl-text-gray-500" data-testid="commit-link" - >{{ item.pipeline.sha }}</gl-link - > + >{{ item.pipeline.sha }} + </gl-link> </template> <template #cell(created)="{ item }"> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 3c090951b7d..cea053992f8 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -9,6 +9,7 @@ export { DELETE_PACKAGE_FILE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, } from '~/packages_and_registries/shared/constants'; export const PACKAGE_TYPE_CONAN = 'CONAN'; @@ -62,6 +63,12 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND = export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND = 'copy_composer_package_include_command'; +export const TRACKING_LABEL_PACKAGE_ASSET = 'package_assets'; + +export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset'; +export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset'; +export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha'; + export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package file.', diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index 768c8d6478b..29438fba86b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -33,7 +33,6 @@ import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, - PULL_PACKAGE_TRACKING_ACTION, DELETE_PACKAGE_FILE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, @@ -41,6 +40,7 @@ import { FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, } from '~/packages_and_registries/package_registry/constants'; import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; @@ -76,10 +76,10 @@ export default { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, - PULL_PACKAGE_TRACKING_ACTION, DELETE_PACKAGE_FILE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, }, data() { return { @@ -288,7 +288,7 @@ export default { v-if="showFiles" :can-delete="packageEntity.canDestroy" :package-files="packageFiles" - @download-file="track($options.trackingActions.PULL_PACKAGE)" + @download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)" @delete-file="handleFileDelete" /> </gl-tab> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js index 482a3ef2ead..3689199751d 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js @@ -1,7 +1,6 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '~/vue_shared/translate'; import SettingsApp from './components/group_settings_app.vue'; import { apolloProvider } from './graphql'; @@ -20,7 +19,6 @@ export default () => { provide: { groupPath: el.dataset.groupPath, groupDependencyProxyPath: el.dataset.groupDependencyProxyPath, - defaultExpanded: parseBoolean(el.dataset.defaultExpanded), }, render(createElement) { return createElement(SettingsApp); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue index 130d6977936..59d4f5e24d0 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue @@ -1,8 +1,7 @@ <script> import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql'; import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; @@ -13,6 +12,7 @@ import { import { DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_DESCRIPTION, DEPENDENCY_PROXY_DOCS_PATH, } from '~/packages_and_registries/settings/group/constants'; @@ -23,15 +23,14 @@ export default { GlSprintf, GlLink, SettingsBlock, - SettingsTitles, }, i18n: { DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_DESCRIPTION, enabledProxyLabel: s__('DependencyProxy|Enable Dependency Proxy'), enabledProxyHelpText: s__( 'DependencyProxy|To see the image prefix and what is in the cache, visit the %{linkStart}Dependency Proxy%{linkEnd}', ), - storageSettingsTitle: s__('DependencyProxy|Storage settings'), ttlPolicyEnabledLabel: s__('DependencyProxy|Clear the Dependency Proxy cache automatically'), ttlPolicyEnabledHelpText: s__( 'DependencyProxy|When enabled, images older than 90 days will be removed from the cache.', @@ -40,7 +39,7 @@ export default { links: { DEPENDENCY_PROXY_DOCS_PATH, }, - inject: ['defaultExpanded', 'groupPath', 'groupDependencyProxyPath'], + inject: ['groupPath', 'groupDependencyProxyPath'], props: { dependencyProxySettings: { type: Object, @@ -130,11 +129,9 @@ export default { </script> <template> - <settings-block - :default-expanded="defaultExpanded" - data-qa-selector="dependency_proxy_settings_content" - > + <settings-block data-qa-selector="dependency_proxy_settings_content"> <template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template> + <template #description> {{ $options.i18n.DEPENDENCY_PROXY_DESCRIPTION }} </template> <template #default> <div> <gl-toggle @@ -156,13 +153,12 @@ export default { </span> </template> </gl-toggle> - - <settings-titles :title="$options.i18n.storageSettingsTitle" class="gl-my-6" /> <gl-toggle v-model="ttlEnabled" :disabled="isLoading" :label="$options.i18n.ttlPolicyEnabledLabel" :help="$options.i18n.ttlPolicyEnabledHelpText" + class="gl-mt-6" data-testid="dependency-proxy-ttl-policies-toggle" /> </div> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue index b0088838acc..51a97aead49 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue @@ -1,11 +1,9 @@ <script> -import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { isEqual } from 'lodash'; import { DUPLICATES_TOGGLE_LABEL, - DUPLICATES_ALLOWED_DISABLED, - DUPLICATES_ALLOWED_ENABLED, DUPLICATES_SETTING_EXCEPTION_TITLE, DUPLICATES_SETTINGS_EXCEPTION_LEGEND, } from '~/packages_and_registries/settings/group/constants'; @@ -18,7 +16,6 @@ export default { DUPLICATES_SETTINGS_EXCEPTION_LEGEND, }, components: { - GlSprintf, GlToggle, GlFormGroup, GlFormInput, @@ -63,9 +60,6 @@ export default { }, }, computed: { - enabledButtonLabel() { - return this.duplicatesAllowed ? DUPLICATES_ALLOWED_ENABLED : DUPLICATES_ALLOWED_DISABLED; - }, isExceptionRegexValid() { return !this.duplicateExceptionRegexError; }, @@ -80,41 +74,30 @@ export default { <template> <form> - <div class="gl-display-flex"> - <gl-toggle - :data-qa-selector="toggleQaSelector" - :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" - label-position="hidden" - :value="duplicatesAllowed" + <gl-toggle + :data-qa-selector="toggleQaSelector" + :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" + :value="!duplicatesAllowed" + :disabled="loading" + @change="update(modelNames.allowed, !$event)" + /> + <gl-form-group + v-if="!duplicatesAllowed" + class="gl-mt-4" + :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" + label-size="sm" + :state="isExceptionRegexValid" + :invalid-feedback="duplicateExceptionRegexError" + :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND" + label-for="maven-duplicated-settings-regex-input" + > + <gl-form-input + id="maven-duplicated-settings-regex-input" :disabled="loading" - @change="update(modelNames.allowed, $event)" + size="lg" + :value="duplicateExceptionRegex" + @change="update(modelNames.exception, $event)" /> - <div class="gl-ml-5"> - <div data-testid="toggle-label" :data-qa-selector="labelQaSelector"> - <gl-sprintf :message="enabledButtonLabel"> - <template #bold="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </div> - <gl-form-group - v-if="!duplicatesAllowed" - class="gl-mt-4" - :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" - label-size="sm" - :state="isExceptionRegexValid" - :invalid-feedback="duplicateExceptionRegexError" - :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND" - label-for="maven-duplicated-settings-regex-input" - > - <gl-form-input - id="maven-duplicated-settings-regex-input" - :disabled="loading" - :value="duplicateExceptionRegex" - @change="update(modelNames.exception, $event)" - /> - </gl-form-group> - </div> - </div> + </gl-form-group> </form> </template> 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 b7e88945dbd..abb9f02d290 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 @@ -1,17 +1,15 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; import { PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, - PACKAGES_DOCS_PATH, } from '~/packages_and_registries/settings/group/constants'; import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; export default { name: 'PackageSettings', @@ -19,18 +17,13 @@ export default { PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, }, - links: { - PACKAGES_DOCS_PATH, - }, components: { - GlSprintf, - GlLink, SettingsBlock, MavenSettings, GenericSettings, DuplicatesSettings, }, - inject: ['defaultExpanded', 'groupPath'], + inject: ['groupPath'], props: { packageSettings: { type: Object, @@ -91,20 +84,11 @@ export default { </script> <template> - <settings-block - :default-expanded="defaultExpanded" - data-qa-selector="package_registry_settings_content" - > + <settings-block data-qa-selector="package_registry_settings_content"> <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> <template #description> <span data-testid="description"> - <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION"> - <template #link="{ content }"> - <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> + {{ $options.i18n.PACKAGE_SETTINGS_DESCRIPTION }} </span> </template> <template #default> @@ -116,8 +100,8 @@ export default { :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" :model-names="modelNames" :loading="isLoading" - toggle-qa-selector="allow_duplicates_toggle" - label-qa-selector="allow_duplicates_label" + toggle-qa-selector="reject_duplicates_toggle" + label-qa-selector="reject_duplicates_label" @update="updateSettings" /> </template> 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 0249b475e46..34764663892 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -1,17 +1,13 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, __ } from '~/locale'; -export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Package Registry'); +export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages'); export const PACKAGE_SETTINGS_DESCRIPTION = s__( - 'PackageRegistry|Use GitLab as a private registry for common package formats. %{linkStart}Learn more.%{linkEnd}', + 'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.', ); -export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); -export const DUPLICATES_ALLOWED_DISABLED = s__( - 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Reject packages with the same name and version.', -); -export const DUPLICATES_ALLOWED_ENABLED = s__( - 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Accept packages with the same name and version.', +export const DUPLICATES_TOGGLE_LABEL = s__( + 'PackageRegistry|Reject packages with the same name and version', ); export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions'); export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( @@ -19,6 +15,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( ); export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy'); +export const DEPENDENCY_PROXY_DESCRIPTION = s__( + 'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.', +); // Parameters diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index fdc7bd39780..90a18d5cf5a 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -81,7 +81,7 @@ export default { </script> <template> - <settings-block :collapsible="false"> + <settings-block data-testid="container-expiration-policy-project-settings"> <template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template> <template #description> <span> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue index d75fb31fd98..7682754fdcb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue @@ -30,6 +30,11 @@ export default { type: String, required: true, }, + description: { + type: String, + required: false, + default: '', + }, }, }; </script> @@ -46,5 +51,10 @@ export default { {{ option.label }} </option> </gl-form-select> + <template v-if="description" #description> + <span data-testid="description" class="gl-text-gray-400"> + {{ description }} + </span> + </template> </gl-form-group> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue new file mode 100644 index 00000000000..1170407a349 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue @@ -0,0 +1,68 @@ +<script> +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + PACKAGES_CLEANUP_POLICY_TITLE, + PACKAGES_CLEANUP_POLICY_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue'; + +export default { + components: { + SettingsBlock, + GlAlert, + GlSprintf, + PackagesCleanupPolicyForm, + }, + inject: ['projectPath'], + i18n: { + FETCH_SETTINGS_ERROR_MESSAGE, + PACKAGES_CLEANUP_POLICY_TITLE, + PACKAGES_CLEANUP_POLICY_DESCRIPTION, + }, + apollo: { + packagesCleanupPolicy: { + query: packagesCleanupPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: (data) => data.project?.packagesCleanupPolicy || {}, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, + data() { + return { + fetchSettingsError: false, + packagesCleanupPolicy: {}, + }; + }, +}; +</script> + +<template> + <settings-block> + <template #title> {{ $options.i18n.PACKAGES_CLEANUP_POLICY_TITLE }}</template> + <template #description> + <span data-testid="description"> + <gl-sprintf :message="$options.i18n.PACKAGES_CLEANUP_POLICY_DESCRIPTION" /> + </span> + </template> + <template #default> + <gl-alert v-if="fetchSettingsError" variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> + </gl-alert> + <packages-cleanup-policy-form + v-else + v-model="packagesCleanupPolicy" + :is-loading="$apollo.queries.packagesCleanupPolicy.loading" + /> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue new file mode 100644 index 00000000000..b1751d5174a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -0,0 +1,137 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, + SET_CLEANUP_POLICY_BUTTON, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, +} from '~/packages_and_registries/settings/project/constants'; +import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; +import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; +import Tracking from '~/tracking'; +import ExpirationDropdown from './expiration_dropdown.vue'; + +export default { + components: { + GlButton, + ExpirationDropdown, + }, + mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + formOptions: formOptionsGenerator(), + i18n: { + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, + SET_CLEANUP_POLICY_BUTTON, + }, + data() { + return { + tracking: { + label: 'packages_cleanup_policies', + }, + mutationLoading: false, + }; + }, + computed: { + prefilledForm() { + return { + ...this.value, + keepNDuplicatedPackageFiles: this.findDefaultOption( + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + ), + }; + }, + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, + isSubmitButtonDisabled() { + return this.showLoadingIcon; + }, + isFieldDisabled() { + return this.showLoadingIcon; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles, + }; + }, + }, + methods: { + findDefaultOption(option) { + return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key; + }, + submit() { + this.track('submit_packages_cleanup_form'); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updatePackagesCleanupPolicyMutation, + variables: { + input: this.mutationVariables, + }, + }) + .then(({ data }) => { + const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? []; + if (errorMessage) { + throw errorMessage; + } else { + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); + } + }) + .catch(() => { + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE); + }) + .finally(() => { + this.mutationLoading = false; + }); + }, + onModelChange(newValue, model) { + this.$emit('input', { ...this.value, [model]: newValue }); + }, + }, +}; +</script> + +<template> + <form ref="form-element" @submit.prevent="submit"> + <div class="gl-md-max-w-50p"> + <expiration-dropdown + v-model="prefilledForm.keepNDuplicatedPackageFiles" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.keepNDuplicatedPackageFiles" + :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL" + :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION" + name="keep-n-duplicated-package-files" + data-testid="keep-n-duplicated-package-files-dropdown" + @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" + /> + </div> + <div class="gl-mt-7 gl-display-flex gl-align-items-center"> + <gl-button + data-testid="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + category="primary" + variant="confirm" + class="js-no-auto-disable gl-mr-4" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 95af19e6d85..710cfe7b1eb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -1,15 +1,19 @@ <script> import ContainerExpirationPolicy from './container_expiration_policy.vue'; +import PackagesCleanupPolicy from './packages_cleanup_policy.vue'; export default { components: { ContainerExpirationPolicy, + PackagesCleanupPolicy, }, + inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'], }; </script> <template> - <section data-testid="registry-settings-app"> - <container-expiration-policy /> - </section> + <div> + <packages-cleanup-policy v-if="showPackageRegistrySettings" /> + <container-expiration-policy v-if="showContainerRegistrySettings" /> + </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 40f980d15fb..948520151ce 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -55,6 +55,31 @@ export const EXPIRATION_POLICY_FOOTER_NOTE = s__( 'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time', ); +export const PACKAGES_CLEANUP_POLICY_TITLE = s__( + 'PackageRegistry|Manage storage used by package assets', +); +export const PACKAGES_CLEANUP_POLICY_DESCRIPTION = s__( + 'PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets.', +); +export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__( + 'PackageRegistry|Number of duplicate assets to keep', +); +export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__( + 'PackageRegistry|Examples of assets include .pom & .jar files', +); + +export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles'; + +export const KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS = [ + { key: 'ONE_PACKAGE_FILE', label: 1, default: false }, + { key: 'TEN_PACKAGE_FILES', label: 10, default: false }, + { key: 'TWENTY_PACKAGE_FILES', label: 20, default: false }, + { key: 'THIRTY_PACKAGE_FILES', label: 30, default: false }, + { key: 'FORTY_PACKAGE_FILES', label: 40, default: false }, + { key: 'FIFTY_PACKAGE_FILES', label: 50, default: false }, + { key: 'ALL_PACKAGE_FILES', label: __('All'), default: true }, +]; + export const KEEP_N_OPTIONS = [ { key: 'ONE_TAG', variable: 1, default: false }, { key: 'FIVE_TAGS', variable: 5, default: false }, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql new file mode 100644 index 00000000000..a77ede37884 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql @@ -0,0 +1,4 @@ +fragment PackagesCleanupPolicyFields on PackagesCleanupPolicy { + keepNDuplicatedPackageFiles + nextRunAt +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql new file mode 100644 index 00000000000..31cdd67e881 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/packages_cleanup_policy.fragment.graphql" + +mutation updatePackagesCleanupPolicy($input: UpdatePackagesCleanupPolicyInput!) { + updatePackagesCleanupPolicy(input: $input) { + packagesCleanupPolicy { + ...PackagesCleanupPolicyFields + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql new file mode 100644 index 00000000000..0e9af253f2c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql @@ -0,0 +1,10 @@ +#import "../fragments/packages_cleanup_policy.fragment.graphql" + +query getProjectPackagesCleanupPolicy($projectPath: ID!) { + project(fullPath: $projectPath) { + id + packagesCleanupPolicy { + ...PackagesCleanupPolicyFields + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index 17c33073668..daf1da6eac8 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js @@ -20,6 +20,8 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, + showContainerRegistrySettings, + showPackageRegistrySettings, } = el.dataset; return new Vue({ el, @@ -34,6 +36,8 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, + showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings), + showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings), }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js index b577a051862..847965454e9 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js @@ -1,5 +1,11 @@ import { n__ } from '~/locale'; -import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants'; +import { + KEEP_N_OPTIONS, + CADENCE_OPTIONS, + OLDER_THAN_OPTIONS, + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS, +} from './constants'; export const findDefaultOption = (options) => { const item = options.find((o) => o.default); @@ -25,5 +31,6 @@ export const formOptionsGenerator = () => { olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), cadence: CADENCE_OPTIONS, keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), + [KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME]: KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS, }; }; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue new file mode 100644 index 00000000000..5caf95cd050 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue @@ -0,0 +1,17 @@ +<template> + <section class="settings gl-py-7"> + <div class="gl-lg-display-flex"> + <div class="gl-lg-w-half gl-pr-10"> + <h4> + <slot name="title"></slot> + </h4> + <p> + <slot name="description"></slot> + </p> + </div> + <div class="gl-lg-w-half gl-pt-3"> + <slot></slot> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index afc72a2c627..5505205cf33 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js @@ -11,6 +11,7 @@ export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; +export const DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset'; export const TRACKING_ACTIONS = { DELETE_PACKAGE: DELETE_PACKAGE_TRACKING_ACTION, @@ -20,6 +21,7 @@ export const TRACKING_ACTIONS = { DELETE_PACKAGE_FILE: DELETE_PACKAGE_FILE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_FILE: REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_FILE: CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, + DOWNLOAD_PACKAGE_ASSET: DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, }; export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue index 5ecacb84d65..ccb449f96e1 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -12,6 +12,7 @@ import { import { toSafeInteger } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { __, n__, s__, sprintf } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SignupCheckbox from './signup_checkbox.vue'; const DENYLIST_TYPE_RAW = 'raw'; @@ -31,7 +32,12 @@ export default { GlLink, SignupCheckbox, GlModal, + PasswordComplexityCheckboxGroup: () => + import( + 'ee_component/pages/admin/application_settings/general/components/password_complexity_checkbox_group.vue' + ), }, + mixins: [glFeatureFlagMixin()], inject: [ 'host', 'settingsPath', @@ -178,6 +184,9 @@ export default { this.submitForm(); }, + setPasswordComplexity({ name, value }) { + this.$set(this.form, name, value); + }, submitForm() { this.$refs.form.submit(); }, @@ -291,9 +300,7 @@ export default { <template #description> <gl-sprintf :message=" - s__( - 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}.', - ) + s__('ApplicationSettings|See %{linkStart}password policy guidelines%{linkEnd}.') " > <template #link="{ content }"> @@ -305,6 +312,10 @@ export default { </template> </gl-form-group> + <password-complexity-checkbox-group + v-if="glFeatures.passwordComplexity" + @set-password-complexity="setPasswordComplexity" + /> <gl-form-group :description="$options.i18n.domainAllowListDescription" :label="$options.i18n.domainAllowListLabel" diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js index a50d8de0e88..0d5c55cb87b 100644 --- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js +++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js @@ -18,6 +18,10 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for 'domainDenylistEnabled', 'denylistTypeRawSelected', 'emailRestrictionsEnabled', + 'passwordNumberRequired', + 'passwordLowercaseRequired', + 'passwordUppercaseRequired', + 'passwordSymbolRequired', ], }); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 44299d235d5..e45a40bd44c 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -28,20 +28,32 @@ export default class Todos { } unbindEvents() { - $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper); - $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper); - $('.todo').off('click', this.goToTodoUrl); - $('.todo').off('auxclick', this.goToTodoUrl); + document.querySelectorAll('.js-done-todo, .js-undo-todo, .js-add-todo').forEach((el) => { + el.removeEventListener('click', this.updateRowStateClickedWrapper); + }); + document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => { + el.removeEventListener('click', this.updateallStateClickedWrapper); + }); + document.querySelectorAll('.todo').forEach((el) => { + el.removeEventListener('click', this.goToTodoUrl); + el.removeEventListener('auxclick', this.goToTodoUrl); + }); } bindEvents() { this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this); this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this); - $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper); - $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper); - $('.todo').on('click', this.goToTodoUrl); - $('.todo').on('auxclick', this.goToTodoUrl); + document.querySelectorAll('.js-done-todo, .js-undo-todo, .js-add-todo').forEach((el) => { + el.addEventListener('click', this.updateRowStateClickedWrapper); + }); + document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => { + el.addEventListener('click', this.updateAllStateClickedWrapper); + }); + document.querySelectorAll('.todo').forEach((el) => { + el.addEventListener('click', this.goToTodoUrl); + el.addEventListener('auxclick', this.goToTodoUrl); + }); } initFilters() { @@ -181,7 +193,13 @@ export default class Todos { } updateBadges(data) { - $(document).trigger('todo:toggle', data.count); + const event = new CustomEvent('todo:toggle', { + detail: { + count: data.count, + }, + }); + + document.dispatchEvent(event); document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter( data.count, ); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index c7c2f6f773e..62d47cb49b8 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -5,12 +5,11 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import { initMembersApp } from '~/members'; -import { MEMBER_TYPES } from '~/members/constants'; +import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; - -initMembersApp(document.querySelector('.js-group-members-list-app'), { +const APP_OPTIONS = { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, @@ -61,7 +60,10 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { tableFields: SHARED_FIELDS.concat('requested'), requestFormatter: groupMemberRequestFormatter, }, -}); + ...EE_APP_OPTIONS, +}; + +initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS); initInviteMembersModal(); initInviteGroupsModal(); diff --git a/app/assets/javascripts/pages/groups/runners/index.js b/app/assets/javascripts/pages/groups/runners/index/index.js index ca1a6bdab75..ca1a6bdab75 100644 --- a/app/assets/javascripts/pages/groups/runners/index.js +++ b/app/assets/javascripts/pages/groups/runners/index/index.js diff --git a/app/assets/javascripts/pages/groups/runners/show/index.js b/app/assets/javascripts/pages/groups/runners/show/index.js new file mode 100644 index 00000000000..c59e3b80dc1 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/show/index.js @@ -0,0 +1,3 @@ +import { initGroupRunnerShow } from '~/runner/group_runner_show'; + +initGroupRunnerShow(); diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js index 364223f1898..dbae89b5ade 100644 --- a/app/assets/javascripts/pages/projects/branches/new/index.js +++ b/app/assets/javascripts/pages/projects/branches/new/index.js @@ -1,8 +1,7 @@ -import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; // eslint-disable-next-line no-new new NewBranchForm( - $('.js-create-branch-form'), + document.querySelector('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML), ); 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 701bf0c1e1d..f92a40e057f 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 @@ -329,7 +329,7 @@ export default { </div> <p class="gl-mt-n5 gl-text-gray-500"> - {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }} + {{ s__('ForkProject|Want to organize several dependent projects under the same namespace?') }} <gl-link :href="newGroupPath" target="_blank"> {{ s__('ForkProject|Create a group') }} </gl-link> diff --git a/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js new file mode 100644 index 00000000000..abececa44ee --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/configuration/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js new file mode 100644 index 00000000000..5482324f1cd --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/databases/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js new file mode 100644 index 00000000000..b5a29b3825b --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/deployments/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js new file mode 100644 index 00000000000..fb66e2fa051 --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/gcp_regions/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/index.js b/app/assets/javascripts/pages/projects/google_cloud/index.js deleted file mode 100644 index 4506ea8efd1..00000000000 --- a/app/assets/javascripts/pages/projects/google_cloud/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initGoogleCloud from '~/google_cloud/index'; - -initGoogleCloud(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js new file mode 100644 index 00000000000..8b644c2b324 --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/service_accounts/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index 75194499a7f..eb3a24f38a8 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -1,23 +1,3 @@ -import Vue from 'vue'; import initJobsTable from '~/jobs/components/table'; -import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -if (gon.features?.jobsTableVue) { - initJobsTable(); -} else { - const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); - - remainingTimeElements.forEach( - (el) => - new Vue({ - el, - render(h) { - return h(GlCountdown, { - props: { - endDateString: el.dateTime, - }, - }); - }, - }), - ); -} +initJobsTable(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue new file mode 100644 index 00000000000..693dc6a15ad --- /dev/null +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue @@ -0,0 +1,15 @@ +<script> +import { s__ } from '~/locale'; + +export default { + name: 'IncludedInTrialIndicator', + i18n: { + trialOnly: s__('LearnGitlab|- Included in trial'), + }, +}; +</script> +<template> + <span class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> + {{ $options.i18n.trialOnly }} + </span> +</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index db9ef4df8af..54e15b6552c 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -38,14 +38,16 @@ export default { actionsData: this.actions, }; }, - maxValue: Object.keys(ACTION_LABELS).length, actionSections: Object.keys(ACTION_SECTIONS), computed: { + maxValue() { + return Object.keys(this.actionsData).length; + }, progressValue() { return Object.values(this.actionsData).filter((a) => a.completed).length; }, progressPercentage() { - return Math.round((this.progressValue / this.$options.maxValue) * 100); + return Math.round((this.progressValue / this.maxValue) * 100); }, }, mounted() { @@ -125,7 +127,7 @@ export default { <template #percentSymbol>%</template> </gl-sprintf> </p> - <gl-progress-bar :value="progressValue" :max="$options.maxValue" /> + <gl-progress-bar :value="progressValue" :max="maxValue" /> </div> <div class="row"> <div diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue deleted file mode 100644 index 09cc0032871..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue +++ /dev/null @@ -1,70 +0,0 @@ -<script> -import { GlLink, GlCard, GlIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - name: 'LearnGitlabInfoCard', - components: { GlLink, GlCard, GlIcon }, - i18n: { - trial: s__('Learn GitLab|Trial only'), - }, - props: { - title: { - required: true, - type: String, - }, - description: { - required: true, - type: String, - }, - actionLabel: { - required: true, - type: String, - }, - url: { - required: true, - type: String, - }, - completed: { - required: true, - type: Boolean, - }, - svg: { - required: true, - type: String, - }, - trialRequired: { - default: false, - required: false, - type: Boolean, - }, - }, -}; -</script> -<template> - <gl-card class="gl-pt-0"> - <div class="gl-text-right gl-h-5"> - <gl-icon - v-if="completed" - name="check-circle-filled" - class="gl-text-green-500" - :size="16" - data-testid="completed-icon" - /> - <span - v-else-if="trialRequired" - class="gl-text-gray-500 gl-font-sm gl-font-style-italic" - data-testid="trial-only" - >{{ $options.i18n.trial }}</span - > - </div> - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img :src="svg" :alt="actionLabel" /> - <h6>{{ title }}</h6> - <p class="gl-font-sm gl-text-gray-700">{{ description }}</p> - <gl-link :href="url" target="_blank" rel="noopener noreferrer" /> - </div> - </gl-card> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 1912477758b..4eab0cccb06 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -6,6 +6,7 @@ import { isExperimentVariant } from '~/experimentation/utils'; import eventHub from '~/invite_members/event_hub'; import { s__, __ } from '~/locale'; import { ACTION_LABELS } from '../constants'; +import IncludedInTrialIndicator from './included_in_trial_indicator.vue'; export default { name: 'LearnGitlabSectionLink', @@ -15,12 +16,12 @@ export default { GlButton, GlPopover, GitlabExperiment, + IncludedInTrialIndicator, }, directives: { GlTooltip, }, i18n: { - trialOnly: s__('LearnGitlab|Trial only'), contactAdmin: s__('LearnGitlab|Contact your administrator to start a free Ultimate trial.'), viewAdminList: s__('LearnGitlab|View administrator list'), watchHow: __('Watch how'), @@ -41,12 +42,6 @@ export default { }; }, computed: { - linkTitle() { - return ACTION_LABELS[this.action].title; - }, - trialOnly() { - return ACTION_LABELS[this.action].trialRequired; - }, showInviteModalLink() { return ( this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding') @@ -55,49 +50,51 @@ export default { openInNewTab() { return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true; }, - linkToVideoTutorial() { - return ACTION_LABELS[this.action].videoTutorial; - }, }, methods: { openModal() { eventHub.$emit('openModal', { source: 'learn_gitlab' }); }, + actionLabelValue(value) { + return ACTION_LABELS[this.action][value]; + }, }, }; </script> <template> <div class="gl-mb-4"> - <div v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - {{ $options.i18n.trialOnly }} - </div> <div class="flex align-items-center"> <span v-if="value.completed" class="gl-text-green-500"> <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> - {{ linkTitle }} + {{ actionLabelValue('title') }} + <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> </span> - <gl-link - v-else-if="showInviteModalLink" - data-track-action="click_link" - :data-track-label="linkTitle" - data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" - data-testid="invite-for-help-continuous-onboarding-experiment-link" - @click="openModal" - > - {{ linkTitle }} - </gl-link> - <gl-link - v-else-if="value.enabled" - :target="openInNewTab ? '_blank' : '_self'" - :href="value.url" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - :data-track-label="linkTitle" - > - {{ linkTitle }} - </gl-link> + <div v-else-if="showInviteModalLink"> + <gl-link + data-track-action="click_link" + :data-track-label="actionLabelValue('trackLabel')" + data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" + data-testid="invite-for-help-continuous-onboarding-experiment-link" + @click="openModal" + >{{ actionLabelValue('title') }}</gl-link + > + + <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> + </div> + <div v-else-if="value.enabled"> + <gl-link + :target="openInNewTab ? '_blank' : '_self'" + :href="value.url" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + :data-track-label="actionLabelValue('trackLabel')" + >{{ actionLabelValue('title') }}</gl-link + > + + <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> + </div> <template v-else> - <div data-testid="disabled-learn-gitlab-link">{{ linkTitle }}</div> + <div data-testid="disabled-learn-gitlab-link">{{ actionLabelValue('title') }}</div> <gl-button :id="popoverId" category="tertiary" @@ -127,19 +124,19 @@ export default { <template #control></template> <template #candidate> <gl-button - v-if="linkToVideoTutorial" + v-if="actionLabelValue('videoTutorial')" v-gl-tooltip category="tertiary" icon="live-preview" :title="$options.i18n.watchHow" :aria-label="$options.i18n.watchHow" - :href="linkToVideoTutorial" + :href="actionLabelValue('videoTutorial')" target="_blank" class="ml-auto" size="small" data-testid="video-tutorial-link" data-track-action="click_video_link" - :data-track-label="linkTitle" + :data-track-label="actionLabelValue('trackLabel')" data-track-property="Growth::Conversion::Experiment::LearnGitLab" data-track-experiment="video_tutorials_continuous_onboarding" /> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 05bacd9b350..cb1a0302d91 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -2,9 +2,10 @@ import { s__ } from '~/locale'; export const ACTION_LABELS = { gitWrite: { - title: s__('LearnGitLab|Create or import a repository'), - actionLabel: s__('LearnGitLab|Create or import a repository'), + title: s__('LearnGitLab|Create a repository'), + actionLabel: s__('LearnGitLab|Create a repository'), description: s__('LearnGitLab|Create or import your first repository into your new project.'), + trackLabel: 'create_a_repository', section: 'workspace', position: 1, }, @@ -14,20 +15,23 @@ export const ACTION_LABELS = { description: s__( 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.', ), + trackLabel: 'invite_your_colleagues', section: 'workspace', position: 0, }, pipelineCreated: { - title: s__('LearnGitLab|Set up CI/CD'), - actionLabel: s__('LearnGitLab|Set-up CI/CD'), + title: s__("LearnGitLab|Set up your first project's CI/CD"), + actionLabel: s__('LearnGitLab|Set up CI/CD'), description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'), + trackLabel: 'set_up_your_first_project_s_ci_cd', section: 'workspace', position: 2, }, trialStarted: { - title: s__('LearnGitLab|Start a free Ultimate trial'), + title: s__('LearnGitLab|Start a free trial of GitLab Ultimate'), actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'), description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'), + trackLabel: 'start_a_free_trial_of_gitlab_ultimate', section: 'workspace', position: 3, openInNewTab: true, @@ -38,6 +42,7 @@ export const ACTION_LABELS = { description: s__( 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.', ), + trackLabel: 'add_code_owners', trialRequired: true, section: 'workspace', position: 4, @@ -45,9 +50,10 @@ export const ACTION_LABELS = { videoTutorial: 'https://vimeo.com/670896787', }, requiredMrApprovalsEnabled: { - title: s__('LearnGitLab|Add merge request approval'), + title: s__('LearnGitLab|Enable require merge approvals'), actionLabel: s__('LearnGitLab|Enable require merge approvals'), description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'), + trackLabel: 'enable_require_merge_approvals', trialRequired: true, section: 'workspace', position: 5, @@ -55,28 +61,52 @@ export const ACTION_LABELS = { videoTutorial: 'https://vimeo.com/670904904', }, mergeRequestCreated: { - title: s__('LearnGitLab|Submit a merge request'), + title: s__('LearnGitLab|Submit a merge request (MR)'), actionLabel: s__('LearnGitLab|Submit a merge request (MR)'), description: s__('LearnGitLab|Review and edit proposed changes to source code.'), + trackLabel: 'submit_a_merge_request_mr', section: 'plan', position: 1, }, - securityScanEnabled: { - title: s__('LearnGitLab|Run a Security scan using CI/CD'), - actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'), - description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), - section: 'deploy', - position: 1, - }, issueCreated: { title: s__('LearnGitLab|Create an issue'), actionLabel: s__('LearnGitLab|Create an issue'), description: s__( 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.', ), + trackLabel: 'create_an_issue', section: 'plan', position: 0, }, + securityScanEnabled: { + title: s__('LearnGitLab|Run a Security scan using CI/CD'), + actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'), + description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), + trackLabel: 'run_a_security_scan_using_ci_cd', + section: 'deploy', + position: 1, + }, + licenseScanningRun: { + title: s__('LearnGitLab|Scan dependencies for licenses'), + trackLabel: 'scan_dependencies_for_licenses', + trialRequired: true, + section: 'deploy', + position: 2, + }, + secureDependencyScanningRun: { + title: s__('LearnGitLab|Scan dependencies for vulnerabilities'), + trackLabel: 'scan_dependencies_for_vulnerabilities', + trialRequired: true, + section: 'deploy', + position: 3, + }, + secureDastRun: { + title: s__('LearnGitLab|Analyze your application for vulnerabilities with DAST'), + trackLabel: 'analyze_your_application_for_vulnerabilities_with_dast', + trialRequired: true, + section: 'deploy', + position: 4, + }, }; export const ACTION_SECTIONS = { diff --git a/app/assets/javascripts/pages/projects/logs/index.js b/app/assets/javascripts/pages/projects/logs/index.js deleted file mode 100644 index 0cff1ffc27e..00000000000 --- a/app/assets/javascripts/pages/projects/logs/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import logsBundle from '~/logs'; - -logsBundle(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 48e360ce762..2db804e1ad8 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -9,6 +9,7 @@ import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; +import { initMrExperienceSurvey } from '~/surveys/merge_request_experience'; import getStateQuery from './queries/get_state.query.graphql'; export default function initMergeRequestShow() { @@ -18,6 +19,7 @@ export default function initMergeRequestShow() { initSourcegraph(); initIssuableSidebar(); initAwardsApp(document.getElementById('js-vue-awards-block')); + initMrExperienceSurvey(); const el = document.querySelector('.js-mr-status-box'); const { iid, issuableType, projectPath } = el.dataset; diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index bf4fb5f3b7e..9a7fd74fd8c 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,4 +1,5 @@ -import initImportAProjectModal from '~/invite_members/init_import_a_project_modal'; +import initImportProjectMembersTrigger from '~/invite_members/init_import_project_members_trigger'; +import initImportProjectMembersModal from '~/invite_members/init_import_project_members_modal'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; @@ -9,11 +10,12 @@ import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import { projectMemberRequestFormatter } from '~/projects/members/utils'; -initImportAProjectModal(); +initImportProjectMembersModal(); initInviteMembersModal(); initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); +initImportProjectMembersTrigger(); const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { @@ -38,7 +40,7 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), { }, }, [MEMBER_TYPES.group]: { - tableFields: SHARED_FIELDS.concat('granted'), + tableFields: SHARED_FIELDS.concat(['source', 'granted']), tableAttrs: { table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' }, @@ -46,7 +48,7 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), { requestFormatter: groupLinkRequestFormatter, filteredSearchBar: { show: true, - tokens: [], + tokens: ['groups_with_inherited_permissions'], searchParam: 'search_groups', placeholder: s__('Members|Search groups'), recentSearchesStorageKey: 'project_group_links', diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 43ab829f5f9..6a9bd34db22 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -9,6 +9,7 @@ import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initSettingsPanels from '~/settings_panels'; import { initTokenAccess } from '~/token_access'; +import { initCiSecureFiles } from '~/ci_secure_files'; // Initialize expandable settings panels initSettingsPanels(); @@ -41,3 +42,4 @@ initSharedRunnersToggle(); initInstallRunner(); initRunnerAwsDeployments(); initTokenAccess(); +initCiSecureFiles(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 81b0dbec0bd..f2c30870a68 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -61,6 +61,10 @@ export default { GlFormCheckbox, GlToggle, ConfirmDanger, + otherProjectSettings: () => + import( + 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue' + ), }, mixins: [settingsMixin, glFeatureFlagsMixin()], @@ -182,6 +186,10 @@ export default { required: false, default: false, }, + membersPagePath: { + type: String, + required: true, + }, }, data() { const defaults = { @@ -521,12 +529,22 @@ export default { /> </div> </div> - <span v-if="!visibilityAllowed(visibilityLevel)" class="form-text text-muted">{{ - s__( - 'ProjectSettings|Visibility options for this fork are limited by the current visibility of the source project.', - ) - }}</span> - <span class="form-text text-muted">{{ visibilityLevelDescription }}</span> + <span + v-if="!visibilityAllowed(visibilityLevel)" + class="gl-display-block gl-text-gray-500 gl-mt-2" + >{{ + s__( + 'ProjectSettings|Visibility options for this fork are limited by the current visibility of the source project.', + ) + }}</span + > + <span class="gl-display-block gl-text-gray-500 gl-mt-2"> + <gl-sprintf :message="visibilityLevelDescription"> + <template #membersPageLink="{ content }"> + <gl-link class="gl-link" :href="membersPagePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> <div v-if="showAdditonalSettings" class="gl-mt-4"> <strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong> <label @@ -891,6 +909,7 @@ export default { <template #help>{{ $options.i18n.pucWarningHelpText }}</template> </gl-form-checkbox> </project-setting-row> + <other-project-settings /> <confirm-danger v-if="isVisibilityReduced" button-variant="confirm" diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index fb1acd5311c..cfca9d400e3 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -8,12 +8,10 @@ export const visibilityOptions = { export const visibilityLevelDescriptions = { [visibilityOptions.PRIVATE]: __( - 'The project is accessible only by members of the project. Access must be granted explicitly to each user.', - ), - [visibilityOptions.INTERNAL]: __('The project can be accessed by any user who is logged in.'), - [visibilityOptions.PUBLIC]: __( - 'The project can be accessed by anyone, regardless of authentication.', + `Only accessible by %{membersPageLinkStart}project members%{membersPageLinkEnd}. Membership must be explicitly granted to each user.`, ), + [visibilityOptions.INTERNAL]: __('Accessible by any user who is logged in.'), + [visibilityOptions.PUBLIC]: __('Accessible by anyone, regardless of authentication.'), }; export const featureAccessLevel = { diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js index 11c257611f0..6eef2352e2c 100644 --- a/app/assets/javascripts/pages/projects/work_items/index.js +++ b/app/assets/javascripts/pages/projects/work_items/index.js @@ -1,3 +1,5 @@ import { initWorkItemsRoot } from '~/work_items/index'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; initWorkItemsRoot(); +initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index 7c23f60954a..e92f386a29e 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -3,6 +3,7 @@ import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { handleLocationHash } from '~/lib/utils/common_utils'; import { renderGFM } from '../render_gfm_facade'; export default { @@ -43,6 +44,7 @@ export default { this.$nextTick() .then(() => { renderGFM(this.$refs.content); + handleLocationHash(); }) .catch(() => createFlash({ diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 024b3bc9595..3c22844434d 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -1,5 +1,16 @@ <script> -import { GlForm, GlIcon, GlLink, GlButton, GlSprintf, GlAlert } from '@gitlab/ui'; +import { + GlForm, + GlIcon, + GlLink, + GlButton, + GlSprintf, + GlAlert, + GlFormGroup, + GlFormInput, + GlFormSelect, +} from '@gitlab/ui'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { setUrlFragment } from '~/lib/utils/url_utility'; @@ -75,12 +86,16 @@ export default { }, components: { GlAlert, + GlIcon, GlForm, + GlFormGroup, + GlFormInput, + GlFormSelect, GlSprintf, - GlIcon, GlLink, GlButton, MarkdownField, + LocalStorageSync, ContentEditor: () => import( /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' @@ -186,6 +201,10 @@ export default { this.useContentEditor = !this.useContentEditor; }, + setUseContentEditor(value) { + this.useContentEditor = value; + }, + async handleFormSubmit(e) { e.preventDefault(); @@ -305,150 +324,151 @@ export default { name="wiki[last_commit_sha]" :value="pageInfo.lastCommitSha" /> - <div class="form-group row"> - <div class="col-sm-2 col-form-label"> - <label class="control-label-full-width" for="wiki_title">{{ - $options.i18n.title.label - }}</label> - </div> - <div class="col-sm-10"> - <input - id="wiki_title" - v-model="title" - name="wiki[title]" - type="text" - class="form-control" - data-qa-selector="wiki_title_textbox" - :required="true" - :autofocus="!pageInfo.persisted" - :placeholder="$options.i18n.title.placeholder" - @input="updateCommitMessage" - /> - <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600"> - <gl-icon class="gl-mr-n1" name="bulb" /> - {{ titleHelpText }} - <gl-link :href="helpPath" target="_blank"> - {{ $options.i18n.title.helpText.learnMore }} - </gl-link> - </span> - </div> - </div> - <div class="form-group row"> - <div class="col-sm-2 col-form-label"> - <label class="control-label-full-width" for="wiki_format">{{ - $options.i18n.format.label - }}</label> + + <div class="row"> + <div class="col-sm-9"> + <gl-form-group :label="$options.i18n.title.label" label-for="wiki_title"> + <template #description> + <gl-icon class="gl-mr-n1" name="bulb" /> + {{ titleHelpText }} + <gl-link :href="helpPath" target="_blank"> + {{ $options.i18n.title.helpText.learnMore }} + </gl-link> + </template> + + <gl-form-input + id="wiki_title" + v-model="title" + name="wiki[title]" + type="text" + class="form-control" + data-qa-selector="wiki_title_textbox" + :required="true" + :autofocus="!pageInfo.persisted" + :placeholder="$options.i18n.title.placeholder" + @input="updateCommitMessage" + /> + </gl-form-group> </div> - <div class="col-sm-10"> - <select - id="wiki_format" - v-model="format" - class="form-control" - name="wiki[format]" - :disabled="isContentEditorActive" - > - <option v-for="(key, label) of formatOptions" :key="key" :value="key"> - {{ label }} - </option> - </select> + + <div class="col-sm-3 row-sm-10"> + <gl-form-group :label="$options.i18n.format.label" label-for="wiki_format"> + <gl-form-select + id="wiki_format" + v-model="format" + name="wiki[format]" + :disabled="isContentEditorActive" + class="form-control" + :value="formatOptions.Markdown" + > + <option v-for="(key, label) of formatOptions" :key="key" :value="key"> + {{ label }} + </option> + </gl-form-select> + </gl-form-group> </div> </div> - <div class="form-group row" data-testid="wiki-form-content-fieldset"> - <div class="col-sm-2 col-form-label"> - <label class="control-label-full-width" for="wiki_content">{{ - $options.i18n.content.label - }}</label> - </div> - <div class="col-sm-10"> - <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3"> - <gl-button - data-testid="toggle-editing-mode-button" - data-qa-selector="editing_mode_button" - :data-qa-mode="toggleEditingModeButtonText" - variant="link" - @click="toggleEditingMode" - >{{ toggleEditingModeButtonText }}</gl-button - > - </div> - <markdown-field - v-if="!isContentEditorActive" - :markdown-preview-path="pageInfo.markdownPreviewPath" - :can-attach-file="true" - :enable-autocomplete="true" - :textarea-value="content" - :markdown-docs-path="pageInfo.markdownHelpPath" - :uploads-path="pageInfo.uploadsPath" - :enable-preview="isMarkdownFormat" - class="bordered-box" - > - <template #textarea> - <textarea - id="wiki_content" - ref="textarea" - v-model="content" - name="wiki[content]" - class="note-textarea js-gfm-input js-autosize markdown-area" - dir="auto" - data-supports-quick-actions="false" - data-qa-selector="wiki_content_textarea" - :autofocus="pageInfo.persisted" - :aria-label="$options.i18n.content.label" - :placeholder="$options.i18n.content.placeholder" - @input="handleContentChange" + + <div class="row" data-testid="wiki-form-content-fieldset"> + <div class="col-sm-12 row-sm-5"> + <gl-form-group> + <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3"> + <gl-button + data-testid="toggle-editing-mode-button" + data-qa-selector="editing_mode_button" + :data-qa-mode="toggleEditingModeButtonText" + variant="link" + @click="toggleEditingMode" + >{{ toggleEditingModeButtonText }}</gl-button > - </textarea> - </template> - </markdown-field> - <div v-if="isContentEditorActive"> - <content-editor - :render-markdown="renderMarkdown" - :uploads-path="pageInfo.uploadsPath" - @initialized="loadInitialContent" - @change="handleContentEditorChange" + </div> + <local-storage-sync + storage-key="gl-wiki-content-editor-enabled" + :value="useContentEditor" + @input="setUseContentEditor" /> - <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> - </div> + <markdown-field + v-if="!isContentEditorActive" + :markdown-preview-path="pageInfo.markdownPreviewPath" + :can-attach-file="true" + :enable-autocomplete="true" + :textarea-value="content" + :markdown-docs-path="pageInfo.markdownHelpPath" + :uploads-path="pageInfo.uploadsPath" + :enable-preview="isMarkdownFormat" + class="bordered-box" + > + <template #textarea> + <textarea + id="wiki_content" + ref="textarea" + v-model="content" + name="wiki[content]" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + data-qa-selector="wiki_content_textarea" + :autofocus="pageInfo.persisted" + :aria-label="$options.i18n.content.label" + :placeholder="$options.i18n.content.placeholder" + @input="handleContentChange" + > + </textarea> + </template> + </markdown-field> + <div v-if="isContentEditorActive"> + <content-editor + :render-markdown="renderMarkdown" + :uploads-path="pageInfo.uploadsPath" + @initialized="loadInitialContent" + @change="handleContentEditorChange" + /> + <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> + </div> - <div class="clearfix"></div> - <div class="error-alert"></div> + <div class="clearfix"></div> + <div class="error-alert"></div> - <div class="form-text gl-text-gray-600"> - <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText"> - <template #linkExample - ><code>{{ linkExample }}</code></template + <div class="form-text gl-text-gray-600"> + <gl-sprintf + v-if="displayWikiSpecificMarkdownHelp" + :message="$options.i18n.linksHelpText" > - <template - #link="// eslint-disable-next-line vue/no-template-shadow + <template #linkExample> + <code>{{ linkExample }}</code> + </template> + <template + #link="// eslint-disable-next-line vue/no-template-shadow { content }" - ><gl-link - :href="wikiSpecificMarkdownHelpPath" - target="_blank" - data-testid="wiki-markdown-help-link" - >{{ content }}</gl-link - ></template - > - </gl-sprintf> - </div> + ><gl-link + :href="wikiSpecificMarkdownHelpPath" + target="_blank" + data-testid="wiki-markdown-help-link" + >{{ content }}</gl-link + ></template + > + </gl-sprintf> + </div> + </gl-form-group> </div> </div> - <div class="form-group row"> - <div class="col-sm-2 col-form-label"> - <label class="control-label-full-width" for="wiki_message">{{ - $options.i18n.commitMessage.label - }}</label> - </div> - <div class="col-sm-10"> - <input - id="wiki_message" - v-model.trim="commitMessage" - name="wiki[message]" - type="text" - class="form-control" - data-qa-selector="wiki_message_textbox" - :placeholder="$options.i18n.commitMessage.label" - /> + + <div class="row"> + <div class="col-sm-12 row-sm-5"> + <gl-form-group :label="$options.i18n.commitMessage.label" label-for="wiki_message"> + <gl-form-input + id="wiki_message" + v-model.trim="commitMessage" + name="wiki[message]" + type="text" + class="form-control" + data-qa-selector="wiki_message_textbox" + :placeholder="$options.i18n.commitMessage.label" + /> + </gl-form-group> </div> </div> + <div class="form-actions"> <gl-button category="primary" 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 1da4a8fea73..a5fa85f1ed5 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -216,7 +216,7 @@ export default { v-if="currentRequest" :current-request="currentRequest" :requests="requests" - class="ml-auto" + class="gl-ml-auto" @change-current-request="changeCurrentRequest" /> <add-request v-on="$listeners" /> diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 23f1592cac1..610a570c4ce 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -19,7 +19,7 @@ export const i18n = { invalid: s__('Pipelines|This GitLab CI configuration is invalid.'), invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'), unavailableValidation: s__('Pipelines|Configuration validation currently not available.'), - valid: s__('Pipelines|This GitLab CI configuration is valid.'), + valid: s__('Pipelines|Pipeline syntax is correct.'), }; export default { diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 9a789ccab4d..0f19b9386e6 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -52,6 +52,11 @@ export default { required: false, default: false, }, + hideAlert: { + type: Boolean, + required: false, + default: false, + }, isValid: { type: Boolean, required: true, @@ -63,7 +68,8 @@ export default { }, lintHelpPagePath: { type: String, - required: true, + required: false, + default: '', }, warnings: { type: Array, @@ -96,6 +102,7 @@ export default { <template> <div> <gl-alert + v-if="!hideAlert" class="gl-mb-5" :variant="status.variant" :title="__('Status:')" diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 08d246a9a00..99ee244577e 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -1,6 +1,6 @@ <script> import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -16,6 +16,7 @@ import { TAB_QUERY_PARAM, TABS_INDEX, VALIDATE_TAB, + VALIDATE_TAB_BADGE_DISMISSED_KEY, VISUALIZE_TAB, } from '../constants'; import getAppStatus from '../graphql/queries/client/app_status.query.graphql'; @@ -29,6 +30,7 @@ import WalkthroughPopover from './popovers/walkthrough_popover.vue'; export default { i18n: { + new: __('NEW'), tabEdit: s__('Pipelines|Edit'), tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), @@ -87,6 +89,10 @@ export default { required: false, default: '', }, + currentTab: { + type: String, + required: true, + }, isNewCiConfigFile: { type: Boolean, required: true, @@ -104,6 +110,11 @@ export default { }, }, }, + data() { + return { + showValidateNewBadge: false, + }; + }, computed: { isMergedYamlAvailable() { return this.ciConfigData?.mergedYaml; @@ -123,6 +134,16 @@ export default { isLoading() { return this.appStatus === EDITOR_APP_STATUS_LOADING; }, + validateTabBadgeTitle() { + if (this.showValidateNewBadge) { + return this.$options.i18n.new; + } + + return ''; + }, + }, + mounted() { + this.showValidateNewBadge = !JSON.parse(localStorage.getItem(VALIDATE_TAB_BADGE_DISMISSED_KEY)); }, created() { const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM); @@ -134,6 +155,11 @@ export default { }, methods: { setCurrentTab(tabName) { + if (this.currentTab === VALIDATE_TAB) { + localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true'); + this.showValidateNewBadge = false; + } + this.$emit('set-current-tab', tabName); }, setDefaultTab(tabName) { @@ -189,11 +215,11 @@ export default { v-if="glFeatures.simulatePipeline" class="gl-mb-3" data-testid="validate-tab" + :badge-title="validateTabBadgeTitle" :title="$options.i18n.tabValidate" @click="setCurrentTab($options.tabConstants.VALIDATE_TAB)" > - <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> - <ci-validate v-else /> + <ci-validate :ci-file-content="ciFileContent" /> </editor-tab> <editor-tab v-else diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue new file mode 100644 index 00000000000..4730a521227 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue @@ -0,0 +1,72 @@ +<script> +import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { VALIDATE_TAB_FEEDBACK_URL } from '../../constants'; + +export const i18n = { + feedbackLink: __('Provide Feedback'), + popoverContent: s__( + 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies. %{linkStart}Learn more%{linkEnd}', + ), + title: s__('PipelineEditor|Validate pipeline under simulated conditions'), +}; + +export default { + name: 'ValidatePipelinePopover', + directives: { Outside }, + components: { + GlLink, + GlPopover, + GlSprintf, + }, + inject: ['simulatePipelineHelpPagePath'], + data() { + return { + showPopover: false, + }; + }, + methods: { + dismiss() { + this.showPopover = false; + }, + }, + i18n, + VALIDATE_TAB_FEEDBACK_URL, +}; +</script> + +<template> + <gl-popover + :show.sync="showPopover" + target="validate-pipeline-help" + triggers="hover focus" + placement="top" + > + <p class="gl-my-3 gl-font-weight-bold">{{ $options.i18n.title }}</p> + <p> + <gl-sprintf :message="$options.i18n.popoverContent"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link + class="gl-font-sm" + target="_blank" + :href="simulatePipelineHelpPagePath" + data-testid="help-link" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + <p class="gl-text-right gl-mb-3"> + <gl-link + class="gl-font-sm" + target="_blank" + :href="$options.VALIDATE_TAB_FEEDBACK_URL" + data-testid="feedback-link" + >{{ $options.i18n.feedbackLink }}</gl-link + > + </p> + </gl-popover> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue index 673599da085..65f399d1912 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlTab } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlTab } from '@gitlab/ui'; import { __, s__ } from '~/locale'; /** * Wrapper of <gl-tab> to optionally lazily render this tab's content @@ -48,6 +48,7 @@ export default { }, components: { GlAlert, + GlBadge, GlTab, // Use a small renderless component to know when the tab content mounts because: // - gl-tab always gets mounted, even if lazy is `true`. See: @@ -59,6 +60,16 @@ export default { }, inheritAttrs: false, props: { + badgeTitle: { + type: String, + required: false, + default: '', + }, + badgeVariant: { + type: String, + required: false, + default: 'info', + }, emptyMessage: { type: String, required: false, @@ -91,6 +102,10 @@ export default { required: false, default: false, }, + title: { + type: String, + required: true, + }, }, data() { return { @@ -98,7 +113,11 @@ export default { }; }, computed: { + hasBadgeTitle() { + return this.badgeTitle.length > 0; + }, slots() { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots return Object.keys(this.$slots); }, }, @@ -116,6 +135,12 @@ export default { </script> <template> <gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners"> + <template #title> + <span>{{ title }}</span> + <gl-badge v-if="hasBadgeTitle" class="gl-ml-2" size="sm" :variant="badgeVariant">{{ + badgeTitle + }}</gl-badge> + </template> <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert> <gl-alert v-else-if="isUnavailable" variant="danger" :dismissible="false"> {{ $options.i18n.unavailable }}</gl-alert diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue index 5f26318497b..47673119db9 100644 --- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue @@ -1,9 +1,35 @@ <script> -import { GlButton, GlDropdown, GlTooltipDirective, GlSprintf } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlDropdown, + GlIcon, + GlLoadingIcon, + GlLink, + GlTooltip, + GlTooltipDirective, + GlSprintf, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue'; +import CiLintResults from '../lint/ci_lint_results.vue'; +import getBlobContent from '../../graphql/queries/blob_content.query.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql'; +import lintCiMutation from '../../graphql/mutations/client/lint_ci.mutation.graphql'; export const i18n = { + alertDesc: s__( + 'PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}', + ), + cancelBtn: __('Cancel'), + contentChange: s__( + 'PipelineEditor|Configuration content has changed. Re-run validation for updated results.', + ), + cta: s__('PipelineEditor|Validate pipeline'), + ctaDisabledTooltip: s__('PipelineEditor|Waiting for CI content to load...'), + errorAlertTitle: s__('PipelineEditor|Pipeline simulation completed with errors'), help: __('Help'), + loading: s__('PipelineEditor|Validating pipeline... It can take up to a minute.'), pipelineSource: s__('PipelineEditor|Pipeline Source'), pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'), pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'), @@ -14,37 +40,179 @@ export const i18n = { simulationNote: s__( 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.', ), - cta: s__('PipelineEditor|Validate pipeline'), + successAlertTitle: s__('PipelineEditor|Simulation completed successfully'), }; +export const VALIDATE_TAB_INIT = 'VALIDATE_TAB_INIT'; +export const VALIDATE_TAB_RESULTS = 'VALIDATE_TAB_RESULTS'; +export const VALIDATE_TAB_LOADING = 'VALIDATE_TAB_LOADING'; +const BASE_CLASSES = [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-align-items-center', + 'gl-mt-11', +]; + export default { name: 'CiValidateTab', components: { + CiLintResults, + GlAlert, GlButton, GlDropdown, + GlIcon, + GlLoadingIcon, + GlLink, GlSprintf, + GlTooltip, + ValidatePipelinePopover, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['validateTabIllustrationPath'], + inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'], + props: { + ciFileContent: { + type: String, + required: true, + }, + }, + apollo: { + initialBlobContent: { + query: getBlobContent, + variables() { + return { + projectPath: this.projectFullPath, + path: this.ciConfigPath, + ref: this.currentBranch, + }; + }, + update(data) { + return data?.project?.repository?.blobs?.nodes[0]?.rawBlob; + }, + }, + currentBranch: { + query: getCurrentBranch, + update(data) { + return data.workBranches?.current?.name; + }, + }, + }, + data() { + return { + yaml: this.ciFileContent, + state: VALIDATE_TAB_INIT, + errors: [], + hasCiContentChanged: false, + isValid: false, + jobs: [], + warnings: [], + }; + }, + computed: { + isInitialCiContentLoading() { + return this.$apollo.queries.initialBlobContent.loading; + }, + isInitState() { + return this.state === VALIDATE_TAB_INIT; + }, + isSimulationLoading() { + return this.state === VALIDATE_TAB_LOADING; + }, + hasSimulationResults() { + return this.state === VALIDATE_TAB_RESULTS; + }, + resultStatus() { + return { + title: this.isValid ? i18n.successAlertTitle : i18n.errorAlertTitle, + variant: this.isValid ? 'success' : 'danger', + }; + }, + }, + watch: { + ciFileContent(value) { + this.yaml = value; + this.hasCiContentChanged = true; + }, + }, + methods: { + cancelSimulation() { + this.state = VALIDATE_TAB_INIT; + }, + async validateYaml() { + this.state = VALIDATE_TAB_LOADING; + + try { + const { + data: { + lintCI: { errors, jobs, valid, warnings }, + }, + } = await this.$apollo.mutate({ + mutation: lintCiMutation, + variables: { + dry_run: true, + content: this.yaml, + endpoint: this.ciLintPath, + }, + }); + + // only save the result if the user did not cancel the simulation + if (this.state === VALIDATE_TAB_LOADING) { + this.errors = errors; + this.jobs = jobs; + this.warnings = warnings; + this.isValid = valid; + this.state = VALIDATE_TAB_RESULTS; + this.hasCiContentChanged = false; + } + } catch (error) { + this.cancelSimulation(); + } + }, + }, i18n, + BASE_CLASSES, }; </script> <template> <div> - <div class="gl-mt-3"> - <label>{{ $options.i18n.pipelineSource }}</label> - <gl-dropdown - v-gl-tooltip.hover - :title="$options.i18n.pipelineSourceTooltip" - :text="$options.i18n.pipelineSourceDefault" - disabled - data-testid="pipeline-source" - /> + <div class="gl-display-flex gl-justify-content-space-between gl-mt-3"> + <div> + <label>{{ $options.i18n.pipelineSource }}</label> + <gl-dropdown + v-gl-tooltip.hover + class="gl-ml-3" + :title="$options.i18n.pipelineSourceTooltip" + :text="$options.i18n.pipelineSourceDefault" + disabled + data-testid="pipeline-source" + /> + <validate-pipeline-popover /> + <gl-icon + id="validate-pipeline-help" + name="question-o" + class="gl-ml-1 gl-fill-blue-500" + category="secondary" + variant="confirm" + :aria-label="$options.i18n.help" + /> + </div> + <div v-if="hasSimulationResults && hasCiContentChanged"> + <span class="gl-text-gray-400" data-testid="content-status"> + {{ $options.i18n.contentChange }} + </span> + <gl-button + variant="confirm" + class="gl-ml-2 gl-mb-2" + data-testid="resimulate-pipeline-button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> </div> - <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <div v-if="isInitState" :class="$options.BASE_CLASSES"> <img :src="validateTabIllustrationPath" /> <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1> <ul> @@ -57,9 +225,61 @@ export default { </gl-sprintf> </li> </ul> - <gl-button variant="confirm" class="gl-mt-3" data-qa-selector="simulate_pipeline"> - {{ $options.i18n.cta }} - </gl-button> + <div ref="simulatePipelineButton"> + <gl-button + ref="simulatePipelineButton" + variant="confirm" + class="gl-mt-3" + :disabled="isInitialCiContentLoading" + data-testid="simulate-pipeline-button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + <gl-tooltip + v-if="isInitialCiContentLoading" + :target="() => $refs.simulatePipelineButton" + :title="$options.i18n.ctaDisabledTooltip" + data-testid="cta-tooltip" + /> + </div> + <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES"> + <gl-loading-icon size="lg" class="gl-m-3" /> + <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1> + <div> + <gl-button class="gl-mt-3" data-testid="cancel-simulation" @click="cancelSimulation"> + {{ $options.i18n.cancelBtn }} + </gl-button> + <gl-button class="gl-mt-3" loading data-testid="simulate-pipeline-button"> + {{ $options.i18n.cta }} + </gl-button> + </div> + </div> + <div v-else-if="hasSimulationResults" class="gl-mt-5"> + <gl-alert + class="gl-mb-5" + :dismissible="false" + :title="resultStatus.title" + :variant="resultStatus.variant" + > + <gl-sprintf :message="$options.i18n.alertDesc"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link target="_blank" href="#">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <ci-lint-results + dry-run + hide-alert + :is-valid="isValid" + :jobs="jobs" + :errors="errors" + :warnings="warnings" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 8f688e6ba76..05db0afd15d 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -54,6 +54,7 @@ export const SOURCE_EDITOR_DEBOUNCE = 500; export const FILE_TREE_DISPLAY_KEY = 'pipeline_editor_file_tree_display'; export const FILE_TREE_POPOVER_DISMISSED_KEY = 'pipeline_editor_file_tree_popover_dismissed'; export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismissed'; +export const VALIDATE_TAB_BADGE_DISMISSED_KEY = 'pipeline_editor_validate_tab_badge_dismissed'; export const STARTER_TEMPLATE_NAME = 'Getting-Started'; @@ -81,6 +82,7 @@ export const pipelineEditorTrackingOptions = { export const TEMPLATE_REPOSITORY_URL = 'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates'; +export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/346687'; export const COMMIT_SHA_POLL_INTERVAL = 1000; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 4caa253b85e..4f5b69107bf 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -27,6 +27,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ciConfigPath, ciExamplesHelpPagePath, ciHelpPagePath, + ciLintPath, defaultBranch, emptyStateIllustrationPath, helpPaths, @@ -40,6 +41,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectPath, projectNamespace, runnerHelpPagePath, + simulatePipelineHelpPagePath, totalBranches, validateTabIllustrationPath, ymlHelpPagePath, @@ -115,6 +117,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ciConfigPath, ciExamplesHelpPagePath, ciHelpPagePath, + ciLintPath, configurationPaths, dataMethod: 'graphql', defaultBranch, @@ -130,6 +133,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectPath, projectNamespace, runnerHelpPagePath, + simulatePipelineHelpPagePath, totalBranches: parseInt(totalBranches, 10), validateTabIllustrationPath, ymlHelpPagePath, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index f26cdd8b017..2d5c01a58b7 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -150,6 +150,7 @@ export default { :ci-config-data="ciConfigData" :ci-file-content="ciFileContent" :commit-sha="commitSha" + :current-tab="currentTab" :is-new-ci-config-file="isNewCiConfigFile" :show-drawer="showDrawer" v-on="$listeners" diff --git a/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue index 5efae2471e5..c9649b2f2f7 100644 --- a/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue +++ b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue @@ -3,10 +3,12 @@ import { isNode, isDocument, isSeq, visit } from 'yaml'; import { capitalize } from 'lodash'; import TextWidget from '~/pipeline_wizard/components/widgets/text.vue'; import ListWidget from '~/pipeline_wizard/components/widgets/list.vue'; +import ChecklistWidget from '~/pipeline_wizard/components/widgets/checklist.vue'; const widgets = { TextWidget, ListWidget, + ChecklistWidget, }; function isNullOrUndefined(v) { @@ -30,8 +32,9 @@ export default { }, target: { type: String, - required: true, + required: false, validator: (v) => /^\$.*/g.test(v), + default: null, }, widget: { type: String, @@ -48,6 +51,7 @@ export default { }, computed: { path() { + if (!this.target) return null; let res; visit(this.template, (seqKey, node, path) => { if (node && node.value === this.target) { diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue index 220b068f747..c6ee883aec8 100644 --- a/app/assets/javascripts/pipeline_wizard/components/step.vue +++ b/app/assets/javascripts/pipeline_wizard/components/step.vue @@ -31,10 +31,7 @@ export default { inputs: { type: Array, required: true, - validator: (value) => - value.every((i) => { - return i?.target && i?.widget; - }), + validator: (value) => value.every((i) => i?.widget), }, template: { type: null, @@ -131,7 +128,7 @@ export default { :template="template" :validate="validate" :widget="input.widget" - class="gl-mb-2" + class="gl-mb-8" v-bind="input" @highlight="onHighlight" @update:valid="(validationState) => onInputValidationStateChange(i, validationState)" diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue new file mode 100644 index 00000000000..f2b159acfee --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue @@ -0,0 +1,80 @@ +<script> +import { GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +const isValidItemDefinition = (value) => { + // The Item definition should either be a simple string + // or an object with at least a "title" property + return typeof value === 'string' || Boolean(value.text); +}; + +export default { + name: 'ChecklistWidget', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormCheckboxGroup, + }, + props: { + title: { + type: String, + required: false, + default: null, + }, + items: { + type: Array, + required: false, + validator: (v) => v.every(isValidItemDefinition), + default: () => [], + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + checklistItems() { + return this.items.map((rawItem) => { + const id = rawItem.id || uniqueId(); + return { + id, + text: rawItem.text || rawItem, + help: rawItem.help || null, + }; + }); + }, + }, + created() { + if (this.items.length > 0) { + this.$emit('update:valid', false); + } + }, + methods: { + updateValidState(values) { + this.$emit( + 'update:valid', + this.checklistItems.every((item) => values.includes(item.id)), + ); + }, + }, +}; +</script> + +<template> + <gl-form-group #default="{ ariaDescribedby }" :label="title"> + <gl-form-checkbox-group :aria-describedby="ariaDescribedby" @input="updateValidState"> + <gl-form-checkbox + v-for="item in checklistItems" + :id="item.id" + :key="item.id" + :value="item.id" + > + {{ item.text }} + <template v-if="item.help" #help> + {{ item.help }} + </template> + </gl-form-checkbox> + </gl-form-checkbox-group> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue index f50cd175510..0fe87bcee7b 100644 --- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -128,6 +128,7 @@ export default { :filename="filename" :project-path="projectPath" @back="currentStepIndex--" + @done="$emit('done')" /> <wizard-step v-for="(step, i) in stepList" diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue index 7200b4e3782..939702fd1b5 100644 --- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue +++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue @@ -60,6 +60,7 @@ export default { :filename="filename" :project-path="projectPath" :steps="steps" + @done="$emit('done')" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep b/app/assets/javascripts/pipeline_wizard/templates/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep +++ /dev/null diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml new file mode 100644 index 00000000000..cd2242b1ba7 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml @@ -0,0 +1,53 @@ +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." +steps: + - inputs: + - label: Select your build image + description: A Docker image that we can use to build your image + placeholder: node:lts + widget: text + target: $BUILD_IMAGE + required: true + 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. + 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." + placeholder: npm ci + widget: list + target: $INSTALLATION_STEPS + template: + # Functions that should be executed before the build script is run + before_script: $INSTALLATION_STEPS + - inputs: + - label: Build Steps + description: "Enter the steps necessary to build a production version of + your application." + widget: list + target: $BUILD_STEPS + template: + + pages: + script: $BUILD_STEPS + + artifacts: + paths: + # The folder that contains the files to be exposed at the Page URL + - public + + rules: + # This ensures that only pushes to the default branch will trigger + # a pages deploy + - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index f822e2c0874..14872c34afb 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -281,6 +281,7 @@ export default { :type="graphViewType" :show-links="showLinks" :tip-previously-dismissed="hoverTipPreviouslyDismissed" + :is-pipeline-complete="pipeline.complete" @dismissHoverTip="handleTipDismissal" @updateViewType="updateViewType" @updateShowLinksState="updateShowLinksState" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index 1920fed84ec..a8c5d85f4ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,17 +1,33 @@ <script> -import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlButtonGroup, + GlLoadingIcon, + GlToggle, + GlModalDirective, +} from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import PerformanceInsightsModal from '../performance_insights_modal.vue'; +import { performanceModalId } from '../../constants'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; export default { name: 'GraphViewSelector', + performanceModalId, components: { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle, + PerformanceInsightsModal, }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [Tracking.mixin()], props: { showLinks: { type: Boolean, @@ -25,6 +41,10 @@ export default { type: String, required: true, }, + isPipelineComplete: { + type: Boolean, + required: true, + }, }, data() { return { @@ -39,6 +59,7 @@ export default { hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'), linksLabelText: s__('GraphViewType|Show dependencies'), viewLabelText: __('Group jobs by'), + performanceBtnText: __('Performance insights'), }, views: { [STAGE_VIEW]: { @@ -129,6 +150,9 @@ export default { this.$emit('updateShowLinksState', val); }); }, + trackInsightsClick() { + this.track('click_insights_button', { label: 'performance_insights' }); + }, }, }; </script> @@ -154,6 +178,15 @@ export default { </gl-button> </gl-button-group> + <gl-button + v-if="isPipelineComplete" + v-gl-modal="$options.performanceModalId" + data-testid="pipeline-insights-btn" + @click="trackInsightsClick" + > + {{ $options.i18n.performanceBtnText }} + </gl-button> + <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> <gl-toggle v-model="showLinksActive" @@ -169,5 +202,7 @@ export default { <gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip"> {{ $options.i18n.hoverTipText }} </gl-alert> + + <performance-insights-modal /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 37878f3fb6d..fabae62fc45 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -63,6 +63,18 @@ export default { default: '', }, }, + modal: { + id: DELETE_MODAL_ID, + actionPrimary: { + text: __('Delete pipeline'), + attributes: { + variant: 'danger', + }, + }, + actionCancel: { + text: __('Cancel'), + }, + }, apollo: { pipeline: { context() { @@ -275,7 +287,7 @@ export default { <gl-button v-if="pipeline.userPermissions.destroyPipeline" - v-gl-modal="$options.DELETE_MODAL_ID" + v-gl-modal="$options.modal.id" :loading="isDeleting" :disabled="isDeleting" class="gl-ml-3" @@ -289,11 +301,11 @@ export default { <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" /> <gl-modal - :modal-id="$options.DELETE_MODAL_ID" + :modal-id="$options.modal.id" :title="__('Delete pipeline')" - :ok-title="__('Delete pipeline')" - ok-variant="danger" - @ok="deletePipeline()" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + @primary="deletePipeline()" > <p> {{ deleteModalConfirmationText }} diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index 070c5ee59de..0c6b8b9ed2b 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -96,7 +96,7 @@ export default { <template #cell(actions)="{ item }"> <gl-button v-if="canRetryJob(item)" - icon="repeat" + icon="retry" :title="$options.retry" :aria-label="$options.retry" @click="retryJob(item.id)" diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue new file mode 100644 index 00000000000..ae6b9186930 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue @@ -0,0 +1,168 @@ +<script> +import { GlAlert, GlCard, GlLink, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { humanizeTimeInterval } from '~/lib/utils/datetime_utility'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import getPerformanceInsightsQuery from '../graphql/queries/get_performance_insights.query.graphql'; +import { performanceModalId } from '../constants'; +import { calculateJobStats, calculateSlowestFiveJobs } from '../utils'; + +export default { + name: 'PerformanceInsightsModal', + i18n: { + queuedCardHeader: s__('Pipeline|Longest queued job'), + queuedCardHelp: s__( + 'Pipeline|The longest queued job is the job that spent the longest time in the pending state, waiting to be picked up by a Runner', + ), + executedCardHeader: s__('Pipeline|Last executed job'), + executedCardHelp: s__( + 'Pipeline|The last executed job is the last job to start in the pipeline.', + ), + viewDependency: s__('Pipeline|View dependency'), + slowJobsTitle: s__('Pipeline|Five slowest jobs'), + feeback: __('Feedback issue'), + insightsLimit: s__('Pipeline|Only able to show first 100 results'), + }, + modal: { + title: s__('Pipeline|Performance insights'), + actionCancel: { + text: __('Close'), + attributes: { + variant: 'confirm', + }, + }, + }, + performanceModalId, + components: { + GlAlert, + GlCard, + GlLink, + GlModal, + GlLoadingIcon, + HelpPopover, + }, + inject: { + pipelineIid: { + default: '', + }, + pipelineProjectPath: { + default: '', + }, + }, + apollo: { + jobs: { + query: getPerformanceInsightsQuery, + variables() { + return { + fullPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + return data.project?.pipeline?.jobs; + }, + }, + }, + data() { + return { + jobs: null, + }; + }, + computed: { + longestQueuedJob() { + return calculateJobStats(this.jobs, 'queuedDuration'); + }, + lastExecutedJob() { + return calculateJobStats(this.jobs, 'startedAt'); + }, + slowestFiveJobs() { + return calculateSlowestFiveJobs(this.jobs); + }, + queuedDurationDisplay() { + return humanizeTimeInterval(this.longestQueuedJob.queuedDuration); + }, + showLimitMessage() { + return this.jobs.pageInfo.hasNextPage; + }, + }, +}; +</script> + +<template> + <gl-modal + :modal-id="$options.performanceModalId" + :title="$options.modal.title" + :action-cancel="$options.modal.actionCancel" + > + <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" /> + + <template v-else> + <gl-alert v-if="showLimitMessage" class="gl-mb-4" :dismissible="false"> + <p>{{ $options.i18n.insightsLimit }}</p> + <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5"> + {{ $options.i18n.feeback }} + </gl-link> + </gl-alert> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-7"> + <gl-card class="gl-w-half gl-mr-7 gl-text-center"> + <template #header> + <span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span> + <help-popover> + {{ $options.i18n.queuedCardHelp }} + </help-popover> + </template> + <div class="gl-display-flex gl-flex-direction-column"> + <span + class="gl-font-weight-bold gl-font-size-h2 gl-mb-2" + data-testid="insights-queued-card-data" + > + {{ queuedDurationDisplay }} + </span> + <gl-link + :href="longestQueuedJob.detailedStatus.detailsPath" + data-testid="insights-queued-card-link" + > + {{ longestQueuedJob.name }} + </gl-link> + </div> + </gl-card> + <gl-card class="gl-w-half gl-text-center" data-testid="insights-executed-card"> + <template #header> + <span class="gl-font-weight-bold">{{ $options.i18n.executedCardHeader }}</span> + <help-popover> + {{ $options.i18n.executedCardHelp }} + </help-popover> + </template> + <div class="gl-display-flex gl-flex-direction-column"> + <span + class="gl-font-weight-bold gl-font-size-h2 gl-mb-2" + data-testid="insights-executed-card-data" + > + {{ lastExecutedJob.name }} + </span> + <gl-link + :href="lastExecutedJob.detailedStatus.detailsPath" + data-testid="insights-executed-card-link" + > + {{ $options.i18n.viewDependency }} + </gl-link> + </div> + </gl-card> + </div> + + <div class="gl-mt-7"> + <span class="gl-font-weight-bold">{{ $options.i18n.slowJobsTitle }}</span> + <div + v-for="job in slowestFiveJobs" + :key="job.name" + class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-3 gl-p-4 gl-border-t-1 gl-border-t-solid gl-border-b-0 gl-border-b-solid gl-border-gray-100" + > + <span data-testid="insights-slow-job-stage">{{ job.stage.name }}</span> + <gl-link :href="job.detailedStatus.detailsPath" data-testid="insights-slow-job-link">{{ + job.name + }}</gl-link> + </div> + </div> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index fa0e153b2af..7a08dacb824 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -80,7 +80,7 @@ export default { class="js-pipelines-retry-button" data-qa-selector="pipeline_retry_button" data-testid="pipelines-retry-button" - icon="repeat" + icon="retry" variant="default" category="secondary" @click="handleRetryClick" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue index 76ee6ab613b..69509c9088b 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlFriendlyWrap, GlLink, GlModal } from '@gitlab/ui'; +import { GlBadge, GlFriendlyWrap, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { __, n__, s__, sprintf } from '~/locale'; import CodeBlock from '~/vue_shared/components/code_block.vue'; @@ -11,6 +12,10 @@ export default { GlFriendlyWrap, GlLink, GlModal, + ModalCopyButton, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { modalId: { @@ -57,6 +62,7 @@ export default { history: __('History'), trace: __('System output'), attachment: s__('TestReports|Attachment'), + copyTestName: s__('TestReports|Copy test name to rerun locally'), }, modalCloseButton: { text: __('Close'), @@ -85,6 +91,13 @@ export default { {{ testCase.file }} </gl-link> <span v-else>{{ testCase.file }}</span> + <modal-copy-button + :title="$options.text.copyTestName" + :text="testCase.file" + :modal-id="modalId" + category="tertiary" + class="gl-ml-1" + /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 58d072b0005..3fb46a4f128 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import createTestReportsStore from '../../stores/test_reports'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; @@ -15,9 +16,10 @@ export default { TestSummary, TestSummaryTable, }, + inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'], computed: { - ...mapState(['isLoading', 'selectedSuiteIndex', 'testReports']), - ...mapGetters(['getSelectedSuite']), + ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']), + ...mapGetters('testReports', ['getSelectedSuite']), showSuite() { return this.selectedSuiteIndex !== null; }, @@ -27,10 +29,19 @@ export default { }, }, created() { + this.$store.registerModule( + 'testReports', + createTestReportsStore({ + blobPath: this.blobPath, + summaryEndpoint: this.summaryEndpoint, + suiteEndpoint: this.suiteEndpoint, + }), + ); + this.fetchSummary(); }, methods: { - ...mapActions([ + ...mapActions('testReports', [ 'fetchTestSuite', 'fetchSummary', 'setSelectedSuiteIndex', diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 1e481d37017..1f438c63fee 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -51,14 +51,18 @@ export default { }, }, computed: { - ...mapState(['pageInfo']), - ...mapGetters(['getSuiteTests', 'getSuiteTestCount', 'getSuiteArtifactsExpired']), + ...mapState('testReports', ['pageInfo']), + ...mapGetters('testReports', [ + 'getSuiteTests', + 'getSuiteTestCount', + 'getSuiteArtifactsExpired', + ]), hasSuites() { return this.getSuiteTests.length > 0; }, }, methods: { - ...mapActions(['setPage']), + ...mapActions('testReports', ['setPage']), }, wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'], i18n, diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 2b44ce57faa..8389c2a5104 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -19,7 +19,7 @@ export default { }, }, computed: { - ...mapGetters(['getTestSuites']), + ...mapGetters('testReports', ['getTestSuites']), hasSuites() { return this.getTestSuites.length > 0; }, diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 0510992e962..2e825016c91 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -109,3 +109,5 @@ export const DEFAULT_FIELDS = [ columnClass: 'gl-w-20p', }, ]; + +export const performanceModalId = 'performanceInsightsModal'; diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql new file mode 100644 index 00000000000..25e990c8934 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql @@ -0,0 +1,28 @@ +query getPerformanceInsightsData($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs { + pageInfo { + hasNextPage + } + nodes { + id + duration + detailedStatus { + id + detailsPath + } + name + stage { + id + name + } + startedAt + queuedDuration + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index e7c00d89a10..c0e769e2485 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue'; import { removeParams, updateHistory } from '~/lib/utils/url_utility'; @@ -7,6 +8,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import { getPipelineDefaultTab, reportToSentry } from './utils'; Vue.use(VueApollo); +Vue.use(Vuex); export const createAppOptions = (selector, apolloProvider) => { const el = document.querySelector(selector); @@ -37,6 +39,7 @@ export const createAppOptions = (selector, apolloProvider) => { PipelineTabs, }, apolloProvider, + store: new Vuex.Store(), provide: { canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports), codequalityReportDownloadPath, diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js index 27ab2418440..fe4ca8e9529 100644 --- a/app/assets/javascripts/pipelines/pipeline_test_details.js +++ b/app/assets/javascripts/pipelines/pipeline_test_details.js @@ -1,9 +1,10 @@ import Vue from 'vue'; +import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '~/vue_shared/translate'; import TestReports from './components/test_reports/test_reports.vue'; -import createTestReportsStore from './stores/test_reports'; +Vue.use(Vuex); Vue.use(Translate); export const createTestDetails = (selector) => { @@ -16,11 +17,6 @@ export const createTestDetails = (selector) => { suiteEndpoint, artifactsExpiredImagePath, } = el?.dataset || {}; - const testReportsStore = createTestReportsStore({ - blobPath, - summaryEndpoint, - suiteEndpoint, - }); // eslint-disable-next-line no-new new Vue({ @@ -32,8 +28,11 @@ export const createTestDetails = (selector) => { emptyStateImagePath, artifactsExpiredImagePath, hasTestReport: parseBoolean(hasTestReport), + blobPath, + summaryEndpoint, + suiteEndpoint, }, - store: testReportsStore, + store: new Vuex.Store(), render(createElement) { return createElement('test-reports'); }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/constants.js b/app/assets/javascripts/pipelines/stores/test_reports/constants.js index 8eebfb6b208..83d14e1a109 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/constants.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/constants.js @@ -1 +1 @@ -export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts have expired'; +export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts not found'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js index 64d4b8bafb1..f45a53f47b7 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/index.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -1,16 +1,14 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; import state from './state'; -Vue.use(Vuex); - -export default (initialState) => - new Vuex.Store({ +export default (initialState) => { + return { + namespaced: true, actions, getters, mutations, state: state(initialState), - }); + }; +}; diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 588d15495ab..83e00b80426 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -153,3 +153,24 @@ export const getPipelineDefaultTab = (url) => { return null; }; + +export const calculateJobStats = (jobs, sortField) => { + const jobNodes = [...jobs.nodes]; + + const sorted = jobNodes.sort((a, b) => { + return b[sortField] - a[sortField]; + }); + + return sorted[0]; +}; + +export const calculateSlowestFiveJobs = (jobs) => { + const jobNodes = [...jobs.nodes]; + const limit = 5; + + return jobNodes + .sort((a, b) => { + return b.duration - a.duration; + }) + .slice(0, limit); +}; diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 00fe0bcf89b..f208280af27 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -30,7 +30,7 @@ export default () => { deleteAccountModal, }, mounted() { - deleteAccountButton.classList.remove('disabled'); + deleteAccountButton.disabled = false; deleteAccountButton.addEventListener('click', () => { this.$root.$emit(BV_SHOW_MODAL, 'delete-account-modal', '#delete-account-button'); }); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 25fefff219c..064bcf8e4c4 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import { Rails } from '~/lib/utils/rails_ujs'; @@ -10,7 +10,7 @@ import TimezoneDropdown, { export default class Profile { constructor({ form } = {}) { this.onSubmitForm = this.onSubmitForm.bind(this); - this.form = form || $('.edit-user'); + this.form = form || $('.js-edit-user'); this.setRepoRadio(); this.bindEvents(); this.initAvatarGlCrop(); @@ -84,9 +84,9 @@ export default class Profile { this.updateHeaderAvatar(); } - createFlash({ + createAlert({ message: data.message, - type: data.status === 'error' ? FLASH_TYPES.ALERT : FLASH_TYPES.NOTICE, + variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO, }); }) .then(() => { @@ -95,8 +95,9 @@ export default class Profile { self.form.find(':input[disabled]').enable(); }) .catch((error) => - createFlash({ + createAlert({ message: error.message, + variant: VARIANT_DANGER, }), ); } diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 9bd78b7c89e..1cdf26b76b7 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -43,7 +43,11 @@ export default { }, apollo: { pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, query: getLinkedPipelinesQuery, + pollInterval: COMMIT_BOX_POLL_INTERVAL, variables() { return { fullPath: this.fullPath, @@ -116,6 +120,7 @@ export default { }, mounted() { toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages); + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); }, }; </script> diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js index 833e946af5c..bc2c16b9e83 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_details_button.js +++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js @@ -1,9 +1,7 @@ -import $ from 'jquery'; - export const initDetailsButton = () => { - $('body').on('click', '.js-details-expand', function expand(e) { + document.querySelector('.commit-info').addEventListener('click', function expand(e) { e.preventDefault(); - $(this).next('.js-details-content').removeClass('hide'); - $(this).hide(); + this.querySelector('.js-details-content').classList.remove('hide'); + this.querySelector('.js-details-expand').classList.add('gl-display-none'); }); }; diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index 884ef732144..f85be67d4b3 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -110,7 +110,7 @@ export default { :text="dropdownText" :disabled="hasSearchParam" toggle-class="gl-py-3 gl-border-0" - class="w-100 mt-2 mt-sm-0" + class="w-100 gl-mt-3 mt-sm-0" > <gl-dropdown-section-header> {{ __('Search by author') }} diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index 3945bed9649..bda58091b97 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -121,27 +121,21 @@ export default { @selectRevision="onSelectRevision" /> </div> - <div class="gl-mt-6"> + <div class="gl-display-flex gl-mt-6 gl-gap-3"> <gl-button category="primary" variant="confirm" @click="onSubmit"> {{ s__('CompareRevisions|Compare') }} </gl-button> - <gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision"> + <gl-button data-testid="swapRevisionsButton" @click="onSwapRevision"> {{ s__('CompareRevisions|Swap revisions') }} </gl-button> <gl-button v-if="projectMergeRequestPath" :href="projectMergeRequestPath" data-testid="projectMrButton" - class="btn btn-default gl-button" > {{ s__('CompareRevisions|View open merge request') }} </gl-button> - <gl-button - v-else-if="createMrPath" - :href="createMrPath" - data-testid="createMrButton" - class="btn btn-default gl-button" - > + <gl-button v-else-if="createMrPath" :href="createMrPath" data-testid="createMrButton"> {{ s__('CompareRevisions|Create merge request') }} </gl-button> </div> diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 476d6466cbb..59ca393fe92 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -16,7 +16,7 @@ const PANELS = [ selector: '#blank-project-pane', title: s__('ProjectsNew|Create blank project'), description: s__( - 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.', + 'ProjectsNew|Create a blank project to store your files, plan your work, and collaborate on code, among other things.', ), illustration: blankProjectIllustration, }, 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 506f1ec5ffd..eccfb3d844c 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 @@ -7,6 +7,7 @@ import { GlDropdownText, GlDropdownSectionHeader, GlSearchBoxByType, + GlTruncate, } from '@gitlab/ui'; import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; @@ -26,6 +27,7 @@ export default { GlDropdownText, GlDropdownSectionHeader, GlSearchBoxByType, + GlTruncate, }, mixins: [Tracking.mixin()], apollo: { @@ -55,10 +57,7 @@ export default { id: this.namespaceId, fullPath: this.namespaceFullPath, } - : { - id: undefined, - fullPath: s__('ProjectsNew|Pick a group or namespace'), - }, + : this.$options.emptyNameSpace, shouldSkipQuery: true, userNamespaceId: this.userNamespaceId, }; @@ -118,12 +117,18 @@ export default { this.setNamespace({ id, fullPath }); }, setNamespace({ id, fullPath }) { - this.selectedNamespace = { - id: getIdFromGraphQLId(id), - fullPath, - }; + this.selectedNamespace = id + ? { + id: getIdFromGraphQLId(id), + fullPath, + } + : this.$options.emptyNameSpace; }, }, + emptyNameSpace: { + id: undefined, + fullPath: s__('ProjectsNew|Pick a group or namespace'), + }, }; </script> @@ -137,13 +142,20 @@ export default { > <gl-dropdown - :text="selectedNamespace.fullPath" class="js-group-namespace-dropdown gl-flex-grow-1" :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`" data-qa-selector="select_namespace_dropdown" @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" @shown="handleDropdownShown" > + <template #button-text> + <gl-truncate + v-if="selectedNamespace.fullPath" + :text="selectedNamespace.fullPath" + position="start" + with-tooltip + /> + </template> <gl-search-box-by-type ref="search" v-model.trim="search" diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 35e7554aee2..186fcf70838 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -14,12 +14,15 @@ export default { LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'), TimeToRestoreServiceCharts: () => import('ee_component/dora/components/time_to_restore_service_charts.vue'), + ChangeFailureRateCharts: () => + import('ee_component/dora/components/change_failure_rate_charts.vue'), ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'), }, piplelinesTabEvent: 'p_analytics_ci_cd_pipelines', deploymentFrequencyTabEvent: 'p_analytics_ci_cd_deployment_frequency', leadTimeTabEvent: 'p_analytics_ci_cd_lead_time', timeToRestoreServiceTabEvent: 'p_analytics_ci_cd_time_to_restore_service', + changeFailureRateTabEvent: 'p_analytics_ci_cd_change_failure_rate', inject: { shouldRenderDoraCharts: { type: Boolean, @@ -40,7 +43,12 @@ export default { const chartsToShow = ['pipelines']; if (this.shouldRenderDoraCharts) { - chartsToShow.push('deployment-frequency', 'lead-time', 'time-to-restore-service'); + chartsToShow.push( + 'deployment-frequency', + 'lead-time', + 'time-to-restore-service', + 'change-failure-rate', + ); } if (this.shouldRenderQualitySummary) { @@ -105,6 +113,13 @@ export default { > <time-to-restore-service-charts /> </gl-tab> + <gl-tab + :title="s__('DORA4Metrics|Change failure rate')" + data-testid="change-failure-rate-tab" + @click="trackTabClick($options.changeFailureRateTabEvent)" + > + <change-failure-rate-charts /> + </gl-tab> </template> <gl-tab v-if="shouldRenderQualitySummary" :title="s__('QualitySummary|Project quality')"> <project-quality-summary /> diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 186946a83ad..fe84660422b 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -342,6 +342,7 @@ const bindEvents = () => { export default { bindEvents, + validateGroupNamespaceDropdown, deriveProjectPathFromUrl, onProjectNameChange, onProjectPathChange, diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js index d299e106b14..b8ac17a01f2 100644 --- a/app/assets/javascripts/projects/project_visibility.js +++ b/app/assets/javascripts/projects/project_visibility.js @@ -10,7 +10,7 @@ const visibilityLevel = { }; function setVisibilityOptions({ name, visibility, showPath, editPath }) { - document.querySelectorAll('.visibility-level-setting .form-check').forEach((option) => { + document.querySelectorAll('.visibility-level-setting .gl-form-radio').forEach((option) => { // Don't change anything if the option is restricted by admin if (option.classList.contains('restricted')) { return; @@ -24,7 +24,7 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) { optionInput.disabled = true; const reason = option.querySelector('.option-disabled-reason'); if (reason) { - const optionTitle = option.querySelector('.option-title'); + const optionTitle = option.querySelector('.js-visibility-level-radio span'); const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : ''; reason.innerHTML = sprintf( __( diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 79dfa166b1a..060178a3cfb 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -441,11 +441,13 @@ export default class AccessDropdown { const { id, fingerprint, + fingerprint_sha256: fingerprintSha256, title, owner: { avatar_url, name, username }, } = response; - const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`; + const availableFingerprint = fingerprintSha256 || fingerprint; + const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`; return { id, diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 9823b0229a0..fcf81c9d1f7 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -203,11 +203,13 @@ export default { const { id, fingerprint, + fingerprint_sha256: fingerprintSha256, title, owner: { avatar_url, name, username }, } = response; - const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`; + const availableFingerprint = fingerprintSha256 || fingerprint; + const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`; return { id, @@ -351,7 +353,6 @@ export default { <gl-dropdown-item v-for="group in groups" :key="`${group.id}${group.name}`" - fingerprint data-testid="group-dropdown-item" :avatar-url="group.avatar_url" is-check-item @@ -388,7 +389,7 @@ export default { }}</gl-dropdown-section-header> <gl-dropdown-item v-for="key in deployKeys" - :key="`${key.id}${key.fingerprint}`" + :key="`${key.id}-{key.title}`" data-testid="deploy_key-dropdown-item" is-check-item :is-checked="isSelected(key)" diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js index 578e22ca25d..5bbace11b15 100644 --- a/app/assets/javascripts/projects/star.js +++ b/app/assets/javascripts/projects/star.js @@ -1,31 +1,33 @@ -import $ from 'jquery'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; export default class Star { - constructor(container = '.project-home-panel') { - $(`${container} .toggle-star`).on('click', function toggleStarClickCallback() { - const $this = $(this); - const $starSpan = $this.find('span'); - const $starIcon = $this.find('svg'); - const iconClasses = $starIcon.attr('class').split(' '); + constructor(containerSelector = '.project-home-panel') { + const container = document.querySelector(containerSelector); + const starToggle = container.querySelector('.toggle-star'); + starToggle.addEventListener('click', function toggleStarClickCallback() { + const starSpan = starToggle.querySelector('span'); + const starIcon = starToggle.querySelector('svg'); + const iconClasses = Array.from(starIcon.classList.values()); axios - .post($this.data('endpoint')) + .post(starToggle.dataset.endpoint) .then(({ data }) => { - const isStarred = $starSpan.hasClass('starred'); - $this.parent().find('.count').text(data.star_count); + const isStarred = starSpan.classList.contains('starred'); + starToggle.parentNode.querySelector('.count').textContent = data.star_count; if (isStarred) { - $starSpan.removeClass('starred').text(s__('StarProject|Star')); - $starIcon.remove(); - $this.prepend(spriteIcon('star-o', iconClasses)); + starSpan.classList.remove('starred'); + starSpan.textContent = s__('StarProject|Star'); + starIcon.remove(); + starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses)); } else { - $starSpan.addClass('starred').text(__('Unstar')); - $starIcon.remove(); - $this.prepend(spriteIcon('star', iconClasses)); + starSpan.classList.add('starred'); + starSpan.textContent = __('Unstar'); + starIcon.remove(); + starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses)); } }) .catch(() => diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 42de419aec4..d765033d00b 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -173,7 +173,7 @@ export default { :label="issuableCategoryHeaderText" label-for="linked-issue-type-radio" label-class="label-bold" - class="mb-2" + class="gl-mb-3" > <gl-form-radio-group id="linked-issue-type-radio" @@ -216,12 +216,12 @@ export default { :disabled="isSubmitButtonDisabled" :loading="isSubmitting" type="submit" - class="float-left" + class="gl-float-left" data-qa-selector="add_issue_button" > {{ __('Add') }} </gl-button> - <gl-button class="float-right" @click="onFormCancel"> + <gl-button class="gl-float-right" @click="onFormCancel"> {{ __('Cancel') }} </gl-button> </div> diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 327da1fb2a1..022c3224bb4 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,5 +1,13 @@ <script> -import { GlButton, GlFormCheckbox, GlFormInput, GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlDatepicker, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlLink, + GlSprintf, +} from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -7,6 +15,7 @@ import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import AssetLinksForm from './asset_links_form.vue'; +import ConfirmDeleteModal from './confirm_delete_modal.vue'; import TagField from './tag_field.vue'; export default { @@ -16,8 +25,10 @@ export default { GlFormInput, GlFormGroup, GlButton, + GlDatepicker, GlLink, GlSprintf, + ConfirmDeleteModal, MarkdownField, AssetLinksForm, MilestoneCombobox, @@ -25,12 +36,14 @@ export default { }, computed: { ...mapState('editNew', [ + 'isExistingRelease', 'isFetchingRelease', 'isUpdatingRelease', 'fetchError', 'markdownDocsPath', 'markdownPreviewPath', 'editReleaseDocsPath', + 'upcomingReleaseDocsPath', 'releasesPagePath', 'release', 'newMilestonePath', @@ -40,7 +53,7 @@ export default { 'groupMilestonesAvailable', 'tagNotes', ]), - ...mapGetters('editNew', ['isValid', 'isExistingRelease', 'formattedReleaseNotes']), + ...mapGetters('editNew', ['isValid', 'formattedReleaseNotes']), showForm() { return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, @@ -76,6 +89,14 @@ export default { this.updateIncludeTagNotes(includeTagNotes); }, }, + releasedAt: { + get() { + return this.release.releasedAt; + }, + set(date) { + this.updateReleasedAt(date); + }, + }, cancelPath() { const backUrl = getParameterByName(BACK_URL_PARAM); @@ -114,10 +135,12 @@ export default { ...mapActions('editNew', [ 'initializeRelease', 'saveRelease', + 'deleteRelease', 'updateReleaseTitle', 'updateReleaseNotes', 'updateReleaseMilestones', 'updateIncludeTagNotes', + 'updateReleasedAt', ]), submitForm() { if (!this.isFormSubmissionDisabled) { @@ -166,6 +189,22 @@ export default { /> </div> </gl-form-group> + <gl-form-group :label="__('Release date')" label-for="release-released-at"> + <template #label-description> + <gl-sprintf + :message=" + __( + 'The date when the release is ready. A release with a date in the future is labeled as an %{linkStart}Upcoming Release%{linkEnd}.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="upcomingReleaseDocsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </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> <div class="bordered-box pr-3 pl-3"> @@ -224,6 +263,7 @@ export default { > {{ saveButtonLabel }} </gl-button> + <confirm-delete-modal v-if="isExistingRelease" @delete="deleteRelease" /> <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button> </div> </form> diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index a949a9d1318..d63a83d1a08 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -4,9 +4,9 @@ import createFlash from '~/flash'; import { historyPushState } from '~/lib/utils/common_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; -import { convertAllReleasesGraphQLResponse } from '~/releases/util'; +import { convertAllReleasesGraphQLResponse, deleteReleaseSessionKey } from '~/releases/util'; import allReleasesQuery from '../graphql/queries/all_releases.query.graphql'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; @@ -172,6 +172,20 @@ export default { return this.isFullRequestLoaded && !this.shouldRenderEmptyState; }, }, + mounted() { + const key = deleteReleaseSessionKey(this.projectPath); + const deletedRelease = window.sessionStorage.getItem(key); + + if (deletedRelease) { + this.$toast.show( + sprintf(__('Release %{deletedRelease} has been successfully deleted.'), { + deletedRelease, + }), + ); + } + + window.sessionStorage.removeItem(key); + }, created() { this.updateQueryParamsFromUrl(); diff --git a/app/assets/javascripts/releases/components/confirm_delete_modal.vue b/app/assets/javascripts/releases/components/confirm_delete_modal.vue new file mode 100644 index 00000000000..aa948fbbaf6 --- /dev/null +++ b/app/assets/javascripts/releases/components/confirm_delete_modal.vue @@ -0,0 +1,77 @@ +<script> +import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { __, s__, sprintf } from '~/locale'; + +export default { + components: { + GlModal, + GlSprintf, + GlLink, + GlButton, + }, + data() { + return { + visible: false, + }; + }, + computed: { + ...mapState('editNew', ['release', 'deleteReleaseDocsPath']), + title() { + return sprintf(__('Delete release %{release}?'), { release: this.release.name }); + }, + }, + modalOptions: { + modalId: 'confirm-delete-release', + static: true, + actionPrimary: { + attributes: { variant: 'danger' }, + text: __('Delete release'), + }, + actionSecondary: { + text: __('Cancel'), + attributes: { variant: 'default' }, + }, + }, + i18n: { + buttonLabel: __('Delete'), + line1: s__( + 'DeleteRelease|You are about to delete release %{release} and its assets. The Git tag %{tag} will not be deleted.', + ), + line2: s__( + 'DeleteRelease|For more details, see %{docsPathStart}Deleting a release%{docsPathEnd}.', + ), + line3: s__('DeleteRelease|Are you sure you want to delete this release?'), + }, +}; +</script> +<template> + <div> + <gl-button class="gl-mr-3" variant="danger" @click="visible = true"> + {{ $options.i18n.buttonLabel }} + </gl-button> + <gl-modal + v-bind="$options.modalOptions" + v-model="visible" + :title="title" + @primary="$emit('delete')" + > + <p> + <gl-sprintf :message="$options.i18n.line1"> + <template #release>{{ release.name }}</template> + <template #tag> + <gl-link :href="release.tagPath">{{ release.tagName }}</gl-link> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.i18n.line2"> + <template #docsPath="{ content }"> + <gl-link :href="deleteReleaseDocsPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p>{{ $options.i18n.line3 }}</p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index 91d6d0911a4..3881c83b5c2 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -42,9 +42,9 @@ export default { default: null, }, releasedAt: { - type: String, + type: Date, required: false, - default: '', + default: null, }, }, computed: { @@ -66,8 +66,11 @@ export default { </script> <template> <div> - <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info"> - <gl-icon ref="commitIcon" name="commit" class="mr-1" /> + <div + v-if="commit" + class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-commit-info" + > + <gl-icon ref="commitIcon" name="commit" class="gl-mr-2" /> <div v-gl-tooltip.bottom :title="commit.title"> <gl-link v-if="commitPath" :href="commitPath"> {{ commit.shortId }} @@ -76,8 +79,11 @@ export default { </div> </div> - <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info"> - <gl-icon name="tag" class="mr-1" /> + <div + v-if="tagName" + class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-tag-info" + > + <gl-icon name="tag" class="gl-mr-2" /> <div v-gl-tooltip.bottom :title="__('Tag')"> <gl-link v-if="tagPath" :href="tagPath"> {{ tagName }} @@ -88,23 +94,23 @@ export default { <div v-if="releasedAt || author" - class="float-left d-flex align-items-center js-author-date-info" + class="gl-float-left gl-display-flex gl-align-items-center js-author-date-info" > - <span class="text-secondary">{{ createdTime }} </span> + <span class="gl-text-secondary">{{ createdTime }} </span> <template v-if="releasedAt"> <span v-gl-tooltip.bottom :title="tooltipTitle(releasedAt)" - class="text-secondary flex-shrink-0" + class="gl-text-secondary gl-flex-shrink-0" > {{ releasedAtTimeAgo }} </span> </template> - <div v-if="author" class="d-flex"> - <span class="text-secondary">{{ __('by') }} </span> + <div v-if="author" class="gl-display-flex"> + <span class="gl-text-secondary">{{ __('by') }} </span> <user-avatar-link - class="gl-my-n1" + class="gl-my-n1 gl-display-flex" :link-href="author.webUrl" :img-src="author.avatarUrl" :img-alt="userImageAltDescription" diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue index f4c0fd5e9ce..b4fea9bee35 100644 --- a/app/assets/javascripts/releases/components/tag_field.vue +++ b/app/assets/javascripts/releases/components/tag_field.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapState } from 'vuex'; import TagFieldExisting from './tag_field_existing.vue'; import TagFieldNew from './tag_field_new.vue'; @@ -9,7 +9,7 @@ export default { TagFieldNew, }, computed: { - ...mapGetters('editNew', ['isExistingRelease']), + ...mapState('editNew', ['isExistingRelease']), }, }; </script> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index d3b6d07590f..08b727dcca0 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -22,12 +22,10 @@ export default { // the input field. This is used to avoid showing validation // errors immediately when the page loads. isInputDirty: false, - - showCreateFrom: true, }; }, computed: { - ...mapState('editNew', ['projectId', 'release', 'createFrom']), + ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']), ...mapGetters('editNew', ['validationErrors']), tagName: { get() { @@ -40,7 +38,7 @@ export default { // When this is called, the selection originated from the // dropdown list of existing tag names, so we know the tag // already exists and don't need to show the "create from" input - this.showCreateFrom = false; + this.updateShowCreateFrom(false); }, }, createFromModel: { @@ -70,7 +68,12 @@ export default { }, }, methods: { - ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom', 'fetchTagNotes']), + ...mapActions('editNew', [ + 'updateReleaseTagName', + 'updateCreateFrom', + 'fetchTagNotes', + 'updateShowCreateFrom', + ]), markInputAsDirty() { this.isInputDirty = true; }, @@ -80,7 +83,7 @@ export default { // This method is called when the user selects the "create tag" // option, so the tag does not already exist. Because of this, // we need to show the "create from" input. - this.showCreateFrom = true; + this.updateShowCreateFrom(true); }, shouldShowCreateTagOption(isLoading, matches, query) { // Show the "create tag" option if: diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 236d266a40a..3ad66afa259 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -3,6 +3,8 @@ fragment ReleaseForEditing on Release { name tagName description + releasedAt + tagPath assets { links { nodes { diff --git a/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql new file mode 100644 index 00000000000..7a8bf9944a3 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql @@ -0,0 +1,5 @@ +mutation deleteRelease($input: ReleaseDeleteInput!) { + releaseDelete(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js index fad0451ceef..c3130a0b778 100644 --- a/app/assets/javascripts/releases/mount_edit.js +++ b/app/assets/javascripts/releases/mount_edit.js @@ -11,7 +11,7 @@ export default () => { const store = createStore({ modules: { - editNew: createEditNewModule(el.dataset), + editNew: createEditNewModule({ ...el.dataset, isExistingRelease: true }), }, }); diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index afb8ab461cd..8e806f0e8d7 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; import createDefaultClient from '~/lib/graphql'; import ReleaseIndexApp from './components/app_index.vue'; @@ -7,6 +8,7 @@ export default () => { const el = document.getElementById('js-releases-page'); Vue.use(VueApollo); + Vue.use(GlToast); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index b358a27f06d..0a3f8b5e63b 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -11,7 +11,7 @@ export default () => { const store = createStore({ modules: { - editNew: createEditNewModule(el.dataset), + editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }), }, }); diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 08197377f61..a71a8125d65 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -3,16 +3,21 @@ import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; +import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql'; import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql'; import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql'; import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql'; -import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util'; +import { + gqClient, + convertOneReleaseGraphQLResponse, + deleteReleaseSessionKey, +} from '~/releases/util'; import * as types from './mutation_types'; -export const initializeRelease = ({ commit, dispatch, getters }) => { - if (getters.isExistingRelease) { +export const initializeRelease = ({ commit, dispatch, state }) => { + if (state.isExistingRelease) { // When editing an existing release, // fetch the release object from the API return dispatch('fetchRelease'); @@ -53,6 +58,9 @@ export const updateReleaseTagName = ({ commit }, tagName) => export const updateCreateFrom = ({ commit }, createFrom) => commit(types.UPDATE_CREATE_FROM, createFrom); +export const updateShowCreateFrom = ({ commit }, showCreateFrom) => + commit(types.UPDATE_SHOW_CREATE_FROM, showCreateFrom); + export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); @@ -88,10 +96,10 @@ export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => { redirectTo(urlToRedirectTo); }; -export const saveRelease = ({ commit, dispatch, getters }) => { +export const saveRelease = ({ commit, dispatch, state }) => { commit(types.REQUEST_SAVE_RELEASE); - dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease'); + dispatch(state.isExistingRelease ? 'updateRelease' : 'createRelease'); }; /** @@ -246,3 +254,30 @@ export const fetchTagNotes = ({ commit, state }, tagName) => { export const updateIncludeTagNotes = ({ commit }, includeTagNotes) => { commit(types.UPDATE_INCLUDE_TAG_NOTES, includeTagNotes); }; + +export const updateReleasedAt = ({ commit }, releasedAt) => { + commit(types.UPDATE_RELEASED_AT, releasedAt); +}; + +export const deleteRelease = ({ commit, getters, dispatch, state }) => { + commit(types.REQUEST_SAVE_RELEASE); + return gqClient + .mutate({ + mutation: deleteReleaseMutation, + variables: getters.releaseDeleteMutationVariables, + }) + .then((response) => checkForErrorsAsData(response, 'releaseDelete', '')) + .then(() => { + window.sessionStorage.setItem( + deleteReleaseSessionKey(state.projectPath), + state.originalRelease.name, + ); + return dispatch('receiveSaveReleaseSuccess', state.releasesPagePath); + }) + .catch((error) => { + commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); + createFlash({ + message: s__('Release|Something went wrong while deleting the release.'), + }); + }); +}; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index 0ca5eb9931a..62d6bd42d51 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -4,14 +4,6 @@ import { hasContent } from '~/lib/utils/text_utility'; import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility'; /** - * @returns {Boolean} `true` if the app is editing an existing release. - * `false` if the app is creating a new release. - */ -export const isExistingRelease = (state) => { - return Boolean(state.tagName); -}; - -/** * @param {Object} link The link to test * @returns {Boolean} `true` if the release link is empty, i.e. it has * empty (or whitespace-only) values for both `url` and `name`. @@ -138,6 +130,7 @@ export const releaseUpdateMutatationVariables = (state, getters) => { projectPath: state.projectPath, tagName: state.release.tagName, name, + releasedAt: state.release.releasedAt, description: state.includeTagNotes ? getters.formattedReleaseNotes : state.release.description, @@ -163,6 +156,13 @@ export const releaseCreateMutatationVariables = (state, getters) => { }; }; +export const releaseDeleteMutationVariables = (state) => ({ + input: { + projectPath: state.projectPath, + tagName: state.release.tagName, + }, +}); + export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) => includeTagNotes && tagNotes ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n` diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js index daa077309a1..0ef017f4eb4 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js @@ -6,6 +6,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; export const UPDATE_RELEASE_TAG_NAME = 'UPDATE_RELEASE_TAG_NAME'; export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM'; +export const UPDATE_SHOW_CREATE_FROM = 'UPDATE_SHOW_CREATE_FROM'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; @@ -26,3 +27,4 @@ export const RECEIVE_TAG_NOTES_SUCCESS = 'RECEIVE_TAG_NOTES_SUCCESS'; export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR'; export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES'; +export const UPDATE_RELEASED_AT = 'UPDATE_RELEASED_AT'; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index 6b22468bbfe..ea794f91f66 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -9,11 +9,12 @@ const findReleaseLink = (release, id) => { export default { [types.INITIALIZE_EMPTY_RELEASE](state) { state.release = { - tagName: null, + tagName: state.tagName, name: '', description: '', milestones: [], groupMilestones: [], + releasedAt: new Date(), assets: { links: [], }, @@ -41,6 +42,9 @@ export default { [types.UPDATE_CREATE_FROM](state, createFrom) { state.createFrom = createFrom; }, + [types.UPDATE_SHOW_CREATE_FROM](state, showCreateFrom) { + state.showCreateFrom = showCreateFrom; + }, [types.UPDATE_RELEASE_TITLE](state, title) { state.release.name = title; }, @@ -113,4 +117,7 @@ export default { [types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) { state.includeTagNotes = includeTagNotes; }, + [types.UPDATE_RELEASED_AT](state, releasedAt) { + state.release.releasedAt = releasedAt; + }, }; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js index 33cb3ee06d0..cb447cf9aaf 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -1,4 +1,5 @@ export default ({ + isExistingRelease, projectId, groupId, groupMilestonesAvailable = false, @@ -10,10 +11,13 @@ export default ({ newMilestonePath, releasesPagePath, editReleaseDocsPath, + upcomingReleaseDocsPath, + deleteReleaseDocsPath = '', tagName = null, defaultBranch = null, }) => ({ + isExistingRelease, projectId, groupId, groupMilestonesAvailable: Boolean(groupMilestonesAvailable), @@ -25,12 +29,15 @@ export default ({ newMilestonePath, releasesPagePath, editReleaseDocsPath, + upcomingReleaseDocsPath, + deleteReleaseDocsPath, /** * The name of the tag associated with the release, provided by the backend. - * When creating a new release, this value is null. + * When creating a new release, this is the default from the URL */ tagName, + showCreateFrom: !tagName, defaultBranch, createFrom: defaultBranch, diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index 22d5fb4f620..f1f5f4bca4c 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -11,10 +11,13 @@ const convertScalarProperties = (graphQLRelease) => 'tagPath', 'description', 'descriptionHtml', - 'releasedAt', 'upcomingRelease', ]); +const convertDateProperties = ({ releasedAt }) => ({ + releasedAt: new Date(releasedAt), +}); + const convertAssets = (graphQLRelease) => { let sources = []; if (graphQLRelease.assets.sources?.nodes) { @@ -88,6 +91,7 @@ const convertMilestones = (graphQLRelease) => ({ */ export const convertGraphQLRelease = (graphQLRelease) => ({ ...convertScalarProperties(graphQLRelease), + ...convertDateProperties(graphQLRelease), ...convertAssets(graphQLRelease), ...convertEvidences(graphQLRelease), ...convertLinks(graphQLRelease), @@ -129,3 +133,5 @@ export const convertOneReleaseGraphQLResponse = (response) => { return { data: release }; }; + +export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`; diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 92d0783749e..ee55368c829 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -84,7 +84,7 @@ export default { </div> </div> <div - v-if="$slots.default" + v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */" class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row" > <slot></slot> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 280455c3fed..bf4f19504f0 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -97,6 +97,7 @@ export default { project: DEFAULT_BLOB_INFO.project, gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled, currentUser: DEFAULT_BLOB_INFO.currentUser, + useFallback: false, }; }, computed: { @@ -130,7 +131,7 @@ export default { }, shouldLoadLegacyViewer() { const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs; - return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType); + return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback; }, legacyViewerLoaded() { return ( @@ -173,6 +174,10 @@ export default { }, }, methods: { + onError() { + this.useFallback = true; + this.loadLegacyViewer(); + }, loadLegacyViewer() { if (this.legacyViewerLoaded) { return; @@ -303,7 +308,7 @@ export default { :loading="isLoadingLegacyViewer" :data-loading="isRenderingLegacyTextViewer" /> - <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" /> + <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" @error="onError" /> <code-intelligence v-if="blobViewer || legacyViewerLoaded" :code-navigation-path="blobInfo.codeNavigationPath" diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index d24d7648f1b..9f2cf8505d3 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -49,10 +49,11 @@ export default { }; }, update: (data) => { - const pipelines = data.project?.repository?.tree?.lastCommit?.pipelines?.edges; + const lastCommit = data.project?.repository?.paginatedTree?.nodes[0]?.lastCommit; + const pipelines = lastCommit?.pipelines?.edges; return { - ...data.project?.repository?.tree?.lastCommit, + ...lastCommit, pipeline: pipelines?.length && pipelines[0].node, }; }, @@ -131,7 +132,9 @@ export default { :css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */" :size="32" /> - <div class="commit-detail flex-list"> + <div + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" + > <div class="commit-content qa-commit-content"> <gl-link v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 41f7a4b147f..1f6b5e98122 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -103,14 +103,12 @@ export default { return this.rowNumbers[key]; }, - getCommit(fileName, type) { + getCommit(fileName) { if (!this.glFeatures.lazyLoadCommits) { return {}; } - return this.commits.find( - (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type, - ); + return this.commits.find((commitEntry) => commitEntry.fileName === fileName); }, }, }; @@ -152,7 +150,7 @@ export default { :loading-path="loadingPath" :total-entries="totalEntries" :row-number="generateRowNumber(entry.flatPath, entry.id, index)" - :commit-info="getCommit(entry.name, entry.type)" + :commit-info="getCommit(entry.name)" v-on="$listeners" /> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 2b910109f7d..99b7395d6e7 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -43,7 +43,6 @@ export default { variables() { return { fileName: this.name, - type: this.type, path: this.currentPath, projectPath: this.projectPath, maxOffset: this.totalEntries, @@ -135,14 +134,11 @@ export default { commitData() { return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit; }, - refactorBlobViewerEnabled() { - return this.glFeatures.refactorBlobViewer; - }, routerLinkTo() { const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` }; const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` }; - if (this.refactorBlobViewerEnabled && this.isBlob) { + if (this.isBlob) { return blobRouteConfig; } @@ -158,7 +154,7 @@ export default { return this.type === 'commit'; }, linkComponent() { - return this.isFolder || (this.refactorBlobViewerEnabled && this.isBlob) ? 'router-link' : 'a'; + return this.isFolder || this.isBlob ? 'router-link' : 'a'; }, fullPath() { return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), ''); @@ -187,10 +183,6 @@ export default { }); }, loadBlob() { - if (!this.refactorBlobViewerEnabled) { - return; - } - this.apolloQuery(blobInfoQuery, { projectPath: this.projectPath, filePath: this.path, diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 2cafeed2ef4..0e80f306638 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -93,7 +93,6 @@ export const LFS_STORAGE = 'lfs'; * These are file types that we want the legacy (backend) syntax highlighter to highlight. */ export const LEGACY_FILE_TYPES = [ - 'package_json', 'gemfile', 'gemspec', 'composer_json', diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 29aabe1b00f..3a59a02af01 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -9,7 +9,7 @@ Vue.use(VueApollo); const defaultClient = createDefaultClient( { Query: { - commit(_, { path, fileName, type, maxOffset }) { + commit(_, { path, fileName, maxOffset }) { return new Promise((resolve) => { fetchLogsTree( defaultClient, @@ -19,7 +19,6 @@ const defaultClient = createDefaultClient( resolve, entry: { name: fileName, - type, }, }, maxOffset, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 8f8735a6371..1d295e18332 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -91,9 +91,7 @@ export default function setupVueRepositoryList() { initLastCommitApp(); - if (gon.features.refactorBlobViewer) { - initBlobControlsApp(); - } + initBlobControlsApp(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index ac02392d60f..9345a8406e3 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -16,9 +16,7 @@ function setNextOffset(offset) { } export function resolveCommit(commits, path, { resolve, entry }) { - const commit = commits.find( - (c) => c.filePath === `${path}/${entry.name}` && c.type === entry.type, - ); + const commit = commits.find((c) => c.filePath === `${path}/${entry.name}`); if (commit) { resolve(commit); diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql index b046fc1f730..80dedfe3e3f 100644 --- a/app/assets/javascripts/repository/queries/commit.fragment.graphql +++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql @@ -6,5 +6,4 @@ fragment TreeEntryCommit on LogTreeCommit { commitPath fileName filePath - type } diff --git a/app/assets/javascripts/repository/queries/commit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql index 7ae4a3b984a..1a01462bd19 100644 --- a/app/assets/javascripts/repository/queries/commit.query.graphql +++ b/app/assets/javascripts/repository/queries/commit.query.graphql @@ -1,7 +1,7 @@ #import "ee_else_ce/repository/queries/commit.fragment.graphql" -query getCommit($fileName: String!, $type: String!, $path: String!, $maxOffset: Number!) { - commit(path: $path, fileName: $fileName, type: $type, maxOffset: $maxOffset) @client { +query getCommit($fileName: String!, $path: String!, $maxOffset: Number!) { + commit(path: $path, fileName: $fileName, maxOffset: $maxOffset) @client { ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js index a67252ec004..878b4fdd71a 100644 --- a/app/assets/javascripts/repository/utils/commit.js +++ b/app/assets/javascripts/repository/utils/commit.js @@ -7,7 +7,6 @@ export function normalizeData(data, path, extra = () => {}) { commitPath: d.commit_path, fileName: d.file_name, filePath: `${path}/${d.file_name}`, - type: d.type, __typename: 'LogTreeCommit', ...extra(d), })); diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue index 06a8eb790fc..9fa4b521ebc 100644 --- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -11,7 +11,7 @@ import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import RunnerJobs from '../components/runner_jobs.vue'; -import { I18N_FETCH_ERROR } from '../constants'; +import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; @@ -20,6 +20,7 @@ export default { name: 'AdminRunnerShowApp', components: { GlBadge, + GlTabs, GlTab, RunnerDeleteButton, RunnerEditButton, @@ -84,6 +85,7 @@ export default { redirectTo(this.runnersPath); }, }, + I18N_DETAILS, }; </script> <template> @@ -96,24 +98,27 @@ export default { </template> </runner-header> - <runner-details :runner="runner"> - <template #jobs-tab> - <gl-tab> - <template #title> - {{ s__('Runners|Jobs') }} - <gl-badge - v-if="jobCount" - data-testid="job-count-badge" - class="gl-tab-counter-badge" - size="sm" - > - {{ jobCount }} - </gl-badge> - </template> + <gl-tabs> + <gl-tab> + <template #title>{{ $options.I18N_DETAILS }}</template> - <runner-jobs v-if="runner" :runner="runner" /> - </gl-tab> - </template> - </runner-details> + <runner-details v-if="runner" :runner="runner" /> + </gl-tab> + <gl-tab> + <template #title> + {{ s__('Runners|Jobs') }} + <gl-badge + v-if="jobCount" + data-testid="job-count-badge" + class="gl-tab-counter-badge" + size="sm" + > + {{ jobCount }} + </gl-badge> + </template> + + <runner-jobs v-if="runner" :runner="runner" /> + </gl-tab> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index a90ef2d3530..f6b7a8b46d7 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,10 +1,17 @@ <script> -import { GlBadge, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; -import { formatNumber } from '~/locale'; import { fetchPolicies } from '~/lib/graphql'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, + isSearchFiltered, +} from 'ee_else_ce/runner/runner_search_utils'; +import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -20,74 +27,12 @@ import RunnerActionsCell from '../components/cells/runner_actions_cell.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 { - ADMIN_FILTERED_SEARCH_NAMESPACE, - INSTANCE_TYPE, - GROUP_TYPE, - PROJECT_TYPE, - STATUS_ONLINE, - STATUS_OFFLINE, - STATUS_STALE, - I18N_FETCH_ERROR, -} from '../constants'; -import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql'; -import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql'; -import { - fromUrlQueryToSearch, - fromSearchToUrl, - fromSearchToVariables, - isSearchFiltered, -} from '../runner_search_utils'; +import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; import { captureException } from '../sentry_utils'; -const countSmartQuery = () => ({ - query: runnersAdminCountQuery, - fetchPolicy: fetchPolicies.NETWORK_ONLY, - update(data) { - return data?.runners?.count; - }, - error(error) { - this.reportToSentry(error); - }, -}); - -const tabCountSmartQuery = ({ type }) => { - return { - ...countSmartQuery(), - variables() { - return { - ...this.countVariables, - type, - }; - }, - }; -}; - -const statusCountSmartQuery = ({ status, name }) => { - return { - ...countSmartQuery(), - skip() { - // skip if filtering by status and not using _this_ status as filter - if (this.countVariables.status && this.countVariables.status !== status) { - // reset count for given status - this[name] = null; - return true; - } - return false; - }, - variables() { - return { - ...this.countVariables, - status, - }; - }, - }; -}; - export default { name: 'AdminRunnersApp', components: { - GlBadge, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -119,7 +64,7 @@ export default { }, apollo: { runners: { - query: runnersAdminQuery, + query: allRunnersQuery, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; @@ -137,31 +82,6 @@ export default { this.reportToSentry(error); }, }, - - // Tabs counts - allRunnersCount: { - ...tabCountSmartQuery({ type: null }), - }, - instanceRunnersCount: { - ...tabCountSmartQuery({ type: INSTANCE_TYPE }), - }, - groupRunnersCount: { - ...tabCountSmartQuery({ type: GROUP_TYPE }), - }, - projectRunnersCount: { - ...tabCountSmartQuery({ type: PROJECT_TYPE }), - }, - - // Runner stats - onlineRunnersTotal: { - ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }), - }, - offlineRunnersTotal: { - ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }), - }, - staleRunnersTotal: { - ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }), - }, }, computed: { variables() { @@ -186,6 +106,7 @@ export default { ...tagTokenConfig, recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, }, + upgradeStatusTokenConfig, ]; }, isBulkDeleteEnabled() { @@ -214,39 +135,10 @@ export default { this.reportToSentry(error); }, methods: { - tabCount({ runnerType }) { - let count; - switch (runnerType) { - case null: - count = this.allRunnersCount; - break; - case INSTANCE_TYPE: - count = this.instanceRunnersCount; - break; - case GROUP_TYPE: - count = this.groupRunnersCount; - break; - case PROJECT_TYPE: - count = this.projectRunnersCount; - break; - default: - return null; - } - if (typeof count === 'number') { - return formatNumber(count); - } - return ''; - }, - refetchFilteredCounts() { - this.$apollo.queries.allRunnersCount.refetch(); - this.$apollo.queries.instanceRunnersCount.refetch(); - this.$apollo.queries.groupRunnersCount.refetch(); - this.$apollo.queries.projectRunnersCount.refetch(); - }, onToggledPaused() { - // When a runner is Paused, the tab count can + // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.refetchFilteredCounts(); + this.$refs['runner-type-tabs'].refetch(); }, onDeleted({ message }) { this.$root.$toast?.show(message); @@ -271,18 +163,14 @@ export default { 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" > <runner-type-tabs + ref="runner-type-tabs" v-model="search" + :count-scope="$options.INSTANCE_TYPE" + :count-variables="countVariables" class="gl-w-full" content-class="gl-display-none" nav-class="gl-border-none!" - > - <template #title="{ tab }"> - {{ tab.title }} - <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm"> - {{ tabCount(tab) }} - </gl-badge> - </template> - </runner-type-tabs> + /> <registration-dropdown class="gl-w-full gl-sm-w-auto gl-mr-auto" @@ -298,11 +186,7 @@ export default { :namespace="$options.filteredSearchNamespace" /> - <runner-stats - :online-runners-count="onlineRunnersTotal" - :offline-runners-count="offlineRunnersTotal" - :stale-runners-count="staleRunnersTotal" - /> + <runner-stats :scope="$options.INSTANCE_TYPE" :variables="countVariables" /> <runner-list-empty-state v-if="noRunnersFound" diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue index 09d46ce3e66..667cb0090b3 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -8,14 +8,16 @@ import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners import { captureException } from '~/runner/sentry_utils'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; +const i18n = { + modalAction: s__('Runners|Reset token'), + modalCancel: __('Cancel'), + modalCopy: __('Are you sure you want to reset the registration token?'), + modalTitle: __('Reset registration token'), +}; + export default { name: 'RunnerRegistrationTokenReset', - i18n: { - modalAction: s__('Runners|Reset token'), - modalCancel: __('Cancel'), - modalCopy: __('Are you sure you want to reset the registration token?'), - modalTitle: __('Reset registration token'), - }, + i18n, components: { GlDropdownItem, GlLoadingIcon, @@ -68,6 +70,18 @@ export default { return null; } }, + actionPrimary() { + return { + text: i18n.modalAction, + attributes: [{ variant: 'danger' }], + }; + }, + actionSecondary() { + return { + text: i18n.modalCancel, + attributes: [{ variant: 'default' }], + }; + }, }, methods: { handleModalPrimary() { @@ -115,14 +129,8 @@ export default { <gl-modal size="sm" :modal-id="$options.modalId" - :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - text: $options.i18n.modalAction, - attributes: [{ variant: 'danger' }], - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - :action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - text: $options.i18n.modalCancel, - attributes: [{ variant: 'default' }], - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :action-primary="actionPrimary" + :action-secondary="actionSecondary" :title="$options.i18n.modalTitle" @primary="handleModalPrimary" > diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue index b1234818b7e..db67acef3db 100644 --- a/app/assets/javascripts/runner/components/runner_detail.vue +++ b/app/assets/javascripts/runner/components/runner_detail.vue @@ -41,6 +41,7 @@ export default { <div class="gl-display-flex gl-pb-4"> <dt class="gl-mr-2">{{ label }}</dt> <dd class="gl-mb-0"> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <template v-if="value || $slots.value"> <slot name="value">{{ value }}</slot> </template> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index 75ddec6c716..60469d26dd5 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -1,5 +1,5 @@ <script> -import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui'; +import { GlIntersperse } from '@gitlab/ui'; import { s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; @@ -11,14 +11,16 @@ import RunnerTags from './runner_tags.vue'; export default { components: { - GlTabs, - GlTab, GlIntersperse, RunnerDetail, RunnerMaintenanceNoteDetail: () => import('ee_component/runner/components/runner_maintenance_note_detail.vue'), RunnerGroups, RunnerProjects, + RunnerUpgradeStatusBadge: () => + import('ee_component/runner/components/runner_upgrade_status_badge.vue'), + RunnerUpgradeStatusAlert: () => + import('ee_component/runner/components/runner_upgrade_status_alert.vue'), RunnerTags, TimeAgo, }, @@ -61,58 +63,57 @@ export default { </script> <template> - <gl-tabs> - <gl-tab> - <template #title>{{ s__('Runners|Details') }}</template> - - <template v-if="runner"> - <div class="gl-pt-4"> - <dl class="gl-mb-0" data-testid="runner-details-list"> - <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> - <runner-detail - :label="s__('Runners|Last contact')" - :empty-value="s__('Runners|Never contacted')" - > - <template #value> - <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> - </template> - </runner-detail> - <runner-detail :label="s__('Runners|Version')" :value="runner.version" /> - <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> - <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" /> - <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> - <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> - <runner-detail :label="s__('Runners|Configuration')"> - <template #value> - <gl-intersperse v-if="configTextProtected || configTextUntagged"> - <span v-if="configTextProtected">{{ configTextProtected }}</span> - <span v-if="configTextUntagged">{{ configTextUntagged }}</span> - </gl-intersperse> - </template> - </runner-detail> - <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> - <runner-detail :label="s__('Runners|Tags')"> - <template #value> - <runner-tags - v-if="runner.tagList && runner.tagList.length" - class="gl-vertical-align-middle" - :tag-list="runner.tagList" - size="sm" - /> - </template> - </runner-detail> - - <runner-maintenance-note-detail - class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid" - :value="runner.maintenanceNoteHtml" + <div> + <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> + <div class="gl-pt-4"> + <dl class="gl-mb-0" data-testid="runner-details-list"> + <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> + <runner-detail + :label="s__('Runners|Last contact')" + :empty-value="s__('Runners|Never contacted')" + > + <template #value> + <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Version')"> + <template v-if="runner.version" #value> + {{ runner.version }} + <runner-upgrade-status-badge size="sm" :runner="runner" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> + <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" /> + <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> + <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> + <runner-detail :label="s__('Runners|Configuration')"> + <template #value> + <gl-intersperse v-if="configTextProtected || configTextUntagged"> + <span v-if="configTextProtected">{{ configTextProtected }}</span> + <span v-if="configTextUntagged">{{ configTextUntagged }}</span> + </gl-intersperse> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> + <runner-detail :label="s__('Runners|Tags')"> + <template #value> + <runner-tags + v-if="runner.tagList && runner.tagList.length" + class="gl-vertical-align-middle" + :tag-list="runner.tagList" + size="sm" /> - </dl> - </div> + </template> + </runner-detail> + + <runner-maintenance-note-detail + class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid" + :value="runner.maintenanceNoteHtml" + /> + </dl> + </div> - <runner-groups v-if="isGroupRunner" :runner="runner" /> - <runner-projects v-if="isProjectRunner" :runner="runner" /> - </template> - </gl-tab> - <slot name="jobs-tab"></slot> - </gl-tabs> + <runner-groups v-if="isGroupRunner" :runner="runner" /> + <runner-projects v-if="isProjectRunner" :runner="runner" /> + </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index f0f8bbdf5df..bff5ec9b238 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -45,7 +45,7 @@ export default { }, }, data() { - // filtered_search_bar_root.vue may mutate the inital + // filtered_search_bar_root.vue may mutate the initial // filters. Use `cloneDeep` to prevent those mutations // from affecting this component const { filters, sort } = cloneDeep(this.value); @@ -54,6 +54,14 @@ export default { initialSortBy: sort, }; }, + computed: { + validTokens() { + // Some filters are only available in EE + // EE-only tokens are represented by `null` or `undefined` + // values when in CE + return this.tokens.filter(Boolean); + }, + }, methods: { onFilter(filters) { // Apply new filters, from page 1 @@ -83,7 +91,7 @@ export default { recent-searches-storage-key="runners-search" :sort-options="$options.sortOptions" :initial-filter-value="initialFilterValue" - :tokens="tokens" + :tokens="validTokens" :initial-sort-by="initialSortBy" :search-input-placeholder="__('Search or filter results...')" data-testid="runners-filtered-search" diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue index 25ed6600dc9..6b9e3bf91ad 100644 --- a/app/assets/javascripts/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue @@ -1,6 +1,7 @@ <script> -import { GlTabs, GlTab } from '@gitlab/ui'; +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; import { searchValidator } from '~/runner/runner_search_utils'; +import { formatNumber } from '~/locale'; import { INSTANCE_TYPE, GROUP_TYPE, @@ -10,6 +11,7 @@ import { I18N_GROUP_TYPE, I18N_PROJECT_TYPE, } from '../constants'; +import RunnerCount from './stat/runner_count.vue'; const I18N_TAB_TITLES = { [INSTANCE_TYPE]: I18N_INSTANCE_TYPE, @@ -17,10 +19,14 @@ const I18N_TAB_TITLES = { [PROJECT_TYPE]: I18N_PROJECT_TYPE, }; +const TAB_COUNT_REF = 'tab-count'; + export default { components: { + GlBadge, GlTabs, GlTab, + RunnerCount, }, props: { runnerTypes: { @@ -33,6 +39,14 @@ export default { required: true, validator: searchValidator, }, + countScope: { + type: String, + required: true, + }, + countVariables: { + type: Object, + required: true, + }, }, computed: { tabs() { @@ -62,7 +76,25 @@ export default { isTabActive({ runnerType }) { return runnerType === this.value.runnerType; }, + tabBadgeCountVariables(runnerType) { + return { ...this.countVariables, type: runnerType }; + }, + tabCount(count) { + if (typeof count === 'number') { + return formatNumber(count); + } + return ''; + }, + + // Component API + refetch() { + // Refresh all of the counts here, can be called by parent component + this.$refs[TAB_COUNT_REF].forEach((countComponent) => { + countComponent.refetch(); + }); + }, }, + TAB_COUNT_REF, }; </script> <template> @@ -74,7 +106,17 @@ export default { @click="onTabSelected(tab)" > <template #title> - <slot name="title" :tab="tab">{{ tab.title }}</slot> + {{ tab.title }} + <runner-count + #default="{ count }" + :ref="$options.TAB_COUNT_REF" + :scope="countScope" + :variables="tabBadgeCountVariables(tab.runnerType)" + > + <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm"> + {{ tabCount(count) }} + </gl-badge> + </runner-count> </template> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js index 1bab875a8a1..c1ad5da3ab9 100644 --- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js @@ -22,7 +22,7 @@ export const pausedTokenConfig = { // contain spaces! // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - title: title.replace(' ', '\u00a0'), + title: title.replace(/\s/g, '\u00a0'), })), operators: OPERATOR_IS_ONLY, }; diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index f28bd491ea5..9e6f63d3f7c 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -30,7 +30,7 @@ export const statusTokenConfig = { // contain spaces! // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - title: title.replace(' ', '\u00a0'), + title: title.replace(/\s/g, '\u00a0'), })), operators: OPERATOR_IS_ONLY, }; diff --git a/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js new file mode 100644 index 00000000000..17ee7073360 --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js @@ -0,0 +1,2 @@ +// Overridden in EE +export const upgradeStatusTokenConfig = null; diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue new file mode 100644 index 00000000000..af18b203f90 --- /dev/null +++ b/app/assets/javascripts/runner/components/stat/runner_count.vue @@ -0,0 +1,103 @@ +<script> +import { fetchPolicies } from '~/lib/graphql'; +import { captureException } from '../../sentry_utils'; +import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql'; +import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; + +/** + * Renderless component that wraps a "count" query for the + * number of runners that follow a filter criteria. + * + * Example usage: + * + * Render the count of "online" runners in the instance in a + * <strong/> tag. + * + * ```vue + * <runner-count-stat + * #default="{ count }" + * :scope="INSTANCE_TYPE" + * :variables="{ status: 'ONLINE' }" + * > + * <strong>{{ count }}</strong> + * </runner-count-stat> + * ``` + * + * Use `:skip="true"` to prevent data from being fetched and + * even rendered. + */ +export default { + name: 'RunnerCount', + props: { + scope: { + type: String, + required: true, + validator: (val) => [INSTANCE_TYPE, GROUP_TYPE].includes(val), + }, + variables: { + type: Object, + required: false, + default: () => {}, + }, + skip: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { count: null }; + }, + apollo: { + count: { + query() { + if (this.scope === INSTANCE_TYPE) { + return allRunnersCountQuery; + } else if (this.scope === GROUP_TYPE) { + return groupRunnersCountQuery; + } + return null; + }, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + variables() { + return this.variables; + }, + skip() { + if (this.skip) { + // Don't show data for skipped stats + this.count = null; + } + return this.skip; + }, + update(data) { + if (this.scope === INSTANCE_TYPE) { + return data?.runners?.count; + } else if (this.scope === GROUP_TYPE) { + return data?.group?.runners?.count; + } + return null; + }, + error(error) { + this.reportToSentry(error); + }, + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + + // Component API + refetch() { + // Parent components can use this method to refresh the count + this.$apollo.queries.count.refetch(); + }, + }, + render() { + return this.$scopedSlots.default({ + count: this.count, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue index d3693ee593e..9e1ca9ba4ee 100644 --- a/app/assets/javascripts/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue @@ -1,49 +1,47 @@ <script> import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; +import RunnerCount from './runner_count.vue'; import RunnerStatusStat from './runner_status_stat.vue'; export default { components: { + RunnerCount, RunnerStatusStat, }, props: { - onlineRunnersCount: { - type: Number, - required: false, - default: null, + scope: { + type: String, + required: true, }, - offlineRunnersCount: { - type: Number, + variables: { + type: Object, required: false, - default: null, + default: () => {}, }, - staleRunnersCount: { - type: Number, - required: false, - default: null, + }, + methods: { + countVariables(vars) { + return { ...this.variables, ...vars }; + }, + statusCountSkip(status) { + // Show an empty result when we already filter by another status + return this.variables.status && this.variables.status !== status; }, }, - STATUS_ONLINE, - STATUS_OFFLINE, - STATUS_STALE, + STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE], }; </script> <template> <div class="gl-display-flex gl-py-6"> - <runner-status-stat - class="gl-px-5" - :status="$options.STATUS_ONLINE" - :value="onlineRunnersCount" - /> - <runner-status-stat - class="gl-px-5" - :status="$options.STATUS_OFFLINE" - :value="offlineRunnersCount" - /> - <runner-status-stat - class="gl-px-5" - :status="$options.STATUS_STALE" - :value="staleRunnersCount" - /> + <runner-count + v-for="status in $options.STATUS_LIST" + #default="{ count }" + :key="status" + :scope="scope" + :variables="countVariables({ status })" + :skip="statusCountSkip(status)" + > + <runner-status-stat class="gl-px-5" :status="status" :value="count" /> + </runner-count> </div> </template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index b9621c26b59..64541729701 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -81,6 +81,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( // Runner details +export const I18N_DETAILS = s__('Runners|Details'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_NONE = __('None'); export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.'); diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql index 61bfe03bf6e..6bb896dda16 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql @@ -1,7 +1,7 @@ #import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" -query getRunners( +query getAllRunners( $before: String $after: String $first: Int diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql index 1dd258a3524..82591b88d3e 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql @@ -1,4 +1,4 @@ -query getRunnersCount( +query getAllRunnersCount( $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue index c336e091fdf..75138b1bd81 100644 --- a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue +++ b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue @@ -1,16 +1,13 @@ <script> -import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import { formatJobCount } from '../utils'; import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; -import RunnerJobs from '../components/runner_jobs.vue'; import { I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; @@ -19,17 +16,11 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo export default { name: 'GroupRunnerShowApp', components: { - GlBadge, - GlTab, RunnerDeleteButton, RunnerEditButton, RunnerPauseButton, RunnerHeader, RunnerDetails, - RunnerJobs, - }, - directives: { - GlTooltip: GlTooltipDirective, }, props: { runnerId: { @@ -40,6 +31,11 @@ export default { type: String, required: true, }, + editGroupRunnerPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -68,9 +64,6 @@ export default { canDelete() { return this.runner.userPermissions?.deleteRunner; }, - jobCount() { - return formatJobCount(this.runner?.jobCount); - }, }, errorCaptured(error) { this.reportToSentry(error); @@ -90,25 +83,12 @@ export default { <div> <runner-header v-if="runner" :runner="runner"> <template #actions> - <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" /> <runner-pause-button v-if="canUpdate" :runner="runner" /> <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> </template> </runner-header> - <runner-details :runner="runner"> - <template #jobs-tab> - <gl-tab> - <template #title> - {{ s__('Runners|Jobs') }} - <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm"> - {{ jobCount }} - </gl-badge> - </template> - - <runner-jobs v-if="runner" :runner="runner" /> - </gl-tab> - </template> - </runner-details> + <runner-details v-if="runner" :runner="runner" /> </div> </template> diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js index d1b87c8e427..62a0dab9211 100644 --- a/app/assets/javascripts/runner/group_runner_show/index.js +++ b/app/assets/javascripts/runner/group_runner_show/index.js @@ -1,21 +1,18 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import GroupRunnerShowApp from './group_runner_show_app.vue'; Vue.use(VueApollo); -export const initAdminRunnerShow = (selector = '#js-group-runner-show') => { - showAlertFromLocalStorage(); - +export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { const el = document.querySelector(selector); if (!el) { return null; } - const { runnerId, runnersPath } = el.dataset; + const { runnerId, runnersPath, editGroupRunnerPath } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -29,6 +26,7 @@ export const initAdminRunnerShow = (selector = '#js-group-runner-show') => { props: { runnerId, runnersPath, + editGroupRunnerPath, }, }); }, diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 641b3a8f560..e8446dbe345 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,8 +1,7 @@ <script> -import { GlBadge, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; -import { formatNumber } from '~/locale'; import { fetchPolicies } from '~/lib/graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; @@ -21,13 +20,9 @@ import { GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_TYPE, PROJECT_TYPE, - STATUS_ONLINE, - STATUS_OFFLINE, - STATUS_STALE, I18N_FETCH_ERROR, } from '../constants'; import groupRunnersQuery from '../graphql/list/group_runners.query.graphql'; -import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql'; import { fromUrlQueryToSearch, fromSearchToUrl, @@ -36,54 +31,9 @@ import { } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; -const countSmartQuery = () => ({ - query: groupRunnersCountQuery, - fetchPolicy: fetchPolicies.NETWORK_ONLY, - update(data) { - return data?.group?.runners?.count; - }, - error(error) { - this.reportToSentry(error); - }, -}); - -const tabCountSmartQuery = ({ type }) => { - return { - ...countSmartQuery(), - variables() { - return { - ...this.countVariables, - type, - }; - }, - }; -}; - -const statusCountSmartQuery = ({ status, name }) => { - return { - ...countSmartQuery(), - skip() { - // skip if filtering by status and not using _this_ status as filter - if (this.countVariables.status && this.countVariables.status !== status) { - // reset count for given status - this[name] = null; - return true; - } - return false; - }, - variables() { - return { - ...this.countVariables, - status, - }; - }, - }; -}; - export default { name: 'GroupRunnersApp', components: { - GlBadge, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -153,28 +103,6 @@ export default { this.reportToSentry(error); }, }, - - // Tabs counts - allRunnersCount: { - ...tabCountSmartQuery({ type: null }), - }, - groupRunnersCount: { - ...tabCountSmartQuery({ type: GROUP_TYPE }), - }, - projectRunnersCount: { - ...tabCountSmartQuery({ type: PROJECT_TYPE }), - }, - - // Runner status summary - onlineRunnersTotal: { - ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }), - }, - offlineRunnersTotal: { - ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }), - }, - staleRunnersTotal: { - ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }), - }, }, computed: { variables() { @@ -221,41 +149,16 @@ export default { this.reportToSentry(error); }, methods: { - tabCount({ runnerType }) { - let count; - switch (runnerType) { - case null: - count = this.allRunnersCount; - break; - case GROUP_TYPE: - count = this.groupRunnersCount; - break; - case PROJECT_TYPE: - count = this.projectRunnersCount; - break; - default: - return null; - } - if (typeof count === 'number') { - return formatNumber(count); - } - return null; - }, webUrl(runner) { return this.runners.urlsById[runner.id]?.web; }, editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, - refetchFilteredCounts() { - this.$apollo.queries.allRunnersCount.refetch(); - this.$apollo.queries.groupRunnersCount.refetch(); - this.$apollo.queries.projectRunnersCount.refetch(); - }, onToggledPaused() { - // When a runner is Paused, the tab count can + // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.refetchFilteredCounts(); + this.$refs['runner-type-tabs'].refetch(); }, onDeleted({ message }) { this.$root.$toast?.show(message); @@ -273,18 +176,15 @@ export default { <div> <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs + ref="runner-type-tabs" v-model="search" + :count-scope="$options.GROUP_TYPE" + :count-variables="countVariables" :runner-types="$options.TABS_RUNNER_TYPES" + class="gl-w-full" content-class="gl-display-none" nav-class="gl-border-none!" - > - <template #title="{ tab }"> - {{ tab.title }} - <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm"> - {{ tabCount(tab) }} - </gl-badge> - </template> - </runner-type-tabs> + /> <registration-dropdown class="gl-ml-auto" @@ -300,11 +200,7 @@ export default { :namespace="filteredSearchNamespace" /> - <runner-stats - :online-runners-count="onlineRunnersTotal" - :offline-runners-count="offlineRunnersTotal" - :stale-runners-count="staleRunnersTotal" - /> + <runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" /> <runner-list-empty-state v-if="noRunnersFound" diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 34910781247..ecde9235e93 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -206,6 +206,7 @@ export default { <template #features> <feature-card v-for="feature in augmentedSecurityFeatures" + :id="feature.anchor" :key="feature.type" data-testid="security-testing-card" :feature="feature" @@ -254,7 +255,6 @@ export default { </section-layout> </gl-tab> <gl-tab - v-if="securityTrainingEnabled" data-testid="vulnerability-management-tab" :title="$options.i18n.vulnerabilityManagement" query-param-value="vulnerability-management" @@ -271,7 +271,7 @@ export default { </p> </template> <template #features> - <training-provider-list /> + <training-provider-list :security-training-enabled="securityTrainingEnabled" /> </template> </section-layout> </gl-tab> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index e4d2bd08f50..6efaf08a178 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -194,6 +194,7 @@ export const securityFeatures = [ helpPath: DAST_HELP_PATH, configurationHelpPath: DAST_CONFIG_HELP_PATH, type: REPORT_TYPE_DAST, + anchor: 'dast', }, { name: DEPENDENCY_SCANNING_NAME, @@ -201,6 +202,7 @@ export const securityFeatures = [ helpPath: DEPENDENCY_SCANNING_HELP_PATH, configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, type: REPORT_TYPE_DEPENDENCY_SCANNING, + anchor: 'dependency-scanning', }, { name: CONTAINER_SCANNING_NAME, 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 ef50d085ae8..0bcb2bb6720 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -39,6 +39,7 @@ const i18n = { primaryTrainingDescription: s__( 'SecurityTraining|Training from this partner takes precedence when more than one training partner is enabled.', ), + unavailableText: s__('SecurityConfiguration|Available with Ultimate'), }; export default { @@ -73,6 +74,13 @@ export default { }, }, }, + props: { + securityTrainingEnabled: { + type: Boolean, + required: true, + }, + }, + data() { return { errorMessage: '', @@ -232,12 +240,13 @@ export default { </div> <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> <li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6"> - <gl-card> + <gl-card :body-class="{ 'gl-bg-gray-10': !securityTrainingEnabled }"> <div class="gl-display-flex"> <gl-toggle :value="provider.isEnabled" :label="__('Training mode')" label-position="hidden" + :disabled="!securityTrainingEnabled" @change="toggleProvider(provider)" /> <div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4"> @@ -249,7 +258,18 @@ export default { ></div> </div> <div class="gl-ml-3"> - <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3> + <div class="gl-display-flex gl-justify-content-space-between"> + <h3 class="gl-font-lg gl-m-0 gl-mb-2"> + {{ provider.name }} + </h3> + <span + v-if="!securityTrainingEnabled" + data-testid="unavailable-text" + class="gl-text-gray-600" + > + {{ $options.i18n.unavailableText }} + </span> + </div> <p> {{ provider.description }} <gl-link @@ -263,7 +283,7 @@ export default { </p> <gl-form-radio :checked="primaryProviderId" - :disabled="!provider.isEnabled" + :disabled="!securityTrainingEnabled || !provider.isEnabled" :value="provider.id" @change="setPrimaryProvider(provider)" > diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql index 891e0dda312..9fdacb4ee10 100644 --- a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql +++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql @@ -1,7 +1,11 @@ -query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) { +query getSecurityTrainingUrls( + $projectFullPath: ID! + $identifierExternalIds: [String!]! + $filename: String +) { project(fullPath: $projectFullPath) { id - securityTrainingUrls(identifierExternalIds: $identifierExternalIds) { + securityTrainingUrls(identifierExternalIds: $identifierExternalIds, filename: $filename) { name status url diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 2f31d8ef3fb..b14e816a674 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -136,7 +136,9 @@ export default { <template> <section class="settings no-animate js-self-monitoring-settings"> <div class="settings-header"> - <h4 class="js-section-header"> + <h4 + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" + > {{ s__('SelfMonitoring|Self monitoring') }} </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index eb0931c6fe2..579316f481c 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -1,10 +1,13 @@ <script> import { + GlButton, GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox, + GlFormInput, + GlFormInputGroup, GlDropdown, GlDropdownItem, GlSafeHtmlDirective, @@ -38,9 +41,12 @@ const statusTimeRanges = [ export default { components: { + GlButton, GlIcon, GlModal, GlFormCheckbox, + GlFormInput, + GlFormInputGroup, GlDropdown, GlDropdownItem, EmojiPicker: () => import('~/emoji/components/picker.vue'), @@ -215,97 +221,80 @@ export default { @primary="setStatus" @secondary="removeStatus" > - <div> - <input - v-model="emoji" - class="js-status-emoji-field" - type="hidden" - name="user[status][emoji]" + <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" /> + <gl-form-input-group class="gl-mb-5"> + <gl-form-input + ref="statusMessageField" + v-model="message" + :placeholder="s__(`SetStatusModal|What's your status?`)" + class="js-status-message-field" + name="user[status][message]" + @keyup="setDefaultEmoji" + @keyup.enter.prevent /> - <div ref="userStatusForm" class="form-group position-relative m-0"> - <div class="input-group gl-mb-5"> - <span class="input-group-prepend"> - <emoji-picker - dropdown-class="gl-h-full" - toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - boundary="viewport" - :right="false" - @click="setEmoji" + <template #prepend> + <emoji-picker + dropdown-class="gl-h-full" + toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + boundary="viewport" + :right="false" + @click="setEmoji" + > + <template #button-content> + <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span> + <span + v-show="noEmoji" + class="js-no-emoji-placeholder no-emoji-placeholder position-relative" > - <template #button-content> - <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span> - <span - v-show="noEmoji" - class="js-no-emoji-placeholder no-emoji-placeholder position-relative" - > - <gl-icon name="slight-smile" class="award-control-icon-neutral" /> - <gl-icon name="smiley" class="award-control-icon-positive" /> - <gl-icon name="smile" class="award-control-icon-super-positive" /> - </span> - </template> - </emoji-picker> - </span> - <input - ref="statusMessageField" - v-model="message" - :placeholder="s__('SetStatusModal|What\'s your status?')" - type="text" - class="form-control form-control input-lg js-status-message-field" - name="user[status][message]" - @keyup="setDefaultEmoji" - @keyup.enter.prevent - /> - <span v-show="isDirty" class="input-group-append"> - <button - v-gl-tooltip.bottom - :title="s__('SetStatusModal|Clear status')" - :aria-label="s__('SetStatusModal|Clear status')" - name="button" - type="button" - class="js-clear-user-status-button clear-user-status btn" - @click="clearStatusInputs()" - > - <gl-icon name="close" /> - </button> - </span> - </div> - <div class="form-group"> - <div class="gl-display-flex"> - <gl-form-checkbox - v-model="availability" - data-testid="user-availability-checkbox" - class="gl-mb-0" - > - <span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span> - </gl-form-checkbox> - </div> - <div class="gl-display-flex"> - <span class="gl-text-gray-600 gl-ml-5"> - {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }} + <gl-icon name="slight-smile" class="award-control-icon-neutral" /> + <gl-icon name="smiley" class="award-control-icon-positive" /> + <gl-icon name="smile" class="award-control-icon-super-positive" /> </span> - </div> - </div> - <div class="form-group"> - <div class="gl-display-flex gl-align-items-baseline"> - <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span> - <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown"> - <gl-dropdown-item - v-for="after in $options.statusTimeRanges" - :key="after.name" - :data-testid="after.name" - @click="setClearStatusAfter(after.label)" - >{{ after.label }}</gl-dropdown-item - > - </gl-dropdown> - </div> - <div - v-if="currentClearStatusAfter.length" - class="gl-mt-3 gl-text-gray-400 gl-font-sm" - data-testid="clear-status-at-message" + </template> + </emoji-picker> + </template> + <template v-if="isDirty" #append> + <gl-button + v-gl-tooltip.bottom + :title="s__('SetStatusModal|Clear status')" + :aria-label="s__('SetStatusModal|Clear status')" + icon="close" + class="js-clear-user-status-button" + @click="clearStatusInputs" + /> + </template> + </gl-form-input-group> + + <gl-form-checkbox + v-model="availability" + class="gl-mb-5" + data-testid="user-availability-checkbox" + > + {{ s__('SetStatusModal|Busy') }} + <template #help> + {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }} + </template> + </gl-form-checkbox> + + <div class="form-group"> + <div class="gl-display-flex gl-align-items-baseline"> + <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span> + <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown"> + <gl-dropdown-item + v-for="after in $options.statusTimeRanges" + :key="after.name" + :data-testid="after.name" + @click="setClearStatusAfter(after.label)" + >{{ after.label }}</gl-dropdown-item > - {{ clearStatusAfterMessage }} - </div> - </div> + </gl-dropdown> + </div> + <div + v-if="currentClearStatusAfter.length" + class="gl-mt-3 gl-text-gray-400 gl-font-sm" + data-testid="clear-status-at-message" + > + {{ clearStatusAfterMessage }} </div> </div> </gl-modal> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index c20dd3b677d..d17c8a123d5 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -72,9 +72,12 @@ export default { }, }, computed: { + isMergeRequest() { + return this.issuableType === IssuableType.MergeRequest; + }, cannotMerge() { const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; - return this.issuableType === IssuableType.MergeRequest && !canMerge; + return this.isMergeRequest && !canMerge; }, tooltipTitle() { const { name = '', availability = '' } = this.user; @@ -86,6 +89,10 @@ export default { }); }, tooltipOption() { + if (this.isMergeRequest) { + return null; + } + return { container: 'body', placement: this.tooltipPlacement, @@ -96,6 +103,10 @@ export default { return this.user.web_url || this.user.webUrl; }, assigneeId() { + if (this.isMergeRequest) { + return null; + } + return isGid(this.user.id) ? getIdFromGraphQLId(this.user.id) : this.user.id; }, }, @@ -105,6 +116,7 @@ export default { <template> <!-- must be `d-inline-block` or parent flex-basis causes width issues --> <gl-link + v-gl-tooltip="tooltipOption" :href="assigneeUrl" :title="tooltipTitle" :data-user-id="assigneeId" 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 699d1bebea1..5f1808ff4da 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,10 +1,11 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import createFlash from '~/flash'; import eventHub from '~/sidebar/event_hub'; +import toast from '~/vue_shared/plugins/global_toast'; import editForm from './edit_form.vue'; export default { @@ -27,6 +28,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + Outside, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -84,6 +86,11 @@ export default { locked: !this.isLocked, fullPath: this.fullPath, }) + .then(() => { + if (this.isMergeRequest) { + toast(this.isLocked ? __('Merge request locked.') : __('Merge request unlocked.')); + } + }) .catch(() => { const flashMessage = __( 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', @@ -96,6 +103,9 @@ export default { this.isLoading = false; }); }, + closeForm() { + this.isLockDialogOpen = false; + }, }, }; </script> @@ -142,6 +152,7 @@ export default { <div class="value sidebar-item-value hide-collapsed"> <edit-form v-if="isLockDialogOpen" + v-outside="closeForm" data-testid="edit-form" :is-locked="isLocked" :issuable-display-name="issuableDisplayName" diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index 36a08482e69..c9b0a4ae2b3 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -68,10 +68,9 @@ export default { <template> <!-- must be `d-inline-block` or parent flex-basis causes width issues --> <gl-link + v-gl-tooltip="tooltipOption" :href="reviewerUrl" :title="tooltipTitle" - :data-user-id="user.id" - data-placement="left" class="gl-display-inline-block js-user-link" > <!-- use d-flex so that slot can be appropriately styled --> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 1bafa845665..7662d645dd9 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -6,6 +6,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import toast from '~/vue_shared/plugins/global_toast'; import { subscribedQueries, Tracking } from '~/sidebar/constants'; const ICON_ON = 'notifications'; @@ -140,6 +141,10 @@ export default { message: errors[0], }); } + + if (this.isMergeRequest) { + toast(subscribed ? __('Notifications turned on.') : __('Notifications turned off.')); + } }, ) .catch(() => { diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index ff3fb4aae6b..127e3a3c610 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -2,7 +2,7 @@ import produce from 'immer'; import VueApollo from 'vue-apollo'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; -import { temporaryConfig } from '~/work_items/graphql/provider'; +import { temporaryConfig, resolvers as workItemResolvers } from '~/work_items/graphql/provider'; const resolvers = { Mutation: { @@ -13,6 +13,7 @@ const resolvers = { }); cache.writeQuery({ query: getIssueStateQuery, data }); }, + ...workItemResolvers.Mutation, }, }; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index bb40ac14438..3f82fe5ce87 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -73,12 +73,14 @@ function mountSidebarToDoWidget() { props: { fullPath: projectPath, issuableId: - isInIssuePage() || isInDesignPage() + isInIssuePage() || isInIncidentPage() || isInDesignPage() ? convertToGraphQLId(TYPE_ISSUE, id) : convertToGraphQLId(TYPE_MERGE_REQUEST, id), issuableIid: iid, issuableType: - isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, }, }), }); diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index ea170203576..05268a5c89c 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -33,6 +33,7 @@ export default class SidebarService { SidebarService.singleton = this; } + // eslint-disable-next-line no-constructor-return return SidebarService.singleton; } diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 7df901577b8..4df00903ab6 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -11,6 +11,8 @@ export default class SidebarMediator { if (!SidebarMediator.singleton) { this.initSingleton(options); } + + // eslint-disable-next-line no-constructor-return return SidebarMediator.singleton; } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index ca85ee7fd94..971e2a15c68 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -4,6 +4,7 @@ export default class SidebarStore { this.initSingleton(options); } + // eslint-disable-next-line no-constructor-return return SidebarStore.singleton; } diff --git a/app/assets/javascripts/surveys/components/satisfaction_rate.vue b/app/assets/javascripts/surveys/components/satisfaction_rate.vue new file mode 100644 index 00000000000..d83de56169b --- /dev/null +++ b/app/assets/javascripts/surveys/components/satisfaction_rate.vue @@ -0,0 +1,71 @@ +<script> +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'SatisfactionRate', + components: { + GlButton, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + unhappy: s__('Surveys|Unhappy'), + delighted: s__('Surveys|Delighted'), + }, + grades: [ + { + title: s__('Surveys|Unhappy'), + icon: 'face-unhappy', + value: 1, + }, + { + title: s__('Surveys|Sad'), + icon: 'slight-frown', + value: 2, + }, + { + title: s__('Surveys|Neutral'), + icon: 'face-neutral', + value: 3, + }, + { + title: s__('Surveys|Happy'), + icon: 'slight-smile', + value: 4, + }, + { + title: s__('Surveys|Delighted'), + icon: 'smiley', + value: 5, + }, + ], +}; +</script> + +<template> + <div> + <ul class="gl-list-style-none gl-display-flex gl-p-0 gl-m-0 gl-justify-content-space-between"> + <li v-for="grade in $options.grades" :key="grade.value"> + <gl-button + v-gl-tooltip="grade.title" + class="gl-p-2!" + variant="default" + category="tertiary" + :aria-label="grade.title" + @click="$emit('rate', grade.value)" + > + <gl-icon class="gl-vertical-align-top" :name="grade.icon" :size="24" /> + </gl-button> + </li> + </ul> + <div + class="gl-display-flex gl-justify-content-space-between gl-pt-3 gl-text-gray-500 gl-font-sm" + > + <div>{{ $options.i18n.unhappy }}</div> + <div>{{ $options.i18n.delighted }}</div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.js b/app/assets/javascripts/surveys/merge_request_experience/app.js new file mode 100644 index 00000000000..ea5d8aef3c5 --- /dev/null +++ b/app/assets/javascripts/surveys/merge_request_experience/app.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue'; +import createDefaultClient from '~/lib/graphql'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); +Vue.use(VueApollo); + +export const startMrSurveyApp = () => { + let channel = null; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + const app = new Vue({ + apolloProvider, + data() { + return { + hidden: false, + }; + }, + render(h) { + if (this.hidden) return null; + return h(MergeRequestExperienceSurveyApp, { + on: { + close: () => { + channel?.postMessage('close'); + app.hidden = true; + }, + rate: () => { + channel?.postMessage('close'); + }, + }, + }); + }, + }); + + app.$mount('#js-mr-experience-survey'); + + if (window.BroadcastChannel) { + channel = new BroadcastChannel('mr_survey'); + channel.addEventListener('message', ({ data }) => { + if (data === 'close') { + app.hidden = true; + channel.close(); + channel = null; + } + }); + } +}; diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue new file mode 100644 index 00000000000..85eed6ae82a --- /dev/null +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -0,0 +1,169 @@ +<script> +import { GlButton, GlSprintf, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui'; +import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg'; +import { s__, __ } from '~/locale'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; +import Tracking from '~/tracking'; + +const steps = [ + { + label: 'overall', + question: s__('MrSurvey|Overall, how satisfied are you with merge requests?'), + }, + { + label: 'performance', + question: s__( + 'MrSurvey|How satisfied are you with %{strongStart}speed/performance%{strongEnd} of merge requests?', + ), + }, +]; + +export default { + name: 'MergeRequestExperienceSurveyApp', + components: { + UserCalloutDismisser, + GlSprintf, + GlButton, + SatisfactionRate, + }, + directives: { + safeHtml: GlSafeHtmlDirective, + tooltip: GlTooltipDirective, + }, + mixins: [Tracking.mixin()], + i18n: { + survey: s__('MrSurvey|Merge request experience survey'), + close: __('Close'), + legal: s__( + 'MrSurvey|By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the %{linkStart}GitLab Privacy Policy%{linkEnd}.', + ), + thanks: s__('MrSurvey|Thank you for your feedback!'), + }, + gitlabLogo, + data() { + return { + visible: false, + stepIndex: 0, + }; + }, + computed: { + step() { + return steps[this.stepIndex]; + }, + }, + mounted() { + document.addEventListener('keyup', this.handleKeyup); + }, + destroyed() { + document.removeEventListener('keyup', this.handleKeyup); + }, + methods: { + onQueryLoaded({ shouldShowCallout }) { + this.visible = shouldShowCallout; + if (!this.visible) this.$emit('close'); + }, + onRate(event) { + this.$emit('rate'); + this.track('survey:mr_experience', { + label: this.step.label, + value: event, + }); + this.stepIndex += 1; + if (!this.step) { + setTimeout(() => { + this.$emit('close'); + }, 5000); + } + }, + handleKeyup(e) { + if (e.key !== 'Escape') return; + this.$emit('close'); + this.$refs.dismisser?.dismiss(); + }, + }, +}; +</script> + +<template> + <user-callout-dismisser + ref="dismisser" + feature-name="mr_experience_survey" + @queryResult.once="onQueryLoaded" + > + <template #default="{ dismiss }"> + <aside + class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5" + :aria-label="$options.i18n.survey" + > + <transition name="survey-slide-up"> + <div + v-if="visible" + class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base" + > + <gl-button + v-tooltip="$options.i18n.close" + :aria-label="$options.i18n.close" + variant="default" + category="tertiary" + class="gl-top-4 gl-right-3 gl-absolute" + icon="close" + @click=" + dismiss(); + $emit('close'); + " + /> + <div + v-if="stepIndex === 0" + class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm" + role="note" + > + <p class="gl-m-0"> + <gl-sprintf :message="$options.i18n.legal"> + <template #link="{ content }"> + <a + class="gl-text-decoration-underline gl-text-gray-500" + href="https://about.gitlab.com/privacy/" + target="_blank" + rel="noreferrer nofollow" + v-text="content" + ></a> + </template> + </gl-sprintf> + </p> + </div> + <div class="gl-relative"> + <div class="gl-absolute"> + <div + v-safe-html="$options.gitlabLogo" + aria-hidden="true" + class="mr-experience-survey-logo" + ></div> + </div> + </div> + <section v-if="step"> + <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7"> + <gl-sprintf :message="step.question"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <satisfaction-rate + aria-labelledby="mr_survey_question" + class="gl-mt-5" + @rate=" + dismiss(); + onRate($event); + " + /> + </section> + <section v-else class="gl-px-7"> + {{ $options.i18n.thanks }} + </section> + </div> + </transition> + </aside> + </template> + </user-callout-dismisser> +</template> diff --git a/app/assets/javascripts/surveys/merge_request_experience/index.js b/app/assets/javascripts/surveys/merge_request_experience/index.js new file mode 100644 index 00000000000..6073bde56c0 --- /dev/null +++ b/app/assets/javascripts/surveys/merge_request_experience/index.js @@ -0,0 +1,23 @@ +import { Tracker } from '~/tracking/tracker'; + +const MR_SURVEY_WAIT_DURATION = 10000; + +const broadcastNotificationVisible = () => { + // We don't want to clutter up the UI by displaying the survey when broadcast message(s) + // are visible as well. + return Boolean(document.querySelector('.broadcast-notification-message')); +}; + +export const initMrExperienceSurvey = () => { + if (!gon.features?.mrExperienceSurvey) return; + if (!gon.current_user_id) return; + if (!Tracker.enabled()) return; + if (broadcastNotificationVisible()) return; + + setTimeout(() => { + // eslint-disable-next-line promise/catch-or-return + import('./app').then(({ startMrSurveyApp }) => { + startMrSurveyApp(); + }); + }, MR_SURVEY_WAIT_DURATION); +}; diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js index 90c9a89d652..0c227ab7afc 100644 --- a/app/assets/javascripts/tabs/constants.js +++ b/app/assets/javascripts/tabs/constants.js @@ -14,3 +14,6 @@ export const ATTR_ROLE = 'role'; export const ATTR_TABINDEX = 'tabindex'; export const TAB_SHOWN_EVENT = 'gl-tab-shown'; + +export const HISTORY_TYPE_HASH = 'hash'; +export const ALLOWED_HISTORY_TYPES = [HISTORY_TYPE_HASH]; diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js index 44937e593e0..9230b7361a5 100644 --- a/app/assets/javascripts/tabs/index.js +++ b/app/assets/javascripts/tabs/index.js @@ -1,4 +1,5 @@ import { uniqueId } from 'lodash'; +import { historyReplaceState, NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils'; import { ACTIVE_TAB_CLASSES, ATTR_ROLE, @@ -12,9 +13,11 @@ import { KEY_CODE_RIGHT, KEY_CODE_DOWN, TAB_SHOWN_EVENT, + HISTORY_TYPE_HASH, + ALLOWED_HISTORY_TYPES, } from './constants'; -export { TAB_SHOWN_EVENT }; +export { TAB_SHOWN_EVENT, HISTORY_TYPE_HASH }; /** * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and @@ -88,9 +91,13 @@ export class GlTabsBehavior { /** * Create a GlTabsBehavior instance. * - * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper. + * @param {HTMLElement} el - The element created by the Rails `gl_tabs_nav` helper. + * @param {Object} [options] + * @param {'hash' | null} [options.history=null] - Sets the type of routing GlTabs will use when navigating between tabs. + * 'hash': Updates the URL hash with the current tab ID. + * null: No routing mechanism will be used. */ - constructor(el) { + constructor(el, { history = null } = {}) { if (!el) { throw new Error('Cannot instantiate GlTabsBehavior without an element'); } @@ -100,8 +107,11 @@ export class GlTabsBehavior { this.tabs = this.getTabs(); this.activeTab = null; + this.history = ALLOWED_HISTORY_TYPES.includes(history) ? history : null; + this.setAccessibilityAttrs(); this.bindEvents(); + if (this.history === HISTORY_TYPE_HASH) this.loadInitialTab(); } setAccessibilityAttrs() { @@ -128,6 +138,7 @@ export class GlTabsBehavior { tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id); } + tabPanel.classList.add(NO_SCROLL_TO_HASH_CLASS); tabPanel.setAttribute(ATTR_ROLE, 'tabpanel'); tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id); }); @@ -164,6 +175,11 @@ export class GlTabsBehavior { }); } + loadInitialTab() { + const tab = this.tabList.querySelector(`a[href="${CSS.escape(window.location.hash)}"]`); + this.activateTab(tab || this.activeTab); + } + activatePreviousTab() { const currentTabIndex = this.tabs.indexOf(this.activeTab); @@ -216,6 +232,7 @@ export class GlTabsBehavior { const tabPanel = this.getPanelForTab(tabToActivate); tabPanel.classList.add(ACTIVE_PANEL_CLASS); + if (this.history === HISTORY_TYPE_HASH) historyReplaceState(tabToActivate.getAttribute('href')); this.activeTab = tabToActivate; this.dispatchTabShown(tabToActivate, tabPanel); diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index aedf5b6acfe..a54a198faed 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -7,6 +7,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; import '~/behaviors/markdown/render_gfm'; +import { trackTrialAcceptTerms } from '~/google_tag_manager'; export default { name: 'TermsApp', @@ -73,6 +74,7 @@ export default { this.setScrollableViewportHeight(); event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); }, + trackTrialAcceptTerms, }, }; </script> @@ -99,7 +101,13 @@ export default { <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> </form> - <form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept"> + <form + v-if="permissions.canAccept" + class="gl-ml-3" + method="post" + :action="paths.accept" + @submit="trackTrialAcceptTerms" + > <gl-button type="submit" variant="confirm" diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index a3615eab26f..3356cada58a 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -117,7 +117,7 @@ function launchPopover(el, mountPopover) { mountPopover(popoverInstance); } -const userLinkSelector = 'a.js-user-link, a.gfm-project_member'; +const userLinkSelector = 'a.js-user-link[data-user], a.js-user-link[data-user-id]'; const getUserLinkNode = (node) => node.closest(userLinkSelector); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index e1e5cc565c6..94b4ee77e7e 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -35,7 +35,7 @@ function UsersSelect(currentUser, els, options = {}) { } } - const { handleClick, autoAssignToMe } = options; + const { handleClick } = options; const userSelect = this; $els.each((i, dropdown) => { @@ -172,7 +172,10 @@ function UsersSelect(currentUser, els, options = {}) { }); }; - const onAssignToMeClick = () => { + $assignToMeLink.on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); + if ($dropdown.data('multiSelect')) { assignYourself(); checkMaxSelect(); @@ -191,19 +194,8 @@ function UsersSelect(currentUser, els, options = {}) { .text(gon.current_user_fullname) .removeClass('is-default'); } - }; - - $assignToMeLink.on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - onAssignToMeClick(); }); - if (autoAssignToMe) { - $assignToMeLink.hide(); - onAssignToMeClick(); - } - $block.on('click', '.js-assign-yourself', (e) => { e.preventDefault(); return assignTo(userSelect.currentUser.id); @@ -249,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) { )} <% } %>`, ); assigneeTemplate = template( - `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + `<% if (username) { %> <a class="author-link gl-font-weight-bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', closingTag: '</a>', @@ -585,7 +577,7 @@ function UsersSelect(currentUser, els, options = {}) { )}</a></li>`; } else { // 0 margin, because it's now handled by a wrapper - img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; + img = `<img src='${avatar}' class='avatar avatar-inline gl-m-0!' width='32' />`; } return userSelect.renderRow( @@ -806,9 +798,9 @@ UsersSelect.prototype.renderRow = function ( : user.name; return ` <li data-user-id=${user.id}> - <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}> + <a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}> ${this.renderRowAvatar(issuableType, user, img)} - <span class="d-flex flex-column overflow-hidden"> + <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> <strong class="dropdown-menu-user-full-name gl-font-weight-bold"> ${escape(name)} </strong> @@ -836,7 +828,7 @@ UsersSelect.prototype.renderRowAvatar = function (issuableType, user, img) { ? spriteIcon('warning-solid', 's12 merge-icon') : ''; - return `<span class="position-relative mr-2"> + return `<span class="gl-relative gl-mr-3"> ${img} ${mergeIcon} </span>`; @@ -851,7 +843,7 @@ UsersSelect.prototype.renderApprovalRules = function (elsClassName, approvalRule const [rule] = approvalRules; const countText = sprintf(__('(+%{count} rules)'), { count }); - const renderApprovalRulesCount = count > 1 ? `<span class="ml-1">${countText}</span>` : ''; + const renderApprovalRulesCount = count > 1 ? `<span class="gl-ml-2">${countText}</span>` : ''; const ruleName = rule.rule_type === 'code_owner' ? __('Code Owner') : escape(rule.name); return `<div class="gl-display-flex gl-font-sm"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue index 655ceb5f700..b76d5d90ead 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; export default { @@ -8,6 +8,9 @@ export default { GlDropdown, GlDropdownItem, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { widget: { type: String, @@ -19,6 +22,12 @@ export default { default: () => [], }, }, + data: () => { + return { + timeout: null, + updatingTooltip: false, + }; + }, computed: { dropdownLabel() { return sprintf(__('%{widget} options'), { widget: this.widget }); @@ -27,9 +36,29 @@ export default { methods: { onClickAction(action) { this.$emit('clickedAction', action); + if (action.onClick) { action.onClick(); } + + if (action.tooltipOnClick) { + this.updatingTooltip = true; + this.$root.$emit('bv::show::tooltip', action.id); + + clearTimeout(this.timeout); + + this.timeout = setTimeout(() => { + this.updatingTooltip = false; + this.$root.$emit('bv::hide::tooltip', action.id); + }, 1000); + } + }, + setTooltip(btn) { + if (this.updatingTooltip && btn.tooltipOnClick) { + return btn.tooltipOnClick; + } + + return btn.tooltipText; }, }, }; @@ -55,6 +84,7 @@ export default { :key="index" :href="btn.href" :target="btn.target" + :data-clipboard-text="btn.dataClipboardText" @click="onClickAction(btn)" > {{ btn.text }} @@ -63,15 +93,20 @@ export default { <template v-if="tertiaryButtons.length"> <gl-button v-for="(btn, index) in tertiaryButtons" + :id="btn.id" :key="index" + v-gl-tooltip.hover + :title="setTooltip(btn)" :href="btn.href" :target="btn.target" :class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }" + :data-clipboard-text="btn.dataClipboardText" + :icon="btn.icon" + :data-testid="btn.testId || 'extension-actions-button'" + :variant="btn.variant || 'confirm'" category="tertiary" - variant="confirm" size="small" class="gl-display-none gl-md-display-block gl-float-left" - data-testid="extension-actions-button" @click="onClickAction(btn)" > {{ btn.text }} 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 4ba620da00a..410331004e4 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 @@ -194,6 +194,24 @@ export default { poll.makeRequest(); }, + initExtensionFullDataPolling() { + const poll = new Poll({ + resource: { + fetchData: () => this.fetchFullData(this), + }, + method: 'fetchData', + successCallback: (response) => { + this.headerCheck(response, (data) => { + this.setFullData(data); + }); + }, + errorCallback: (e) => { + this.setExpandedError(e); + }, + }); + + poll.makeRequest(); + }, headerCheck(response, callback) { const headers = normalizeHeaders(response.headers); @@ -220,6 +238,10 @@ export default { }); } }, + setFullData(data) { + this.loadingState = null; + this.fullData = data.map((x, i) => ({ id: i, ...x })); + }, setCollapsedData(data) { this.collapsedData = data; this.loadingState = null; @@ -229,21 +251,26 @@ export default { Sentry.captureException(e); }, + setExpandedError(e) { + this.loadingState = LOADING_STATES.expandedError; + Sentry.captureException(e); + }, loadAllData() { if (this.hasFullData) return; this.loadingState = LOADING_STATES.expandedLoading; - this.fetchFullData(this) - .then((data) => { - this.loadingState = null; - this.fullData = data.map((x, i) => ({ id: i, ...x })); - }) - .catch((e) => { - this.loadingState = LOADING_STATES.expandedError; - - Sentry.captureException(e); - }); + if (this.$options.enableExpandedPolling) { + this.initExtensionFullDataPolling(); + } else { + this.fetchFullData(this) + .then((data) => { + this.setFullData(data); + }) + .catch((e) => { + this.setExpandedError(e); + }); + } }, appear(index) { if (index === this.fullData.length - 1) { @@ -288,6 +315,7 @@ export default { @mouseup="onRowMouseUp" > <status-icon + :level="1" :name="$options.label || $options.name" :is-loading="isLoadingSummary" :icon-name="statusIconName" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index f4fcf4c9571..7e329399957 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -20,6 +20,7 @@ export const registerExtension = (extension) => { i18n: extension.i18n, expandEvent: extension.expandEvent, enablePolling: extension.enablePolling, + enableExpandedPolling: extension.enableExpandedPolling, modalComponent: extension.modalComponent, computed: { ...extension.props.reduce( @@ -35,7 +36,7 @@ export const registerExtension = (extension) => { (acc, computedKey) => ({ ...acc, // Making the computed property a method allows us to pass in arguments - // this allows for each computed property to recieve some data + // this allows for each computed property to receive some data [computedKey]() { return extension.computed[computedKey]; }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue index bb626c9adba..dc748ba44f2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -9,6 +9,11 @@ export default { GlIcon, }, props: { + level: { + type: Number, + required: false, + default: 0, + }, name: { type: String, required: false, @@ -27,7 +32,7 @@ export default { size: { type: Number, required: false, - default: 16, + default: 12, }, }, computed: { @@ -44,8 +49,8 @@ export default { <div :class="[ $options.EXTENSION_ICON_CLASS[iconName], - { 'mr-widget-extension-icon': !isLoading && size === 16 }, - { 'gl-p-2': isLoading || size === 16 }, + { 'mr-widget-extension-icon gl-w-6': !isLoading && level === 1 }, + { 'gl-p-2': isLoading || level === 1 }, ]" class="gl-rounded-full gl-mr-3 gl-relative gl-p-2" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js index aec3a35f37c..b551cd2fd60 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js @@ -65,7 +65,7 @@ function simplifyWidgetName(componentName) { function baseRedisEventName(extensionName) { const redisEventName = extensionName.replace(/([A-Z])/g, '_$1').toLowerCase(); - return `i_merge_request_widget_${redisEventName}`; + return `i_code_review_merge_request_widget_${redisEventName}`; } function whenable(bus) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 701ef89304c..a45823823f0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -4,7 +4,7 @@ import StatusIcon from '../mr_widget_status_icon.vue'; export default { i18n: { - approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'), + approvalNeeded: s__('mrWidget|Merge blocked: all required approvals must be given.'), blockingMergeRequests: s__( 'mrWidget|Merge blocked: you can only merge after the above items are resolved.', ), diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 3511fffcfbb..59767eb2e6e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -3,6 +3,7 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import toast from '~/vue_shared/plugins/global_toast'; import simplePoll from '~/lib/utils/simple_poll'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; @@ -120,13 +121,15 @@ export default { .poll() .then((res) => res.data) .then((res) => { - if (res.rebase_in_progress) { + if (res.rebase_in_progress || res.should_be_rebased) { continuePolling(); } else { this.isMakingRequest = false; if (res.merge_error && res.merge_error.length) { this.rebasingError = res.merge_error; + } else { + toast(__('Rebase completed')); } eventHub.$emit('MRWidgetRebaseSuccess'); @@ -218,6 +221,17 @@ export default { > {{ __('Rebase') }} </gl-button> + <gl-button + v-if="glFeatures.restructuredMrWidget && showRebaseWithoutCi" + :loading="isMakingRequest" + variant="confirm" + size="small" + category="secondary" + data-testid="rebase-without-ci-button" + @click="rebaseWithoutCi" + > + {{ __('Rebase without pipeline') }} + </gl-button> </div> </div> </template> 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.js index f14e80d0be6..22e907f7e48 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js @@ -40,6 +40,9 @@ export default { return numOfResults === 0 ? successText : warningText; }, + shouldCollapse() { + return this.collapsedData?.summary?.errored > 0; + }, fetchCollapsedData() { return axios.get(this.accessibilityReportPath); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index a7aaa2f4476..ca95e1b5de8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -32,7 +32,7 @@ export default { // Status icon to be used next to the summary text // Receives the collapsed data as an argument statusIcon(count) { - return EXTENSION_ICONS.warning; + return EXTENSION_ICONS.failed; }, // Tertiary action buttons that will take the user elsewhere // in the GitLab app diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js index 23f14bea4e1..4994a0bcbeb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js @@ -7,6 +7,8 @@ export const TESTS_FAILED_STATUS = 'failed'; export const ERROR_STATUS = 'error'; export const i18n = { + copyFailedSpecs: s__('Reports|Copy failed tests'), + copyFailedSpecsTooltip: s__('Reports|Copy failed test names to run locally'), label: s__('Reports|Test summary'), loading: s__('Reports|Test summary results are loading'), error: s__('Reports|Test summary failed to load results'), diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js index 164bda33b95..c74445a5b80 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js @@ -1,4 +1,5 @@ import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import { EXTENSION_ICONS } from '../../constants'; @@ -19,6 +20,20 @@ export default { props: ['testResultsPath', 'headBlobPath', 'pipeline'], modalComponent: TestCaseDetails, computed: { + failedTestNames() { + if (!this.collapsedData?.suites) { + return ''; + } + + const newFailures = this.collapsedData?.suites.flatMap((suite) => [suite.new_failures || []]); + const fileNames = newFailures.flatMap((newFailure) => { + return newFailure.map((failure) => { + return failure.file; + }); + }); + + return fileNames.join(' '); + }, summary(data) { if (data.parsingInProgress) { return this.$options.i18n.loading; @@ -32,9 +47,6 @@ export default { }; }, statusIcon(data) { - if (data.parsingInProgress) { - return null; - } if (data.status === TESTS_FAILED_STATUS) { return EXTENSION_ICONS.warning; } @@ -44,30 +56,46 @@ export default { return EXTENSION_ICONS.success; }, tertiaryButtons() { - return [ - { - text: this.$options.i18n.fullReport, - href: `${this.pipeline.path}/test_report`, - target: '_blank', - fullReport: true, - }, - ]; + const actionButtons = []; + + if (this.failedTestNames().length > 0) { + actionButtons.push({ + dataClipboardText: this.failedTestNames(), + id: uniqueId('copy-to-clipboard'), + icon: 'copy-to-clipboard', + testId: 'copy-failed-specs-btn', + text: this.$options.i18n.copyFailedSpecs, + tooltipText: this.$options.i18n.copyFailedSpecsTooltip, + tooltipOnClick: __('Copied'), + }); + } + + actionButtons.push({ + text: this.$options.i18n.fullReport, + href: `${this.pipeline.path}/test_report`, + target: '_blank', + fullReport: true, + testId: 'full-report-link', + }); + + return actionButtons; }, }, methods: { fetchCollapsedData() { - return axios.get(this.testResultsPath).then((res) => { - const { data = {}, status } = res; + return axios.get(this.testResultsPath).then((response) => { + const { data = {}, status } = response; + const { suites = [], summary = {} } = data; return { - ...res, + ...response, data: { - hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS), + hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS), parsingInProgress: status === 204, ...data, summary: { - recentlyFailed: countRecentlyFailedTests(data.suites), - ...data.summary, + recentlyFailed: countRecentlyFailedTests(suites), + ...summary, }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js index 7bbcb0cd04a..4ffd06de61f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { i18n } from './constants'; const textBuilder = (results, boldNumbers = false) => { @@ -65,6 +66,11 @@ export const reportSubTextBuilder = ({ suite_errors, summary }) => { }; export const countRecentlyFailedTests = (subject) => { + // return 0 count if subject is [], null, or undefined + if (isEmpty(subject)) { + return 0; + } + // handle either a single report or an array of reports const reports = !subject.length ? [subject] : subject; @@ -73,10 +79,10 @@ export const countRecentlyFailedTests = (subject) => { return ( [report.new_failures, report.existing_failures, report.resolved_failures] // only count tests which have failed more than once - .map( - (failureArray) => - failureArray.filter((failure) => failure.recent_failures?.count > 1).length, - ) + .map((failureArray) => { + if (!failureArray) return 0; + return failureArray.filter((failure) => failure.recent_failures?.count > 1).length; + }) .reduce((total, count) => total + count, 0) ); }) 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 c68437b9879..3e0ac236fdf 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 @@ -221,8 +221,11 @@ export default { formattedHumanAccess() { return (this.mr.humanAccess || '').toLowerCase(); }, + hasMergeError() { + return this.mr.mergeError && this.state !== 'closed'; + }, hasAlerts() { - return this.mr.mergeError || this.showMergePipelineForkWarning; + return this.hasMergeError || this.showMergePipelineForkWarning; }, shouldShowExtension() { return ( @@ -574,7 +577,12 @@ export default { /> <div class="mr-section-container mr-widget-workflow"> <div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container"> - <mr-widget-alert-message v-if="mr.mergeError" type="danger" dismissible> + <mr-widget-alert-message + v-if="hasMergeError" + type="danger" + dismissible + data-testid="merge_error" + > <span v-safe-html="mergeError"></span> </mr-widget-alert-message> <mr-widget-alert-message diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index 25c44beaf18..981c667f27a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -6,11 +6,13 @@ query getState($projectPath: ID!, $iid: String!) { mergeRequest(iid: $iid) { id autoMergeEnabled + availableAutoMergeStrategies commitCount conflicts diffHeadSha mergeError mergeStatus + mergeable mergeableDiscussionsState headPipeline { id diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue index 322ea64eb7e..f2c27cf611e 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -61,7 +61,7 @@ export default { }, }); - return document.dispatchEvent(headerTodoEvent); + document.dispatchEvent(headerTodoEvent); }, addToDo() { this.isUpdating = true; @@ -75,9 +75,10 @@ export default { }) .then(({ data: { errors = [] } }) => { if (errors[0]) { - return this.throwError(errors[0]); + this.throwError(errors[0]); + return; } - return this.updateToDoCount(true); + this.updateToDoCount(true); }) .catch(() => { this.throwError(); @@ -98,9 +99,10 @@ export default { }) .then(({ data: { errors = [] } }) => { if (errors[0]) { - return this.throwError(errors[0]); + this.throwError(errors[0]); + return; } - return this.updateToDoCount(false); + this.updateToDoCount(false); }) .catch(() => { this.throwError(); diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue index 92817d5fa70..70cac061ca6 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue @@ -14,12 +14,12 @@ export default { </script> <template> - <div> + <div class="color-item"> <span - class="dropdown-label-box gl-flex-shrink-0 gl-top-1 gl-mr-0" + class="dropdown-label-box color-item-color" data-testid="color-item" :style="{ backgroundColor: color }" ></span> - <span class="hide-collapsed">{{ title }}</span> + <span class="color-item-text">{{ title }}</span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue index 6b79883d76b..a88a4ca5cb8 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue @@ -1,4 +1,5 @@ <script> +import { isString } from 'lodash'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; @@ -52,13 +53,23 @@ export default { required: false, default: s__('ColorWidget|Assign epic color'), }, + defaultColor: { + type: Object, + required: false, + validator(value) { + return isString(value?.color) && isString(value?.title); + }, + default() { + return { + color: '', + title: '', + }; + }, + }, }, data() { return { - issuableColor: { - color: '', - title: '', - }, + issuableColor: this.defaultColor, colorUpdateInProgress: false, oldIid: null, sidebarExpandedOnClick: false, @@ -106,9 +117,9 @@ export default { methods: { handleDropdownClose(color) { if (this.iid !== '') { - this.updateSelectedColor(this.getUpdateVariables(color)); + this.updateSelectedColor(color); } else { - this.$emit('updateSelectedColor', color); + this.$emit('updateSelectedColor', { color }); } this.collapseEditableItem(); @@ -129,13 +140,15 @@ export default { color: color.color, }; }, - updateSelectedColor(inputVariables) { + updateSelectedColor(color) { this.colorUpdateInProgress = true; + const input = this.getUpdateVariables(color); + this.$apollo .mutate({ mutation: updateEpicColorMutation, - variables: { input: inputVariables }, + variables: { input }, }) .then(({ data }) => { if (data.updateIssuableColor?.errors?.length) { @@ -144,7 +157,7 @@ export default { this.$emit('updateSelectedColor', { id: data.updateIssuableColor?.issuable?.id, - color: data.updateIssuableColor?.issuable?.color, + color, }); }) .catch((error) => diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js index c70785abd1e..701ac71d755 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js @@ -1,4 +1,4 @@ -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export const COLOR_WIDGET_COLOR = s__('ColorWidget|Color'); @@ -7,7 +7,7 @@ export const DROPDOWN_VARIANT = { Embedded: 'embedded', }; -export const DEFAULT_COLOR = { title: __('SuggestedColors|Blue'), color: '#1068bf' }; +export const DEFAULT_COLOR = { title: s__('SuggestedColors|Blue'), color: '#1068bf' }; export const ISSUABLE_COLORS = [ DEFAULT_COLOR, diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue index 4eb1d3d08ca..84da6e1437e 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue @@ -1,11 +1,13 @@ <script> import { GlDropdown } from '@gitlab/ui'; +import ColorItem from './color_item.vue'; import DropdownContentsColorView from './dropdown_contents_color_view.vue'; import DropdownHeader from './dropdown_header.vue'; import { isDropdownVariantSidebar } from './utils'; export default { components: { + ColorItem, DropdownContentsColorView, DropdownHeader, GlDropdown, @@ -42,12 +44,15 @@ export default { }, computed: { buttonText() { - if (!this.localSelectedColor?.title) { + if (!this.hasSelectedColor) { return this.dropdownButtonText; } return this.localSelectedColor.title; }, + hasSelectedColor() { + return this.localSelectedColor?.title; + }, }, watch: { localSelectedColor: { @@ -91,7 +96,15 @@ export default { </script> <template> - <gl-dropdown ref="dropdown" :text="buttonText" class="gl-w-full" @hide="handleDropdownHide"> + <gl-dropdown ref="dropdown" class="gl-w-full" @hide="handleDropdownHide"> + <template #button-text> + <color-item + v-if="hasSelectedColor" + :color="localSelectedColor.color" + :title="localSelectedColor.title" + /> + <span v-else data-testid="fallback-button-text">{{ buttonText }}</span> + </template> <template #header> <dropdown-header ref="header" diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue index 62f4cf59c14..91906388049 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue @@ -36,8 +36,8 @@ export default { </script> <template> - <gl-dropdown-form> - <div> + <gl-dropdown-form class="js-colors-list"> + <div data-testid="dropdown-content"> <gl-dropdown-item v-for="color in colors" :key="color.color" diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue index 4cba66eefd2..7ae803ebf4d 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue @@ -20,6 +20,11 @@ export default { required: true, }, }, + computed: { + hasColor() { + return this.selectedColor.color !== ''; + }, + }, }; </script> @@ -31,13 +36,18 @@ export default { class="sidebar-collapsed-icon" > <gl-icon name="appearance" /> + <color-item :color="selectedColor.color" :title="selectedColor.title" /> + </div> + + <span v-if="!hasColor" class="no-value hide-collapsed"> + <slot></slot> + </span> + <template v-else> <color-item + class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" - class="gl-font-base gl-line-height-24" /> - </div> - - <color-item class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/deployment_instance.vue b/app/assets/javascripts/vue_shared/components/deployment_instance.vue index 4aae86fc82b..1b907078cf9 100644 --- a/app/assets/javascripts/vue_shared/components/deployment_instance.vue +++ b/app/assets/javascripts/vue_shared/components/deployment_instance.vue @@ -13,8 +13,6 @@ * Mockup is https://gitlab.com/gitlab-org/gitlab/issues/35570 */ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -23,7 +21,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], props: { /** * Represents the status of the pod. Each state is represented with a different @@ -54,17 +51,11 @@ export default { required: false, default: '', }, - - logsPath: { - type: String, - required: false, - default: '', - }, }, computed: { isLink() { - return this.logsPath !== '' && this.podName !== ''; + return this.podName !== ''; }, cssClass() { @@ -74,12 +65,6 @@ export default { link: this.isLink, }; }, - - computedLogPath() { - return this.isLink && this.glFeatures.monitorLogging - ? mergeUrlParams({ pod_name: this.podName }, this.logsPath) - : null; - }, }, }; </script> @@ -88,7 +73,6 @@ export default { v-gl-tooltip :class="cssClass" :title="tooltipText" - :href="computedLogPath" class="deployment-instance d-flex justify-content-center align-items-center" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue index ca427ed4897..b9608a26d91 100644 --- a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue +++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue @@ -22,7 +22,7 @@ export default { }); }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index a512eb687b7..a246eadb790 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -37,6 +37,7 @@ export default { aria-expanded="false" > <gl-loading-icon v-show="isLoading" size="sm" :inline="true" /> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <slot v-if="$slots.default"></slot> <span v-else class="dropdown-toggle-text"> {{ toggleText }} </span> <gl-icon diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue index 5d0ed8b0821..1da84df022f 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue @@ -75,7 +75,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql new file mode 100644 index 00000000000..38222e4e8c2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql @@ -0,0 +1,6 @@ +fragment ContactFragment on CustomerRelationsContact { + id + firstName + lastName + email +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql new file mode 100644 index 00000000000..a7de3c7f7af --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql @@ -0,0 +1,4 @@ +fragment OrganizationFragment on CustomerRelationsOrganization { + id + name +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql new file mode 100644 index 00000000000..647aaa0f7f8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql @@ -0,0 +1,28 @@ +#import "./crm_contact.fragment.graphql" + +query searchCrmContacts( + $isProject: Boolean = false + $fullPath: ID! + $searchString: String + $searchIds: [CustomerRelationsContactID!] +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + contacts(search: $searchString, ids: $searchIds) { + nodes { + ...ContactFragment + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + group { + id + contacts(search: $searchString, ids: $searchIds) { + nodes { + ...ContactFragment + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql new file mode 100644 index 00000000000..c4f4663de45 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql @@ -0,0 +1,28 @@ +#import "./crm_organization.fragment.graphql" + +query searchCrmOrganizations( + $isProject: Boolean = false + $fullPath: ID! + $searchString: String + $searchIds: [CustomerRelationsOrganizationID!] +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + organizations(search: $searchString, ids: $searchIds) { + nodes { + ...OrganizationFragment + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + group { + id + organizations(search: $searchString, ids: $searchIds) { + nodes { + ...OrganizationFragment + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue new file mode 100644 index 00000000000..adfe0559b62 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -0,0 +1,131 @@ +<script> +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; + +import { ITEM_TYPE } from '~/groups/constants'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import createFlash from '~/flash'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql'; + +import { DEFAULT_NONE_ANY } from '../constants'; + +import BaseToken from './base_token.vue'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return { + contacts: this.config.initialContacts || [], + loading: false, + }; + }, + computed: { + defaultContacts() { + return this.config.defaultContacts || DEFAULT_NONE_ANY; + }, + namespace() { + return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; + }, + }, + methods: { + getActiveContact(contacts, data) { + return contacts.find((contact) => { + return `${this.formatContactId(contact)}` === data; + }); + }, + getContactName(contact) { + return `${contact.firstName} ${contact.lastName}`; + }, + fetchContacts(searchTerm) { + let searchString = null; + let searchId = null; + if (isPositiveInteger(searchTerm)) { + searchId = this.formatContactGraphQLId(searchTerm); + } else { + searchString = searchTerm; + } + + this.loading = true; + + this.$apollo + .query({ + query: searchCrmContactsQuery, + variables: { + fullPath: this.config.fullPath, + searchString, + searchIds: searchId ? [searchId] : null, + isProject: this.config.isProject, + }, + }) + .then(({ data }) => { + this.contacts = this.config.isProject + ? data[this.namespace]?.group.contacts.nodes + : data[this.namespace]?.contacts.nodes; + }) + .catch(() => + createFlash({ + message: __('There was a problem fetching CRM contacts.'), + }), + ) + .finally(() => { + this.loading = false; + }); + }, + formatContactId(contact) { + return `${getIdFromGraphQLId(contact.id)}`; + }, + formatContactGraphQLId(id) { + return convertToGraphQLId('CustomerRelations::Contact', id); + }, + }, +}; +</script> + +<template> + <base-token + :config="config" + :value="value" + :active="active" + :suggestions-loading="loading" + :suggestions="contacts" + :get-active-token-value="getActiveContact" + :default-suggestions="defaultContacts" + v-bind="$attrs" + @fetch-suggestions="fetchContacts" + v-on="$listeners" + > + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? getContactName(activeTokenValue) : inputValue }} + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion + v-for="contact in suggestions" + :key="formatContactId(contact)" + :value="formatContactId(contact)" + > + <div> + <div>{{ getContactName(contact) }}</div> + <div class="gl-font-sm">{{ contact.email }}</div> + </div> + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue new file mode 100644 index 00000000000..e6ab944449e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -0,0 +1,125 @@ +<script> +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; + +import { ITEM_TYPE } from '~/groups/constants'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import createFlash from '~/flash'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql'; + +import { DEFAULT_NONE_ANY } from '../constants'; + +import BaseToken from './base_token.vue'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return { + organizations: this.config.initialOrganizations || [], + loading: false, + }; + }, + computed: { + defaultOrganizations() { + return this.config.defaultOrganizations || DEFAULT_NONE_ANY; + }, + namespace() { + return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; + }, + }, + methods: { + getActiveOrganization(organizations, data) { + return organizations.find((organization) => { + return `${this.formatOrganizationId(organization)}` === data; + }); + }, + fetchOrganizations(searchTerm) { + let searchString = null; + let searchId = null; + if (isPositiveInteger(searchTerm)) { + searchId = this.formatOrganizationGraphQLId(searchTerm); + } else { + searchString = searchTerm; + } + + this.loading = true; + + this.$apollo + .query({ + query: searchCrmOrganizationsQuery, + variables: { + fullPath: this.config.fullPath, + searchString, + searchIds: searchId ? [searchId] : null, + isProject: this.config.isProject, + }, + }) + .then(({ data }) => { + this.organizations = this.config.isProject + ? data[this.namespace]?.group.organizations.nodes + : data[this.namespace]?.organizations.nodes; + }) + .catch(() => + createFlash({ + message: __('There was a problem fetching CRM organizations.'), + }), + ) + .finally(() => { + this.loading = false; + }); + }, + formatOrganizationId(organization) { + return `${getIdFromGraphQLId(organization.id)}`; + }, + formatOrganizationGraphQLId(id) { + return convertToGraphQLId('CustomerRelations::Organization', id); + }, + }, +}; +</script> + +<template> + <base-token + :config="config" + :value="value" + :active="active" + :suggestions-loading="loading" + :suggestions="organizations" + :get-active-token-value="getActiveOrganization" + :default-suggestions="defaultOrganizations" + v-bind="$attrs" + @fetch-suggestions="fetchOrganizations" + v-on="$listeners" + > + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion + v-for="organization in suggestions" + :key="formatOrganizationId(organization)" + :value="formatOrganizationId(organization)" + > + {{ organization.name }} + </gl-filtered-search-suggestion> + </template> + </base-token> +</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 15d858b99b9..482a2964b4c 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 @@ -139,6 +139,7 @@ export default { /> </template> </gl-form-input-group> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <template v-for="slot in Object.keys($slots)" #[slot]> <slot :name="slot"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index f2abade8036..96f7427dda1 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -163,6 +163,7 @@ export default { </template> </section> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex"> <slot></slot> </section> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index c3f184446a8..1b89bd324c6 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -38,6 +38,7 @@ export default { <template #default> <div v-safe-html="options.content"></div> </template> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <template v-for="slot in Object.keys($slots)" #[slot]> <slot :name="slot"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue index 4ece87310c7..96c779c5ce4 100644 --- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -100,7 +100,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 1f309a19b14..32b3a0e22c2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -248,7 +248,7 @@ export default { labels: this.enableAutocomplete, snippets: this.enableAutocomplete, vulnerabilities: this.enableAutocomplete, - contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete, + contacts: this.enableAutocomplete, }, true, ); diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 8a1b8363f19..7646a8718d6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -139,8 +139,8 @@ export default { </script> <template> - <div class="md-suggestion-header border-bottom-0 mt-2"> - <div class="js-suggestion-diff-header font-weight-bold"> + <div class="md-suggestion-header border-bottom-0 gl-mt-3"> + <div class="js-suggestion-diff-header gl-font-weight-bold"> {{ __('Suggested change') }} <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn"> <gl-icon name="question-o" css-classes="link-highlight" /> @@ -151,13 +151,13 @@ export default { </gl-badge> <div v-else-if="isApplying" - class="d-flex align-items-center text-secondary" + class="gl-display-flex gl-align-items-center text-secondary" data-qa-selector="applying_badge" > - <gl-loading-icon size="sm" class="d-flex-center mr-2" /> + <gl-loading-icon size="sm" class="gl-align-items-center gl-justify-content-center gl-mr-3" /> <span>{{ applyingSuggestionsMessage }}</span> </div> - <div v-else-if="isLoggedIn" class="d-flex align-items-center"> + <div v-else-if="isLoggedIn" class="gl-display-flex gl-align-items-center"> <div v-if="isBatched"> <gl-button class="btn-inverted js-remove-from-batch-btn btn-grouped" diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 624dbcc6d8e..0cb4a5bc39f 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -16,17 +16,17 @@ * :note="{body: 'This is a note'}" * /> */ -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { renderMarkdown } from '~/notes/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import userAvatarLink from '../user_avatar/user_avatar_link.vue'; export default { name: 'PlaceholderNote', directives: { SafeHtml }, components: { - userAvatarLink, + GlAvatarLink, + GlAvatar, TimelineEntryItem, }, props: { @@ -55,7 +55,10 @@ export default { return 24; } - return 40; + return { + default: 24, + md: 32, + }; }, }, }; @@ -64,11 +67,14 @@ export default { <template> <timeline-entry-item class="note note-wrapper being-posted fade-in-half"> <div class="timeline-icon"> - <user-avatar-link - :link-href="getUserData.path" - :img-src="getUserData.avatar_url" - :img-size="avatarSize" - /> + <gl-avatar-link class="gl-mr-3" :href="getUserData.path"> + <gl-avatar + :src="getUserData.avatar_url" + :entity-name="getUserData.username" + :alt="getUserData.name" + :size="avatarSize" + /> + </gl-avatar-link> </div> <div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content"> <div class="note-header"> diff --git a/app/assets/javascripts/vue_shared/components/page_size_selector.vue b/app/assets/javascripts/vue_shared/components/page_size_selector.vue new file mode 100644 index 00000000000..9783946b786 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/page_size_selector.vue @@ -0,0 +1,37 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export const PAGE_SIZES = [20, 50, 100]; + +export default { + components: { GlDropdown, GlDropdownItem }, + props: { + value: { + type: Number, + required: true, + }, + }, + methods: { + emitInput(pageSize) { + this.$emit('input', pageSize); + }, + getPageSizeText(pageSize) { + return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize }); + }, + }, + PAGE_SIZES, +}; +</script> + +<template> + <gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0"> + <gl-dropdown-item + v-for="pageSize in $options.PAGE_SIZES" + :key="pageSize" + @click="emitInput(pageSize)" + > + <span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index a8b250f2041..5516c9943b8 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -38,6 +38,7 @@ export default { }, }, mounted() { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots this.detailsSlots = Object.keys(this.$slots).filter((k) => k.startsWith('details-')); }, methods: { @@ -55,7 +56,7 @@ export default { > <div class="gl-display-flex gl-align-items-center gl-py-3"> <div - v-if="$slots['left-action']" + v-if="$slots['left-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */" class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2" > <slot name="left-action"></slot> @@ -65,7 +66,9 @@ export default { > <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> <div - v-if="$slots['left-primary']" + v-if=" + $slots['left-primary'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" > <slot name="left-primary"></slot> @@ -79,7 +82,11 @@ export default { /> </div> <div - v-if="$slots['left-secondary']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'left-secondary' + ] + " class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" > <slot name="left-secondary"></slot> @@ -89,13 +96,21 @@ export default { class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" > <div - v-if="$slots['right-primary']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-primary' + ] + " class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" > <slot name="right-primary"></slot> </div> <div - v-if="$slots['right-secondary']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-secondary' + ] + " class="gl-display-flex gl-align-items-center gl-min-h-6" > <slot name="right-secondary"></slot> @@ -103,7 +118,9 @@ export default { </div> </div> <div - v-if="$slots['right-action']" + v-if=" + $slots['right-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <slot name="right-action"></slot> diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index fc0976b0792..ad979387596 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -47,6 +47,7 @@ export default { methods: { recalculateMetadataSlots() { const METADATA_PREFIX = 'metadata-'; + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX)); if (!isEqual(metadataSlots, this.metadataSlots)) { @@ -76,7 +77,9 @@ export default { </h2> <div - v-if="$slots['sub-header']" + v-if=" + $slots['sub-header'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > <slot name="sub-header"></slot> @@ -107,6 +110,7 @@ export default { </template> </div> </div> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <div v-if="$slots['right-actions']" class="gl-mt-3"> <slot name="right-actions"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js new file mode 100644 index 00000000000..1c08433ee78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js @@ -0,0 +1 @@ +// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js new file mode 100644 index 00000000000..1c08433ee78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js @@ -0,0 +1 @@ +// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js new file mode 100644 index 00000000000..1c08433ee78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js @@ -0,0 +1 @@ +// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 5471cda0cc5..0127df730b8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -193,7 +193,7 @@ export default { <gl-dropdown ref="dropdown" :text="buttonText" - class="gl-w-full gl-mt-2" + class="gl-w-full" data-testid="labels-select-dropdown-contents" data-qa-selector="labels_dropdown_content" @hide="handleDropdownHide" diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue index 67726f01744..641b09e0982 100644 --- a/app/assets/javascripts/vue_shared/components/slot_switch.vue +++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue @@ -20,6 +20,7 @@ export default { computed: { allSlotNames() { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots return Object.keys(this.$slots); }, }, diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 0d78530d878..3ac35abcf3a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -45,6 +45,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { haskell: 'haskell', haxe: 'haxe', http: 'http', + html: 'xml', hylang: 'hy', ini: 'ini', isbl: 'isbl', @@ -90,7 +91,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { scala: 'scala', scheme: 'scheme', scss: 'scss', - shell: 'shell', + shell: 'sh', smalltalk: 'smalltalk', sml: 'sml', sqf: 'sqf', @@ -112,6 +113,12 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { yaml: 'yaml', }; +export const EVENT_ACTION = 'view_source'; + +export const EVENT_LABEL_VIEWER = 'source_viewer'; + +export const EVENT_LABEL_FALLBACK = 'legacy_fallback'; + export const LINES_PER_CHUNK = 70; export const BIDI_CHARS = [ @@ -138,3 +145,5 @@ export const BIDI_CHAR_TOOLTIP = __( export const HLJS_COMMENT_SELECTOR = 'hljs-comment'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; + +export const NPM_URL = 'https://npmjs.com/package'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js index c9f7e5508be..5d24a3d110b 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js @@ -1,5 +1,6 @@ import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants'; import wrapComments from './wrap_comments'; +import linkDependencies from './link_dependencies'; /** * Registers our plugins for Highlight.js @@ -8,6 +9,9 @@ import wrapComments from './wrap_comments'; * * @param {Object} hljs - the Highlight.js instance. */ -export const registerPlugins = (hljs) => { +export const registerPlugins = (hljs, fileType, rawContent) => { hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments }); + hljs.addPlugin({ + [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent), + }); }; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js new file mode 100644 index 00000000000..5b7650c56ae --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -0,0 +1,25 @@ +import packageJsonLinker from './utils/package_json_linker'; + +const DEPENDENCY_LINKERS = { + package_json: packageJsonLinker, +}; + +/** + * Highlight.js plugin for generating links to dependencies when viewing dependency files. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} result - an object that represents the highlighted result from Highlight.js + * @param {String} fileType - a string containing the file type + * @param {String} rawContent - raw (non-highlighted) file content + */ +export default (result, fileType, rawContent) => { + if (DEPENDENCY_LINKERS[fileType]) { + try { + // eslint-disable-next-line no-param-reassign + result.value = DEPENDENCY_LINKERS[fileType](result, rawContent); + } catch (e) { + // Shallowed (do nothing), in this case the original unlinked dependencies will be rendered. + } + } +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js new file mode 100644 index 00000000000..56ad55ef553 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js @@ -0,0 +1,15 @@ +import { escape } from 'lodash'; +import { setAttributes } from '~/lib/utils/dom_utils'; + +export const createLink = (href, innerText) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + const rel = 'nofollow noreferrer noopener'; + const link = document.createElement('a'); + + setAttributes(link, { href: escape(href), rel }); + link.innerText = escape(innerText); + + return link.outerHTML; +}; + +export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">"`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js new file mode 100644 index 00000000000..d013d077ba3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js @@ -0,0 +1,46 @@ +import { joinPaths } from '~/lib/utils/url_utility'; +import { NPM_URL } from '../../constants'; +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const attrOpenTag = generateHLJSOpenTag('attr'); +const stringOpenTag = generateHLJSOpenTag('string'); +const closeTag = '"</span>'; +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: <span class="hljs-attr">"@babel/core"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"^7.18.5"</span> + * Group 1: @babel/core + * Group 2: ^7.18.5 + */ + `${attrOpenTag}(.*)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`, + 'gm', +); + +const handleReplace = (original, packageName, version, dependenciesToLink) => { + const href = joinPaths(NPM_URL, packageName); + const packageLink = createLink(href, packageName); + const versionLink = createLink(href, version); + const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`; + const dependencyToLink = dependenciesToLink[packageName]; + + if (dependencyToLink && dependencyToLink === version) { + return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`; + } + + return original; +}; + +export default (result, raw) => { + const { dependencies, devDependencies, peerDependencies, optionalDependencies } = JSON.parse(raw); + + const dependenciesToLink = { + ...dependencies, + ...devDependencies, + ...peerDependencies, + ...optionalDependencies, + }; + + return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) => + handleReplace(original, packageName, version, dependenciesToLink), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index f819a9e5be2..1bdae40332f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -3,7 +3,14 @@ import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; -import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants'; +import Tracking from '~/tracking'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, +} from './constants'; import Chunk from './components/chunk.vue'; import { registerPlugins } from './plugins/index'; @@ -23,6 +30,7 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, + mixins: [Tracking.mixin()], props: { blob: { type: Object, @@ -49,8 +57,22 @@ export default { lineNumbers() { return this.splitContent.length; }, + unsupportedLanguage() { + const supportedLanguages = Object.keys(languageLoader); + return ( + !supportedLanguages.includes(this.language) && + !supportedLanguages.includes(this.blob.language) + ); + }, }, async created() { + this.trackEvent(EVENT_LABEL_VIEWER); + + if (this.unsupportedLanguage) { + this.handleUnsupportedLanguage(); + return; + } + this.generateFirstChunk(); this.hljs = await this.loadHighlightJS(); @@ -70,6 +92,13 @@ export default { }); }, methods: { + trackEvent(label) { + this.track(EVENT_ACTION, { label, property: this.blob.language }); + }, + handleUnsupportedLanguage() { + this.trackEvent(EVENT_LABEL_FALLBACK); + this.$emit('error'); + }, generateFirstChunk() { const lines = this.splitContent.splice(0, LINES_PER_CHUNK); this.firstChunk = this.createChunk(lines); @@ -112,7 +141,7 @@ export default { let detectedLanguage = language; let highlightedContent; if (this.hljs) { - registerPlugins(this.hljs); + registerPlugins(this.hljs, this.blob.fileType, this.content); if (!detectedLanguage) { const hljsHighlightAuto = this.hljs.highlightAuto(content); highlightedContent = hljsHighlightAuto.value; diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue index 20a666509a4..779a2ab5461 100644 --- a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue +++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue @@ -1,7 +1,6 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; export default { name: 'UsageBanner', @@ -15,13 +14,6 @@ export default { default: false, }, }, - i18n: { - dependencyProxy: s__('UsageQuota|Dependency proxy'), - storageUsed: s__('UsageQuota|Storage used'), - dependencyProxyMessage: s__( - 'UsageQuota|Local proxy used for frequently-accessed upstream Docker images. %{linkStart}More information%{linkEnd}', - ), - }, storageUsageQuotaHelpPage: helpPagePath('user/usage_quotas'), }; </script> @@ -33,13 +25,21 @@ export default { > <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> <div - v-if="$slots['left-primary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'left-primary-text' + ] + " class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" > <slot name="left-primary-text"></slot> </div> <div - v-if="$slots['left-secondary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'left-secondary-text' + ] + " class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1 gl-w-70p gl-md-max-w-70p" > <slot name="left-secondary-text"></slot> @@ -49,13 +49,21 @@ export default { class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" > <div - v-if="$slots['right-primary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-primary-text' + ] + " class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" > <slot name="right-primary-text"></slot> </div> <div - v-if="$slots['right-secondary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-secondary-text' + ] + " class="gl-display-flex gl-align-items-center gl-min-h-6" > <slot v-if="!loading" name="right-secondary-text"></slot> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue index c58a5357883..707b0bbec67 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue @@ -96,7 +96,10 @@ export default { /> <gl-tooltip - v-if="tooltipText || $slots.default" + v-if=" + tooltipText || + $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " :target="() => $refs.userAvatar.$el" :placement="tooltipPlacement" boundary="window" diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue index 15ba8e3b39b..6e8c200d5c3 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue @@ -100,7 +100,10 @@ export default { class="avatar" /> <gl-tooltip - v-if="tooltipText || $slots.default" + v-if=" + tooltipText || + $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " :target="() => $refs.userAvatarImage" :placement="tooltipPlacement" boundary="window" diff --git a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue index 121c3bd94ef..ab5ddbc8af8 100644 --- a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue +++ b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue @@ -56,7 +56,13 @@ import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.que * - shouldShowCallout: boolean * - A combination of the above which should cover 95% of use cases: `true` * if the query has loaded without error, and the user is logged in, and - * the callout has not been dismissed yet; `false` otherwise. + * the callout has not been dismissed yet; `false` otherwise + * + * The component emits a `queryResult` event when the GraphQL query + * completes. The payload is a combination of the ApolloQueryResult object and + * this component's `slotProps` computed property. This is useful for things + * like cleaning up/unmounting the component if the callout shouldn't be + * displayed. */ export default { name: 'UserCalloutDismisser', @@ -86,6 +92,9 @@ export default { update(data) { return data?.currentUser; }, + result(data) { + this.$emit('queryResult', { ...data, ...this.slotProps }); + }, error(err) { this.queryError = err; }, diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 768cd005727..a0d8ca117a4 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -7,13 +7,13 @@ import { GlSafeHtmlDirective, GlSprintf, GlButton, + GlAvatarLabeled, } from '@gitlab/ui'; import { __ } from '~/locale'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import { glEmojiTag } from '~/emoji'; import createFlash from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; -import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; +import { isUserBusy } from '~/set_status_modal/utils'; import { USER_POPOVER_DELAY } from './constants'; const MAX_SKELETON_LINES = 4; @@ -22,15 +22,17 @@ export default { name: 'UserPopover', maxSkeletonLines: MAX_SKELETON_LINES, USER_POPOVER_DELAY, + i18n: { + busy: __('Busy'), + }, components: { GlIcon, GlLink, GlPopover, GlSkeletonLoader, - UserAvatarImage, - UserNameWithStatus, GlSprintf, GlButton, + GlAvatarLabeled, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -95,6 +97,15 @@ export default { toggleFollowButtonVariant() { return this.user?.isFollowed ? 'default' : 'confirm'; }, + hasPronouns() { + return Boolean(this.user?.pronouns?.trim()); + }, + isBusy() { + return isUserBusy(this.availabilityStatus); + }, + username() { + return `@${this.user?.username}`; + }, }, methods: { async toggleFollow() { @@ -149,43 +160,46 @@ export default { :placement="placement" boundary="viewport" triggers="hover focus manual" + data-testid="user-popover" > - <div class="gl-py-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> - <div class="gl-mr-4 gl-flex-shrink-0"> - <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-m-0!" /> + <div class="gl-mb-3"> + <div v-if="userIsLoading" class="gl-w-20"> + <gl-skeleton-loader :width="160" :height="64"> + <rect x="70" y="19" rx="3" ry="3" width="88" height="9" /> + <rect x="70" y="36" rx="3" ry="3" width="64" height="8" /> + <circle cx="32" cy="32" r="32" /> + </gl-skeleton-loader> </div> - <div class="gl-w-full gl-word-break-word gl-display-flex gl-align-items-center"> - <template v-if="userIsLoading"> - <gl-skeleton-loader - :lines="$options.maxSkeletonLines" - preserve-aspect-ratio="none" - equal-width-lines - :height="52" - /> - </template> - <template v-else> - <div> - <h5 class="gl-m-0"> - <user-name-with-status - :name="user.name" - :availability="availabilityStatus" - :pronouns="user.pronouns" - /> - </h5> - <span class="gl-text-gray-500">@{{ user.username }}</span> - <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3"> - <gl-button - :variant="toggleFollowButtonVariant" - :loading="toggleFollowLoading" - size="small" - data-testid="toggle-follow-button" - @click="toggleFollow" - >{{ toggleFollowButtonText }}</gl-button - > - </div> - </div> + <gl-avatar-labeled + v-else + :size="64" + :src="user.avatarUrl" + :label="user.name" + :sub-label="username" + > + <gl-button + v-if="shouldRenderToggleFollowButton" + class="gl-mt-3 gl-align-self-start" + :variant="toggleFollowButtonVariant" + :loading="toggleFollowLoading" + size="small" + data-testid="toggle-follow-button" + @click="toggleFollow" + >{{ toggleFollowButtonText }}</gl-button + > + + <template #meta> + <span + v-if="hasPronouns" + class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1" + data-testid="user-popover-pronouns" + >({{ user.pronouns }})</span + > + <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1" + >({{ $options.i18n.busy }})</span + > </template> - </div> + </gl-avatar-labeled> </div> <div class="gl-mt-2 gl-w-full gl-word-break-word"> <template v-if="userIsLoading"> diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue index eff39e2fb89..4ef9bc07b1c 100644 --- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue +++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue @@ -15,7 +15,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index 89eecea5239..25799171905 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -81,7 +81,8 @@ export default { ref="textarea" v-model="issuableDescription" dir="auto" - class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area" + class="note-textarea rspec-issuable-form-description js-gfm-input js-autosize markdown-area" + data-qa-selector="issuable_form_description_field" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" ></textarea> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index a9f8caa3e1f..b616b390032 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -86,7 +86,18 @@ export default { createdAt() { return getTimeago().format(this.issuable.createdAt); }, - updatedAt() { + timestamp() { + if (this.issuable.state === 'closed' && this.issuable.closedAt) { + return this.issuable.closedAt; + } + return this.issuable.updatedAt; + }, + formattedTimestamp() { + if (this.issuable.state === 'closed' && this.issuable.closedAt) { + return sprintf(__('closed %{timeago}'), { + timeago: getTimeago().format(this.issuable.closedAt), + }); + } return sprintf(__('updated %{timeAgo}'), { timeAgo: getTimeago().format(this.issuable.updatedAt), }); @@ -134,6 +145,7 @@ export default { }, methods: { hasSlotContents(slotName) { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots return Boolean(this.$slots[slotName]); }, scopedLabel(label) { @@ -311,10 +323,10 @@ export default { <div v-gl-tooltip.bottom class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block" - :title="tooltipTitle(issuable.updatedAt)" - data-testid="issuable-updated-at" + :title="tooltipTitle(timestamp)" + data-testid="issuable-timestamp" > - {{ updatedAt }} + {{ formattedTimestamp }} </div> </div> </li> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 8fbf0bb10a0..189bbb56432 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -1,11 +1,13 @@ <script> import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { DEFAULT_SKELETON_COUNT } from '../constants'; +import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants'; import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; import IssuableItem from './issuable_item.vue'; import IssuableTabs from './issuable_tabs.vue'; @@ -29,6 +31,8 @@ export default { IssuableBulkEditSidebar, GlPagination, VueDraggable, + PageSizeSelector, + LocalStorageSync, }, props: { namespace: { @@ -173,6 +177,11 @@ export default { required: false, default: false, }, + showPageSizeChangeControls: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -262,7 +271,11 @@ export default { handleVueDraggableUpdate({ newIndex, oldIndex }) { this.$emit('reorder', { newIndex, oldIndex }); }, + handlePageSizeChange(newPageSize) { + this.$emit('page-size-change', newPageSize); + }, }, + PAGE_SIZE_STORAGE_KEY, }; </script> @@ -353,24 +366,38 @@ export default { <slot v-else name="empty-state"></slot> </template> - <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3"> + <div class="gl-text-center gl-mt-6 gl-relative"> <gl-keyset-pagination + v-if="showPaginationControls && useKeysetPagination" :has-next-page="hasNextPage" :has-previous-page="hasPreviousPage" @next="$emit('next-page')" @prev="$emit('previous-page')" /> + <gl-pagination + v-else-if="showPaginationControls" + :per-page="defaultPageSize" + :total-items="totalItems" + :value="currentPage" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="$emit('page-change', $event)" + /> + + <local-storage-sync + v-if="showPageSizeChangeControls" + :value="defaultPageSize" + :storage-key="$options.PAGE_SIZE_STORAGE_KEY" + @input="handlePageSizeChange" + > + <page-size-selector + :value="defaultPageSize" + class="gl-absolute gl-right-0" + @input="handlePageSizeChange" + /> + </local-storage-sync> </div> - <gl-pagination - v-else-if="showPaginationControls" - :per-page="defaultPageSize" - :total-items="totalItems" - :value="currentPage" - :prev-page="previousPage" - :next-page="nextPage" - align="center" - class="gl-pagination gl-mt-3" - @input="$emit('page-change', $event)" - /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js index be9afc0610d..507f333a34e 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js @@ -56,3 +56,5 @@ export const IssuableTypes = { export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_SKELETON_COUNT = 5; + +export const PAGE_SIZE_STORAGE_KEY = 'issuable_list_page_size'; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index f57b5b2deb4..d4e9120ff17 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -37,7 +37,11 @@ export default { </script> <template> - <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }"> + <div + class="description" + :class="{ 'js-task-list-container': canEdit && enableTaskList }" + data-qa-selector="description_content" + > <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> <textarea v-if="issuable.description && enableTaskList" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 33dca3e9332..2fc1f935501 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue @@ -123,7 +123,6 @@ export default { :placeholder="__('Title')" :aria-label="__('Title')" :autofocus="true" - class="qa-title-input" @keydown="handleKeydown($event, 'title')" /> </gl-form-group> @@ -149,7 +148,7 @@ export default { :data-supports-quick-actions="enableAutocomplete" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" - class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" @keydown="handleKeydown($event, 'description')" ></textarea> 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 f035795a045..cdc5903b934 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 @@ -112,7 +112,7 @@ export default { <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> <span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span> </gl-badge> - <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block"> + <div class="issuable-meta gl-display-flex! gl-align-items-center"> <div v-if="blocked || confidential" class="gl-display-inline-block"> <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> <gl-icon name="lock" :aria-label="__('Blocked')" /> @@ -139,13 +139,15 @@ export default { :size="24" :src="author.avatarUrl" :label="author.name" - class="d-none d-sm-inline-flex gl-mx-1" + :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline-flex gl-mx-1']" > <template #meta> - <gl-icon v-if="isAuthorExternal" name="external-link" /> + <gl-icon v-if="isAuthorExternal" name="external-link" class="gl-ml-1" /> </template> </gl-avatar-labeled> - <strong class="author d-sm-none d-inline">@{{ author.username }}</strong> + <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!" + >@{{ author.username }}</strong + > </gl-avatar-link> <span v-if="taskCompletionStatus && hasTasks" 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 3d7c71ce974..35124bd15d2 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 @@ -64,8 +64,9 @@ export default { <div class="title-container"> <h1 v-safe-html="issuable.titleHtml || issuable.title" - class="title qa-title gl-font-size-h-display" + class="title gl-font-size-h-display" dir="auto" + data-qa-selector="title_content" data-testid="title" ></h1> <gl-button @@ -74,7 +75,7 @@ export default { :title="$options.i18n.editTitleAndDescription" :aria-label="$options.i18n.editTitleAndDescription" icon="pencil" - class="btn-edit js-issuable-edit qa-edit-button" + class="btn-edit js-issuable-edit" @click="$emit('edit-issuable', $event)" /> </div> diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 69670d3471c..2dc8e3a1101 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -54,7 +54,7 @@ export default { :label-for="$options.labelId" label-cols="3" label-cols-lg="2" - label-class="gl-pb-0!" + label-class="gl-pb-0! gl-overflow-wrap-break" class="gl-align-items-center" > <gl-form-select diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index ce2fa158596..1cdc9c28f05 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -1,5 +1,4 @@ <script> -import { escape } from 'lodash'; import { __ } from '~/locale'; export default { @@ -21,15 +20,11 @@ export default { }, }, methods: { - getSanitizedTitle(inputEl) { - const { innerText } = inputEl; - return escape(innerText); - }, handleBlur({ target }) { - this.$emit('title-changed', this.getSanitizedTitle(target)); + this.$emit('title-changed', target.innerText); }, handleInput({ target }) { - this.$emit('title-input', this.getSanitizedTitle(target)); + this.$emit('title-input', target.innerText); }, handleSubmit() { this.$refs.titleEl.blur(); @@ -40,7 +35,7 @@ export default { <template> <h2 - class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-w-full" + class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full" :class="{ 'gl-cursor-not-allowed': disabled }" aria-labelledby="item-title" > 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 31e4a932c5a..77002eeaf55 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -5,7 +5,7 @@ import Tracking from '~/tracking'; export default { i18n: { - deleteWorkItem: s__('WorkItem|Delete work item'), + deleteTask: s__('WorkItem|Delete task'), }, components: { GlDropdown, @@ -54,7 +54,7 @@ export default { right > <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{ - $options.i18n.deleteWorkItem + $options.i18n.deleteTask }}</gl-dropdown-item> </gl-dropdown> <gl-modal @@ -66,9 +66,7 @@ export default { @hide="handleCancelDeleteWorkItem" > {{ - s__( - 'WorkItem|Are you sure you want to delete the work item? This action cannot be reversed.', - ) + s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.') }} </gl-modal> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4d1c171772e..9ff424aa20f 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -1,10 +1,35 @@ <script> -import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui'; +import { + GlTokenSelector, + GlIcon, + GlAvatar, + GlLink, + GlSkeletonLoader, + GlButton, + GlDropdownItem, + GlDropdownDivider, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { n__, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; -function isClosingIcon(el) { - return el?.classList.contains('gl-token-close'); +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; } export default { @@ -13,7 +38,15 @@ export default { GlIcon, GlAvatar, GlLink, + GlSkeletonLoader, + GlButton, + SidebarParticipant, + InviteMembersTrigger, + GlDropdownItem, + GlDropdownDivider, }, + mixins: [Tracking.mixin()], + inject: ['fullPath'], props: { workItemId: { type: String, @@ -23,67 +56,188 @@ export default { type: Array, required: true, }, + allowsMultipleAssignees: { + type: Boolean, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { isEditing: false, - localAssignees: this.assignees.map((assignee) => ({ - ...assignee, - class: 'gl-bg-transparent!', - })), + searchStarted: false, + localAssignees: this.assignees.map(addClass), + searchKey: '', + searchUsers: [], + currentUser: null, }; }, + apollo: { + searchUsers: { + query() { + return userSearchQuery; + }, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + currentUser: { + query: currentUserQuery, + }, + }, computed: { - assigneeIds() { - return this.localAssignees.map((assignee) => assignee.id); + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_assignees', + property: `type_${this.workItemType}`, + }; }, assigneeListEmpty() { return this.assignees.length === 0; }, containerClass() { - return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : ''; + return !this.isEditing ? 'gl-shadow-none!' : ''; + }, + isLoadingUsers() { + return this.$apollo.queries.searchUsers.loading; + }, + assigneeText() { + return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); + }, + dropdownItems() { + if (this.currentUser && this.searchEmpty) { + if (this.searchUsers.some((user) => user.username === this.currentUser.username)) { + return this.moveCurrentUserToStart(this.searchUsers); + } + return [this.currentUser, ...this.searchUsers]; + } + return this.searchUsers; + }, + searchEmpty() { + return this.searchKey.length === 0; + }, + addAssigneesText() { + return this.allowsMultipleAssignees + ? s__('WorkItem|Add assignees') + : s__('WorkItem|Add assignee'); }, }, + watch: { + assignees(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, methods: { getUserId(id) { return getIdFromGraphQLId(id); }, - setAssignees(e) { - if (isClosingIcon(e.relatedTarget) || !this.isEditing) return; + handleAssigneesInput(assignees) { + if (!this.allowsMultipleAssignees) { + this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : []; + this.isEditing = false; + return; + } + this.localAssignees = assignees; + this.focusTokenSelector(); + }, + handleBlur(e) { + if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; this.isEditing = false; + this.setAssignees(this.localAssignees); + }, + setAssignees(assignees) { this.$apollo.mutate({ mutation: localUpdateWorkItemMutation, variables: { input: { id: this.workItemId, - assigneeIds: this.assigneeIds, + assignees, }, }, }); + this.track('updated_assignees'); }, - async focusTokenSelector() { + handleFocus() { this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector() { + this.handleFocus(); await this.$nextTick(); this.$refs.tokenSelector.focusTextInput(); }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, + moveCurrentUserToStart(users = []) { + if (this.currentUser) { + return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)]; + } + return users; + }, + closeDropdown() { + this.$refs.tokenSelector.closeDropdown(); + }, }, }; </script> <template> - <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative"> - <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{ - __('Assignee(s)') - }}</span> + <div class="form-row gl-mb-5 work-item-assignees gl-relative"> + <span + class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + data-testid="assignees-title" + >{{ assigneeText }}</span + > <gl-token-selector ref="tokenSelector" - v-model="localAssignees" - hide-dropdown-with-no-items + :selected-tokens="localAssignees" :container-class="containerClass" - class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" - @token-remove="focusTokenSelector" - @focus="isEditing = true" - @blur="setAssignees" + class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!" + :class="{ 'gl-hover-border-gray-200': canUpdate }" + :dropdown-items="dropdownItems" + :loading="isLoadingUsers" + :view-only="!canUpdate" + @input="handleAssigneesInput" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @blur="handleBlur" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" > <template #empty-placeholder> <div @@ -91,7 +245,15 @@ export default { data-testid="empty-state" > <gl-icon name="profile" /> - <span class="gl-ml-2">{{ __('Add assignees') }}</span> + <span class="gl-ml-2 gl-mr-4">{{ addAssigneesText }}</span> + <gl-button + v-if="currentUser" + size="small" + class="assign-myself" + data-testid="assign-self" + @click.stop="setAssignees([currentUser])" + >{{ __('Assign myself') }}</gl-button + > </div> </template> <template #token-content="{ token }"> @@ -106,6 +268,29 @@ export default { <span class="gl-pl-2">{{ token.name }}</span> </gl-link> </template> + <template #dropdown-item-content="{ dropdownItem }"> + <sidebar-participant :user="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> + <template #dropdown-footer> + <gl-dropdown-divider /> + <gl-dropdown-item @click="closeDropdown"> + <invite-members-trigger + :display-text="__('Invite members')" + trigger-element="side-nav" + icon="plus" + trigger-source="work-item-assignees-dropdown" + classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" + /> + </gl-dropdown-item> + </template> </gl-token-selector> </div> </template> 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 5a85fcdd7ac..90e3cd45cb4 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -35,7 +35,7 @@ export default { isEditing: false, isSubmitting: false, isSubmittingWithKeydown: false, - desc: '', + descriptionText: '', }; }, apollo: { @@ -71,16 +71,17 @@ export default { descriptionHtml() { return this.workItemDescription?.descriptionHtml; }, - descriptionText: { - get() { - return this.desc; - }, - set(desc) { - this.desc = desc; - }, + descriptionEmpty() { + return this.descriptionHtml?.trim() === ''; }, workItemDescription() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + const descriptionWidget = this.workItem?.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_DESCRIPTION, + ); + return { + ...descriptionWidget, + description: descriptionWidget?.description || '', + }; }, workItemType() { return this.workItem?.workItemType?.name; @@ -95,14 +96,14 @@ export default { async startEditing() { this.isEditing = true; - this.desc = getDraft(this.autosaveKey) || this.workItemDescription?.description || ''; + this.descriptionText = getDraft(this.autosaveKey) || this.workItemDescription?.description; await this.$nextTick(); this.$refs.textarea.focus(); }, async cancelEditing() { - const isDirty = this.desc !== this.workItemDescription?.description; + const isDirty = this.descriptionText !== this.workItemDescription?.description; if (isDirty) { const msg = s__('WorkItem|Are you sure you want to cancel editing?'); @@ -125,7 +126,7 @@ export default { return; } - updateDraft(this.autosaveKey, this.desc); + updateDraft(this.autosaveKey, this.descriptionText); }, async updateWorkItem(event) { if (event.key) { @@ -171,25 +172,10 @@ export default { <template> <gl-form-group v-if="isEditing" - class="gl-pt-5 gl-mb-5 gl-mt-0! gl-border-t! gl-border-b" + class="gl-my-5" :label="__('Description')" label-for="work-item-description" - label-class="gl-float-left" > - <div class="gl-display-flex gl-justify-content-flex-end"> - <gl-button class="gl-ml-auto" data-testid="cancel" @click="cancelEditing">{{ - __('Cancel') - }}</gl-button> - <gl-button - class="js-no-auto-disable gl-ml-4" - category="primary" - variant="confirm" - :loading="isSubmitting" - data-testid="save-description" - @click="updateWorkItem" - >{{ __('Save') }}</gl-button - > - </div> <markdown-field can-attach-file :textarea-value="descriptionText" @@ -216,19 +202,35 @@ export default { ></textarea> </template> </markdown-field> - </gl-form-group> - <div v-else class="gl-pt-5 gl-mb-5 gl-border-t gl-border-b"> + <div class="gl-display-flex"> - <h3 class="gl-font-base gl-mt-0">{{ __('Description') }}</h3> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ __('Save') }}</gl-button + > + <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{ + __('Cancel') + }}</gl-button> + </div> + </gl-form-group> + <div v-else class="gl-mb-5"> + <div class="gl-display-flex gl-align-items-center gl-mb-5"> + <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3> <gl-button v-if="canEdit" class="gl-ml-auto" icon="pencil" data-testid="edit-description" + :aria-label="__('Edit')" @click="startEditing" - >{{ __('Edit') }}</gl-button - > + /> </div> - <div v-safe-html="descriptionHtml" class="md gl-mb-5"></div> + + <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div> + <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div> </div> </template> 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 5272df2d53f..ad90fe88947 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,11 +1,15 @@ <script> -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { i18n, - WIDGET_TYPE_ASSIGNEE, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_WEIGHT, + WIDGET_TYPE_HIERARCHY, + WORK_ITEM_VIEWED_STORAGE_KEY, } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; @@ -14,22 +18,34 @@ import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; import WorkItemDescription from './work_item_description.vue'; import WorkItemAssignees from './work_item_assignees.vue'; +import WorkItemLabels from './work_item_labels.vue'; import WorkItemWeight from './work_item_weight.vue'; +import WorkItemInformation from './work_item_information.vue'; export default { i18n, components: { GlAlert, + GlButton, GlSkeletonLoader, + GlIcon, WorkItemAssignees, WorkItemActions, WorkItemDescription, + WorkItemLabels, WorkItemTitle, WorkItemState, WorkItemWeight, + WorkItemInformation, + LocalStorageSync, }, mixins: [glFeatureFlagMixin()], props: { + isModal: { + type: Boolean, + required: false, + default: false, + }, workItemId: { type: String, required: false, @@ -45,6 +61,7 @@ export default { return { error: undefined, workItem: {}, + showInfoBanner: true, }; }, apollo: { @@ -91,17 +108,40 @@ export default { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); }, workItemAssignees() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEE); + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES); + }, + workItemLabels() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, workItemWeight() { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, + workItemHierarchy() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); + }, + parentWorkItem() { + return this.workItemHierarchy?.parent; + }, + parentUrl() { + return `../../issues/${this.parentWorkItem?.iid}`; + }, + }, + beforeDestroy() { + /** make sure that if the user has not even dismissed the alert , + * should no be able to see the information next time and update the local storage * */ + this.dismissBanner(); }, + methods: { + dismissBanner() { + this.showInfoBanner = false; + }, + }, + WORK_ITEM_VIEWED_STORAGE_KEY, }; </script> <template> - <section> + <section class="gl-pt-5"> <gl-alert v-if="error" variant="danger" @dismiss="error = undefined"> {{ error }} </gl-alert> @@ -113,39 +153,95 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div class="gl-display-flex gl-align-items-start"> - <work-item-title - :work-item-id="workItem.id" - :work-item-title="workItem.title" - :work-item-type="workItemType" - :work-item-parent-id="workItemParentId" - class="gl-mr-5" - @error="error = $event" - /> + <div class="gl-display-flex gl-align-items-center"> + <ul + v-if="parentWorkItem" + class="list-unstyled gl-display-flex gl-mr-auto" + data-testid="work-item-parent" + > + <li class="gl-ml-n4"> + <gl-button icon="issues" category="tertiary" :href="parentUrl">{{ + parentWorkItem.title + }}</gl-button> + <gl-icon name="chevron-right" :size="16" /> + </li> + <li class="gl-px-4 gl-py-3 gl-line-height-0"> + <gl-icon name="task-done" /> + {{ workItemType }} + </li> + </ul> + <span + v-else + class="gl-font-weight-bold gl-text-secondary gl-mr-auto" + data-testid="work-item-type" + >{{ workItemType }}</span + > <work-item-actions :work-item-id="workItem.id" :can-delete="canDelete" - class="gl-ml-auto gl-mt-6" @deleteWorkItem="$emit('deleteWorkItem')" @error="error = $event" /> + <gl-button + v-if="isModal" + category="tertiary" + data-testid="work-item-close" + icon="close" + :aria-label="__('Close')" + @click="$emit('close')" + /> </div> - <template v-if="workItemsMvc2Enabled"> - <work-item-assignees - v-if="workItemAssignees" - :work-item-id="workItem.id" - :assignees="workItemAssignees.nodes" + <local-storage-sync + v-model="showInfoBanner" + :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" + > + <work-item-information + v-if="showInfoBanner" + :show-info-banner="showInfoBanner" + @work-item-banner-dismissed="dismissBanner" /> - <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" /> - </template> + </local-storage-sync> + <work-item-title + :work-item-id="workItem.id" + :work-item-title="workItem.title" + :work-item-type="workItemType" + :work-item-parent-id="workItemParentId" + @error="error = $event" + /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" @error="error = $event" /> + <template v-if="workItemsMvc2Enabled"> + <work-item-assignees + v-if="workItemAssignees" + :can-update="canUpdate" + :work-item-id="workItem.id" + :assignees="workItemAssignees.assignees.nodes" + :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" + :work-item-type="workItemType" + @error="error = $event" + /> + <work-item-labels + v-if="workItemLabels" + :work-item-id="workItem.id" + :can-update="canUpdate" + @error="error = $event" + /> + <work-item-weight + v-if="workItemWeight" + class="gl-mb-5" + :can-update="canUpdate" + :weight="workItemWeight.weight" + :work-item-id="workItem.id" + :work-item-type="workItemType" + /> + </template> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" + class="gl-pt-5" @error="error = $event" /> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index d1c8022ac57..df7c6cab7ef 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -80,13 +80,16 @@ export default { .catch((e) => { this.error = e.message || - s__('WorkItem|Something went wrong when deleting the work item. Please try again.'); + s__('WorkItem|Something went wrong when deleting the task. Please try again.'); }); }, closeModal() { this.error = ''; this.$emit('close'); }, + hide() { + this.$refs.modal.hide(); + }, setErrorMessage(message) { this.error = message; }, @@ -104,7 +107,6 @@ export default { size="lg" modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" - body-class="gl-pb-6!" @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -112,9 +114,11 @@ export default { </gl-alert> <work-item-detail + is-modal :work-item-parent-id="issueGid" :work-item-id="workItemId" class="gl-p-5 gl-mt-n3" + @close="hide" @deleteWorkItem="deleteWorkItem" /> </gl-modal> diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue new file mode 100644 index 00000000000..2ff7ba169ea --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_information.vue @@ -0,0 +1,57 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + i18n: { + learnTasksButtonText: s__('WorkItem|Learn about tasks'), + workItemsText: s__('WorkItem|work items'), + tasksInformationTitle: s__('WorkItem|Introducing tasks'), + tasksInformationBody: s__( + 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.', + ), + }, + helpPageLinks: { + tasksDocLinkPath: helpPagePath('user/tasks'), + workItemsLinkPath: helpPagePath(`development/work_items`), + }, + components: { + GlAlert, + GlSprintf, + GlLink, + }, + props: { + showInfoBanner: { + type: Boolean, + required: false, + default: true, + }, + }, + emits: ['work-item-banner-dismissed'], +}; +</script> + +<template> + <section class="gl-display-block gl-mb-2"> + <gl-alert + v-if="showInfoBanner" + variant="tip" + :title="$options.i18n.tasksInformationTitle" + :primary-button-link="$options.helpPageLinks.tasksDocLinkPath" + :primary-button-text="$options.i18n.learnTasksButtonText" + data-testid="work-item-information" + class="gl-mt-3" + @dismiss="$emit('work-item-banner-dismissed')" + > + <gl-sprintf :message="$options.i18n.tasksInformationBody"> + <template #workItemsLink> + <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{ + $options.i18n.workItemsText + }}</gl-link> + </template> + ></gl-sprintf + > + </gl-alert> + </section> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue new file mode 100644 index 00000000000..78ed67998d7 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -0,0 +1,246 @@ +<script> +import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import Tracking from '~/tracking'; +import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +import workItemQuery from '../graphql/work_item.query.graphql'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; + +import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants'; + +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; +} + +export default { + components: { + GlTokenSelector, + GlLabel, + GlSkeletonLoader, + LabelItem, + }, + mixins: [Tracking.mixin()], + inject: ['fullPath'], + props: { + workItemId: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isEditing: false, + searchStarted: false, + localLabels: [], + searchKey: '', + searchLabels: [], + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + skip() { + return !this.workItemId; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + searchLabels: { + query: labelSearchQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_labels', + property: `type_${this.workItem.workItemType?.name}`, + }; + }, + allowScopedLabels() { + return this.labelsWidget.allowScopedLabels; + }, + listEmpty() { + return this.labels.length === 0; + }, + containerClass() { + return !this.isEditing ? 'gl-shadow-none!' : ''; + }, + isLoading() { + return this.$apollo.queries.searchLabels.loading; + }, + labelsWidget() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + }, + labels() { + return this.labelsWidget?.nodes || []; + }, + }, + watch: { + labels(newVal) { + if (!this.isEditing) { + this.localLabels = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + getId(id) { + return getIdFromGraphQLId(id); + }, + removeLabel({ id }) { + this.localLabels = this.localLabels.filter((label) => label.id !== id); + }, + setLabels(event) { + this.searchKey = ''; + if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; + this.isEditing = false; + this.$apollo + .mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + labels: this.localLabels, + }, + }, + }) + .catch((e) => { + this.$emit('error', e); + }); + this.track('updated_labels'); + }, + handleFocus() { + this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector(labels) { + if (this.allowScopedLabels) { + const newLabel = labels[labels.length - 1]; + const existingLabels = labels.slice(0, labels.length - 1); + + const newLabelKey = scopedLabelKey(newLabel); + + const removeLabelsWithSameScope = existingLabels.filter((label) => { + const sameKey = newLabelKey === scopedLabelKey(label); + return !sameKey; + }); + + this.localLabels = [...removeLabelsWithSameScope, newLabel]; + } + this.handleFocus(); + await this.$nextTick(); + this.$refs.tokenSelector.focusTextInput(); + }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, + scopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <div class="form-row gl-mb-5 work-item-labels gl-relative"> + <span + class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + data-testid="labels-title" + >{{ __('Labels') }}</span + > + <gl-token-selector + ref="tokenSelector" + v-model="localLabels" + :container-class="containerClass" + :dropdown-items="searchLabels" + :loading="isLoading" + :view-only="!canUpdate" + class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + @input="focusTokenSelector" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @blur="setLabels" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #empty-placeholder> + <div + class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2" + data-testid="empty-state" + > + <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span> + <span v-else class="gl-ml-2">{{ __('None') }}</span> + </div> + </template> + <template #token-content="{ token }"> + <gl-label + :data-qa-label-name="token.title" + :title="token.title" + :description="token.description" + :background-color="token.color" + :scoped="scopedLabel(token)" + :show-close-button="canUpdate" + @close="removeLabel(token)" + /> + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <label-item :label="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> + </gl-token-selector> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 320a4a213e3..176f84f6c1a 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -1,9 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; import createDefaultClient from '~/lib/graphql'; import WorkItemLinks from './work_item_links.vue'; Vue.use(VueApollo); +Vue.use(GlToast); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -19,6 +21,7 @@ export default function initWorkItemLinks() { if (!workItemLinksRoot) { return; } + // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, @@ -27,6 +30,9 @@ export default function initWorkItemLinks() { components: { workItemLinks: WorkItemLinks, }, + provide: { + projectPath: workItemLinksRoot.dataset.projectPath, + }, render: (createElement) => createElement('work-item-links', { props: { 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 bdfff100333..89f086cfca5 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 @@ -11,6 +11,7 @@ import { } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { @@ -19,6 +20,7 @@ export default { GlIcon, GlLoadingIcon, WorkItemLinksForm, + WorkItemLinksMenu, }, props: { workItemId: { @@ -77,6 +79,9 @@ export default { isLoading() { return this.$apollo.queries.children.loading; }, + childrenIds() { + return this.children.map((c) => c.id); + }, }, methods: { badgeVariant(state) { @@ -88,13 +93,16 @@ export default { toggleAddForm() { this.isShownAddForm = !this.isShownAddForm; }, + addChild(child) { + this.children = [child, ...this.children]; + }, }, i18n: { title: s__('WorkItem|Child items'), emptyStateMessage: s__( 'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!', ), - addChildButtonLabel: s__('WorkItem|Add a child'), + addChildButtonLabel: s__('WorkItem|Add a task'), }, WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK, WORK_ITEM_STATUS_TEXT, @@ -107,8 +115,16 @@ export default { class="gl-p-4 gl-display-flex gl-justify-content-space-between" :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" > - <h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4"> + <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5> + <gl-button + v-if="!isShownAddForm" + category="secondary" + data-testid="toggle-add-form" + @click="toggleAddForm" + > + {{ $options.i18n.addChildButtonLabel }} + </gl-button> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3"> <gl-button category="tertiary" :icon="toggleIcon" @@ -126,37 +142,38 @@ export default { <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" /> <template v-else> - <div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty"> - <p> + <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty"> + <p class="gl-my-3"> {{ $options.i18n.emptyStateMessage }} </p> - <gl-button - v-if="!isShownAddForm" - category="secondary" - variant="confirm" - data-testid="toggle-add-form" - @click="toggleAddForm" - > - {{ $options.i18n.addChildButtonLabel }} - </gl-button> - <work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" /> </div> + <work-item-links-form + v-if="isShownAddForm" + data-testid="add-links-form" + :issuable-gid="issuableGid" + :children-ids="childrenIds" + @cancel="toggleAddForm" + @addWorkItemChild="addChild" + /> <div v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base" + class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" data-testid="links-child" > <div> <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" /> <span class="gl-word-break-all">{{ child.title }}</span> </div> - <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0"> + <div + class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center" + > <gl-badge :variant="badgeVariant(child.state)"> <span class="gl-sm-display-block">{{ $options.WORK_ITEM_STATUS_TEXT[child.state] }}</span> </gl-badge> + <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" /> </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 22728f58026..fadba0753db 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -1,27 +1,127 @@ <script> -import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; export default { components: { + GlAlert, GlForm, - GlFormInput, + GlFormCombobox, GlButton, }, + inject: ['projectPath'], + props: { + issuableGid: { + type: String, + required: false, + default: null, + }, + childrenIds: { + type: Array, + required: false, + default: () => [], + }, + }, + apollo: { + availableWorkItems: { + query: projectWorkItemsQuery, + variables() { + return { + projectPath: this.projectPath, + searchTerm: this.search?.title || this.search, + types: ['TASK'], + }; + }, + skip() { + return this.search.length === 0; + }, + update(data) { + return data.workspace.workItems.edges + .filter((wi) => !this.childrenIds.includes(wi.node.id)) + .map((wi) => wi.node); + }, + }, + }, data() { return { - relatedWorkItem: '', + availableWorkItems: [], + search: '', + error: null, }; }, + methods: { + getIdFromGraphQLId, + unsetError() { + this.error = null; + }, + addChild() { + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.issuableGid, + hierarchyWidget: { + childrenIds: [this.search.id], + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate?.errors?.length) { + [this.error] = data.workItemUpdate.errors; + } else { + this.unsetError(); + this.$emit('addWorkItemChild', this.search); + } + }) + .catch(() => { + this.error = this.$options.i18n.errorMessage; + }) + .finally(() => { + this.search = ''; + }); + }, + }, + i18n: { + inputLabel: __('Children'), + errorMessage: s__( + 'WorkItem|Something went wrong when trying to add a child. Please try again.', + ), + }, }; </script> <template> - <gl-form @submit.prevent> - <gl-form-input v-model="relatedWorkItem" class="gl-mb-4" /> - <gl-button type="submit" category="secondary" variant="confirm"> - {{ s__('WorkItem|Add') }} + <gl-form + class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base" + > + <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> + {{ error }} + </gl-alert> + <gl-form-combobox + v-model="search" + :token-list="availableWorkItems" + match-value-to-attr="title" + class="gl-mb-4" + :label-text="$options.i18n.inputLabel" + label-sr-only + autofocus + > + <template #result="{ item }"> + <div class="gl-display-flex"> + <div class="gl-text-gray-400 gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div> + <div>{{ item.title }}</div> + </div> + </template> + </gl-form-combobox> + <gl-button category="secondary" data-testid="add-child-button" @click="addChild"> + {{ s__('WorkItem|Add task') }} </gl-button> - <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')"> + <gl-button category="tertiary" @click="$emit('cancel')"> {{ s__('WorkItem|Cancel') }} </gl-button> </gl-form> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue new file mode 100644 index 00000000000..6deb87c5dca --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -0,0 +1,101 @@ +<script> +import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { produce } from 'immer'; +import { s__ } from '~/locale'; +import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql'; +import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import { WIDGET_TYPE_HIERARCHY } from '../../constants'; + +export default { + components: { + GlDropdownItem, + GlDropdown, + GlIcon, + }, + props: { + workItemId: { + type: String, + required: true, + }, + parentWorkItemId: { + type: String, + required: true, + }, + }, + data() { + return { + activeToast: null, + }; + }, + methods: { + toggleChildFromCache(data, store) { + const sourceData = store.readQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.parentWorkItemId }, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex( + (child) => child.id === this.workItemId, + ); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem); + } + }); + + store.writeQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.parentWorkItemId }, + data: newData, + }); + }, + async addChild(data) { + const { data: resp } = await this.$apollo.mutate({ + mutation: changeWorkItemParentMutation, + variables: { id: this.workItemId, parentId: this.parentWorkItemId }, + update: this.toggleChildFromCache.bind(this, data), + }); + + if (resp.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + }, + async removeChild() { + const { data } = await this.$apollo.mutate({ + mutation: changeWorkItemParentMutation, + variables: { id: this.workItemId, parentId: null }, + update: this.toggleChildFromCache.bind(this, null), + }); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.addChild.bind(this, data), + }, + }); + } + }, + }, +}; +</script> + +<template> + <span class="gl-ml-2"> + <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true"> + <template #button-content> + <gl-icon name="ellipsis_v" :size="14" /> + </template> + <gl-dropdown-item @click="removeChild"> + {{ s__('WorkItem|Remove') }} + </gl-dropdown-item> + </gl-dropdown> + </span> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue index b0f2b3aa14a..30e2c1e56b8 100644 --- a/app/assets/javascripts/work_items/components/work_item_weight.vue +++ b/app/assets/javascripts/work_items/components/work_item_weight.vue @@ -1,26 +1,142 @@ <script> +import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { TRACKING_CATEGORY_SHOW } from '../constants'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; + +/* eslint-disable @gitlab/require-i18n-strings */ +const allowedKeys = [ + 'Alt', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'Backspace', + 'Control', + 'Delete', + 'End', + 'Enter', + 'Home', + 'Meta', + 'PageDown', + 'PageUp', + 'Tab', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', +]; +/* eslint-enable @gitlab/require-i18n-strings */ export default { + inputId: 'weight-widget-input', + components: { + GlForm, + GlFormGroup, + GlFormInput, + }, + mixins: [Tracking.mixin()], inject: ['hasIssueWeightsFeature'], props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, weight: { type: Number, required: false, default: undefined, }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + isEditing: false, + }; }, computed: { - weightText() { - return this.weight ?? __('None'); + placeholder() { + return this.canUpdate && this.isEditing ? __('Enter a number') : __('None'); + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_weight', + property: `type_${this.workItemType}`, + }; + }, + type() { + return this.canUpdate && this.isEditing ? 'number' : 'text'; + }, + }, + methods: { + blurInput() { + this.$refs.input.$el.blur(); + }, + handleFocus() { + this.isEditing = true; + }, + handleKeydown(event) { + if (!allowedKeys.includes(event.key)) { + event.preventDefault(); + } + }, + updateWeight(event) { + this.isEditing = false; + this.track('updated_weight'); + this.$apollo.mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + weight: event.target.value === '' ? null : Number(event.target.value), + }, + }, + }); }, }, }; </script> <template> - <div v-if="hasIssueWeightsFeature" class="gl-mb-5"> - <span class="gl-display-inline-block gl-font-weight-bold gl-w-15">{{ __('Weight') }}</span> - {{ weightText }} - </div> + <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput"> + <gl-form-group + class="gl-align-items-center" + :label="__('Weight')" + :label-for="$options.inputId" + label-class="gl-pb-0! gl-overflow-wrap-break" + label-cols="3" + label-cols-lg="2" + > + <gl-form-input + :id="$options.inputId" + ref="input" + min="0" + :placeholder="placeholder" + :readonly="!canUpdate" + size="sm" + :type="type" + :value="weight" + @blur="updateWeight" + @focus="handleFocus" + @keydown="handleKeydown" + @keydown.exact.esc.stop="blurInput" + /> + </gl-form-group> + </gl-form> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2df4978a319..2140b418e6d 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -13,12 +13,14 @@ export const i18n = { updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), }; -export const DEFAULT_MODAL_TYPE = 'Task'; +export const TASK_TYPE_NAME = 'Task'; -export const WIDGET_TYPE_ASSIGNEE = 'ASSIGNEES'; +export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; +export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; export const WIDGET_TYPE_TASK_ICON = 'task-done'; diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql new file mode 100644 index 00000000000..dc5286174d8 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql @@ -0,0 +1,13 @@ +mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) { + workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) { + workItem { + id + workItemType { + id + } + title + state + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql index b25210f5c74..ccfe62cc585 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql @@ -1,8 +1,12 @@ +#import "./work_item.fragment.graphql" + mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { workItemCreateFromTask(input: $input) { workItem { - id - descriptionHtml + ...WorkItem + } + newWorkItem { + ...WorkItem } errors } diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql index 0d31ecef6f8..43c92cf89ec 100644 --- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql @@ -1,6 +1,6 @@ #import "./work_item.fragment.graphql" -mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) { +mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) { localUpdateWorkItem(input: $input) @client { workItem { ...WorkItem 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 new file mode 100644 index 00000000000..7d38d203b84 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -0,0 +1,14 @@ +query projectWorkItems($searchTerm: String, $projectPath: ID!, $types: [IssueType!]) { + workspace: project(fullPath: $projectPath) { + id + workItems(search: $searchTerm, types: $types) { + edges { + node { + id + title + state + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 09d929faae2..8788ad21e7b 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -2,7 +2,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { WIDGET_TYPE_ASSIGNEE } from '../constants'; +import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants'; import typeDefs from './typedefs.graphql'; import workItemQuery from './work_item.query.graphql'; @@ -10,7 +10,7 @@ export const temporaryConfig = { typeDefs, cacheConfig: { possibleTypes: { - LocalWorkItemWidget: ['LocalWorkItemAssignees'], + LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'], }, typePolicies: { WorkItem: { @@ -20,33 +20,15 @@ export const temporaryConfig = { return ( widgets || [ { - __typename: 'LocalWorkItemAssignees', - type: 'ASSIGNEES', - nodes: [ - { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - avatarUrl: '', - webUrl: '', - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'John Doe', - username: 'doe_I', - }, - { - __typename: 'UserCore', - id: 'gid://gitlab/User/2', - avatarUrl: '', - webUrl: '', - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Marcus Rutherford', - username: 'ruthfull', - }, - ], + __typename: 'LocalWorkItemLabels', + type: WIDGET_TYPE_LABELS, + allowScopedLabels: true, + nodes: [], }, { __typename: 'LocalWorkItemWeight', type: 'WEIGHT', - weight: 0, + weight: null, }, ] ); @@ -67,12 +49,26 @@ export const resolvers = { }); const data = produce(sourceData, (draftData) => { - const assigneesWidget = draftData.workItem.mockWidgets.find( - (widget) => widget.type === WIDGET_TYPE_ASSIGNEE, - ); - assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) => - input.assigneeIds.includes(assignee.id), - ); + if (input.assignees) { + const assigneesWidget = draftData.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_ASSIGNEES, + ); + assigneesWidget.assignees.nodes = [...input.assignees]; + } + + if (input.weight != null) { + const weightWidget = draftData.workItem.mockWidgets.find( + (widget) => widget.type === WIDGET_TYPE_WEIGHT, + ); + weightWidget.weight = input.weight; + } + + if (input.labels) { + const labelsWidget = draftData.workItem.mockWidgets.find( + (widget) => widget.type === WIDGET_TYPE_LABELS, + ); + labelsWidget.nodes = [...input.labels]; + } }); cache.writeQuery({ diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index bfe2f0fe0ce..48228b15a53 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,5 +1,6 @@ enum LocalWidgetType { ASSIGNEES + LABELS WEIGHT } @@ -12,6 +13,12 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget { nodes: [UserCore] } +type LocalWorkItemLabels implements LocalWorkItemWidget { + type: LocalWidgetType! + allowScopedLabels: Boolean! + nodes: [Label!] +} + type LocalWorkItemWeight implements LocalWorkItemWidget { type: LocalWidgetType! weight: Int @@ -21,9 +28,11 @@ extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } -type LocalWorkItemAssigneesInput { +input LocalUpdateWorkItemInput { id: WorkItemID! - assigneeIds: [ID!] + assignees: [UserCore!] + labels: [Label] + weight: Int } type LocalWorkItemPayload { @@ -32,5 +41,5 @@ type LocalWorkItemPayload { } extend type Mutation { - localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload + localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalWorkItemPayload } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index c0b6e856411..25eb8099251 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -5,5 +5,6 @@ mutation workItemUpdate($input: WorkItemUpdateInput!) { workItem { ...WorkItem } + errors } } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql index 470de060ee3..ad861a60d15 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql @@ -1,8 +1,13 @@ +#import "./work_item.fragment.graphql" + mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { workItemUpdate: workItemUpdateTask(input: $input) { workItem { id descriptionHtml } + task { + ...WorkItem + } } } 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 04701f6899e..5f64eda96aa 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment WorkItem on WorkItem { id title @@ -17,5 +19,29 @@ fragment WorkItem on WorkItem { description descriptionHtml } + ... on WorkItemWidgetAssignees { + type + allowsMultipleAssignees + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetHierarchy { + type + parent { + id + iid + title + } + children { + edges { + node { + id + } + } + } + } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 30bc61f5c59..61cb8802187 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,17 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" #import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem mockWidgets @client { - ... on LocalWorkItemAssignees { + ... on LocalWorkItemLabels { type + allowScopedLabels nodes { - id - avatarUrl - name - username - webUrl + ...Label } } ... on LocalWorkItemWeight { diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 33e28831b54..6437df597b4 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -10,6 +10,7 @@ export const initWorkItemsRoot = () => { return new Vue({ el, + name: 'WorkItemsRoot', router: createRouter(el.dataset.fullPath), apolloProvider: createApolloProvider(), provide: { 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 04c6a61689c..482da5419c6 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -6,12 +6,11 @@ import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; -import { DEFAULT_MODAL_TYPE } from '../constants'; import ItemTitle from '../components/item_title.vue'; export default { - createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'), fetchTypesErrorText: s__( 'WorkItem|Something went wrong when fetching work item types. Please try again', ), @@ -24,11 +23,6 @@ export default { }, inject: ['fullPath'], props: { - isModal: { - type: Boolean, - required: false, - default: false, - }, initialTitle: { type: String, required: false, @@ -78,13 +72,6 @@ export default { text: node.name, })); }, - result() { - if (!this.selectedWorkItemType && this.isModal) { - this.selectedWorkItemType = this.formOptions.find( - (options) => options.text === DEFAULT_MODAL_TYPE, - )?.value; - } - }, error() { this.error = this.$options.fetchTypesErrorText; }, @@ -104,11 +91,7 @@ export default { methods: { async createWorkItem() { this.loading = true; - if (this.isModal) { - await this.createWorkItemFromTask(); - } else { - await this.createStandaloneWorkItem(); - } + await this.createStandaloneWorkItem(); this.loading = false; }, async createStandaloneWorkItem() { @@ -174,11 +157,7 @@ export default { this.title = title; }, handleCancelClick() { - if (!this.isModal) { - this.$router.go(-1); - return; - } - this.$emit('closeModal'); + this.$router.go(-1); }, }, }; @@ -187,7 +166,7 @@ export default { <template> <form @submit.prevent="createWorkItem"> <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert> - <div :class="{ 'gl-px-5': isModal }" data-testid="content"> + <div data-testid="content"> <item-title :title="initialTitle" data-testid="title-input" @title-input="handleTitleInput" /> <div> <gl-loading-icon @@ -203,14 +182,11 @@ export default { /> </div> </div> - <div - class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4" - :class="{ 'gl-display-flex gl-justify-content-end': isModal }" - > + <div class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4"> <gl-button variant="confirm" :disabled="isButtonDisabled" - :class="{ 'gl-mr-3': !isModal }" + class="gl-mr-3" :loading="loading" data-testid="create-button" type="submit" @@ -221,7 +197,6 @@ export default { type="button" data-testid="cancel-button" class="gl-order-n1" - :class="{ 'gl-mr-3': isModal }" @click="handleCancelClick" > {{ __('Cancel') }} diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 092cf643e0f..be72ec33465 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,5 +1,6 @@ @import './pages/branches'; @import './pages/clusters'; +@import './pages/colors'; @import './pages/commits'; @import './pages/deploy_keys'; @import './pages/detail_page'; @@ -8,6 +9,7 @@ @import './pages/events'; @import './pages/groups'; @import './pages/help'; +@import './pages/hierarchy'; @import './pages/issuable'; @import './pages/issues'; @import './pages/labels'; @@ -25,9 +27,8 @@ @import './pages/registry'; @import './pages/search'; @import './pages/service_desk'; -@import './pages/settings'; @import './pages/settings_ci_cd'; +@import './pages/settings'; @import './pages/storage_quota'; @import './pages/tree'; @import './pages/users'; -@import './pages/hierarchy'; diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 870ed50c6eb..1b6a0208ca7 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -106,11 +106,14 @@ } } -.table-dropdown .dropdown-menu { +.content-editor-dropdown .dropdown-menu { + width: auto !important; + @include gl-min-w-0; - @include gl-w-auto; - @include gl-white-space-nowrap; + button { + @include gl-white-space-nowrap; + } } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index ced62926218..37f92d3cf3d 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -506,8 +506,7 @@ max-width: unset; } - .no-emoji-placeholder, - .clear-user-status { + .no-emoji-placeholder { svg { fill: var(--gray-500, $gray-500); } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index a8e740525e2..f27a36d1966 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -60,6 +60,13 @@ @include icon-styles($gray-500, $gray-100); } +.password-status-icon-success { + svg { + vertical-align: middle; + fill: $green-500; + } +} + .icon-link { &:hover { text-decoration: none; @@ -67,6 +74,7 @@ } .user-avatar-link { + display: inline-block; text-decoration: none; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index eeffc4fc21b..1e921b4234e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -432,7 +432,6 @@ $gl-input-padding: 10px; $gl-vert-padding: 6px; $gl-padding-top: 10px; $gl-sidebar-padding: 22px; -$gl-bar-padding: 3px; $input-horizontal-padding: 12px; $browser-scrollbar-size: 10px; diff --git a/app/assets/stylesheets/highlight/hljs.scss b/app/assets/stylesheets/highlight/hljs.scss index 2e31e7c1f6d..e1bc23852a4 100644 --- a/app/assets/stylesheets/highlight/hljs.scss +++ b/app/assets/stylesheets/highlight/hljs.scss @@ -37,6 +37,10 @@ &.class_ { color: var(--color-hljs-class); + + &.inherited__ { + color: var(--color-hljs-variable); + } } &.function_ { diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index f4d9909d81f..709e7f5ae18 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -188,7 +188,11 @@ $dark-il: #de935f; .diff-line-num.new, .line-coverage.new, .line-codequality.new, - .line_content.new { + .line_content.new, + .diff-line-num.new-nomappinginraw, + .line-coverage.new-nomappinginraw, + .line-codequality.new-nomappinginraw, + .line_content.new-nomappinginraw { @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border); &::before, @@ -200,7 +204,11 @@ $dark-il: #de935f; .diff-line-num.old, .line-coverage.old, .line-codequality.old, - .line_content.old { + .line_content.old, + .diff-line-num.old-nomappinginraw, + .line-coverage.old-nomappinginraw, + .line-codequality.old-nomappinginraw, + .line_content.old-nomappinginraw { @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border); &::before, diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index dfa32d4b773..0ed9c209417 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -100,6 +100,8 @@ $monokai-gh: #75715e; // We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251) @include hljs-override('string', $monokai-s); @include hljs-override('attr', $monokai-na); + @include hljs-override('attribute', $monokai-n); + @include hljs-override('selector-tag', $monokai-nt); @include hljs-override('keyword', $monokai-k); @include hljs-override('variable', $monokai-nv); @include hljs-override('variable.language_', $monokai-k); @@ -113,7 +115,8 @@ $monokai-gh: #75715e; @include hljs-override('section', $monokai-gh); @include hljs-override('bullet', $monokai-n); @include hljs-override('subst', $monokai-p); - @include hljs-override('symbol', $monokai-ni); + @include hljs-override('symbol', $monokai-ss); + @include hljs-override('title.class_.inherited__', $monokai-no); // Line numbers .file-line-num { @@ -178,7 +181,11 @@ $monokai-gh: #75715e; .diff-line-num.new, .line-coverage.new, .line-codequality.new, - .line_content.new { + .line_content.new, + .diff-line-num.new-nomappinginraw, + .line-coverage.new-nomappinginraw, + .line-codequality.new-nomappinginraw, + .line_content.new-nomappinginraw { @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); &::before, @@ -190,7 +197,11 @@ $monokai-gh: #75715e; .diff-line-num.old, .line-coverage.old, .line-codequality.old, - .line_content.old { + .line_content.old, + .diff-line-num.old-nomappinginraw, + .line-coverage.old-nomappinginraw, + .line-codequality.old-nomappinginraw, + .line_content.old-nomappinginraw { @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); &::before, diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index f70c53c9eaa..868e466b1f8 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -75,7 +75,9 @@ .line-coverage, .line-codequality { &.old, - &.new { + &.new, + &.new-nomappinginraw, + &.old-nomappinginraw { background-color: $white-normal; } } @@ -131,7 +133,7 @@ } .line_content { - &.old { + &.old, &.old-nomappinginraw { background-color: $white-normal; &::before { @@ -144,7 +146,7 @@ } } - &.new { + &.new, &.new-nomappinginraw { background-color: $white-normal; &::before { diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 73aa6275d17..6260339a48d 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -103,6 +103,8 @@ $solarized-dark-il: #2aa198; // We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251) @include hljs-override('string', $solarized-dark-s); @include hljs-override('attr', $solarized-dark-na); + @include hljs-override('attribute', $solarized-dark-n); + @include hljs-override('selector-tag', $solarized-dark-nt); @include hljs-override('keyword', $solarized-dark-k); @include hljs-override('variable', $solarized-dark-nv); @include hljs-override('variable.language_', $solarized-dark-k); @@ -117,7 +119,8 @@ $solarized-dark-il: #2aa198; @include hljs-override('bullet', $solarized-dark-n); @include hljs-override('subst', $solarized-dark-p); @include hljs-override('symbol', $solarized-dark-ni); - + @include hljs-override('title.class_.inherited__', $solarized-dark-no); + // Line numbers .file-line-num { @include line-number-link($solarized-dark-line-color); @@ -189,7 +192,11 @@ $solarized-dark-il: #2aa198; .diff-line-num.new, .line-coverage.new, .line-codequality.new, - .line_content.new { + .line_content.new, + .diff-line-num.new-nomappinginraw, + .line-coverage.new-nomappinginraw, + .line-codequality.new-nomappinginraw, + .line_content.new-nomappinginraw { @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); &::before, @@ -201,7 +208,11 @@ $solarized-dark-il: #2aa198; .diff-line-num.old, .line-coverage.old, .line-codequality.old, - .line_content.old { + .line_content.old, + .diff-line-num.old-nomappinginraw, + .line-coverage.old-nomappinginraw, + .line-codequality.old-nomappinginraw, + .line_content.old-nomappinginraw { @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); &::before, diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 74448317270..e6f098f4cdf 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -106,6 +106,7 @@ $solarized-light-il: #2aa198; } .code.solarized-light { + @include hljs-override('title.class_.inherited__', $solarized-light-no); // Line numbers .file-line-num { @include line-number-link($solarized-light-line-color); @@ -169,7 +170,11 @@ $solarized-light-il: #2aa198; .diff-line-num.new, .line-coverage.new, .line-codequality.new, - .line_content.new { + .line_content.new, + .diff-line-num.new-nomappinginraw, + .line-coverage.new-nomappinginraw, + .line-codequality.new-nomappinginraw, + .line_content.new-nomappinginraw { @include diff-background($solarized-light-new-bg, $solarized-light-new-idiff, $solarized-light-border); @@ -190,7 +195,11 @@ $solarized-light-il: #2aa198; .diff-line-num.old, .line-coverage.old, .line-codequality.old, - .line_content.old { + .line_content.old, + .diff-line-num.old-nomappinginraw, + .line-coverage.old-nomappinginraw, + .line-codequality.old-nomappinginraw, + .line_content.old-nomappinginraw { @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); &::before, diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss index 8698e448c94..b0f6595feff 100644 --- a/app/assets/stylesheets/highlight/themes/white.scss +++ b/app/assets/stylesheets/highlight/themes/white.scss @@ -2,6 +2,9 @@ @import '../white_base'; @include conflict-colors('white'); + @include hljs-override('variable', $white-nv); + @include hljs-override('symbol', $white-ss); + @include hljs-override('title.class_.inherited__', $white-no); } :root { diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index aac8ccde96e..770a90bbc57 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -158,7 +158,8 @@ pre.code, } .diff-line-num { - &.old { + &.old, + &.old-nomappinginraw { background-color: $line-number-old; a { @@ -166,7 +167,8 @@ pre.code, } } - &.new { + &.new, + &.new-nomappinginraw { background-color: $line-number-new; a { @@ -204,7 +206,8 @@ pre.code, } .line_content { - &.old { + &.old, + &.old-nomappinginraw { background-color: $line-removed; &::before { @@ -216,7 +219,8 @@ pre.code, } } - &.new { + &.new, + &.new-nomappinginraw { background-color: $line-added; &::before { @@ -243,11 +247,13 @@ pre.code, .line-coverage, .line-codequality { - &.old { + &.old, + &.old-nomappinginraw { background-color: $line-removed; } - &.new { + &.new, + &.new-nomappinginraw { background-color: $line-added; } diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index b8cbe64df38..da120b5fb4e 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -2,8 +2,6 @@ // Do not use 3-letter hex codes, bgcolor vs css background-color is problematic in emails // -// stylelint-disable color-hex-length - $mailer-font: 'Helvetica Neue', Helvetica, Arial, sans-serif; $mailer-text-color: #333; $mailer-bg-color: #fafafa; diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss index 57053c7f0cb..d93b4f75d77 100644 --- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss @@ -1,9 +1,3 @@ - -// stylelint-disable selector-class-pattern -// stylelint-disable selector-max-compound-selectors -// stylelint-disable stylelint-gitlab/duplicate-selectors -// stylelint-disable stylelint-gitlab/utility-classes - .blob-editor-container { flex: 1; height: 0; diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index 25a565ce2ba..c584bbaac09 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -64,10 +64,6 @@ border-color: var(--ide-input-border, $gray-darkest); } } - - a.gl-tab-nav-item-active { - box-shadow: inset 0 -2px 0 0 var(--ide-input-border, $gray-darkest); - } } .drag-handle:hover { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 14873c54cd7..1b27e51e793 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -46,9 +46,7 @@ $tabs-holder-z-index: 250; position: -webkit-sticky; position: sticky; // Unitless zero values are not allowed in calculations - // stylelint-disable-next-line length-zero-no-unit top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px)); - // stylelint-disable-next-line length-zero-no-unit max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); .drag-handle { @@ -632,6 +630,24 @@ $tabs-holder-z-index: 250; height: 24px; } +.mr-widget-extension-icon::after { + @include gl-content-empty; + @include gl-absolute; + @include gl-rounded-full; + + top: 4px; + left: 4px; + width: 16px; + height: 16px; + border: 4px solid currentColor; +} + +.mr-widget-extension-icon svg { + position: relative; + top: 2px; + left: 2px; +} + .mr-widget-heading { position: relative; border: 1px solid var(--border-color, $border-color); diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index ddc638197ca..91fd2d42657 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -70,7 +70,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi .timeline-header-blank, .timeline-header-item { - @include float-left; + @include gl-float-left; height: $header-item-height; border-bottom: $border-style; background-color: var(--white, $white); @@ -150,7 +150,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi .details-cell, .timeline-cell { - @include float-left; + @include gl-float-left; height: $item-height; } diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss index 0bc3cc6678c..eec5ebdb383 100644 --- a/app/assets/stylesheets/page_bundles/project.scss +++ b/app/assets/stylesheets/page_bundles/project.scss @@ -128,10 +128,6 @@ > li { display: inline-block; - &:not(:last-child) { - margin-right: $gl-padding; - } - &.right { vertical-align: top; margin-top: 0; @@ -179,7 +175,6 @@ } .btn { - margin-bottom: $gl-padding-8; padding: $gl-btn-vert-padding $gl-btn-padding; line-height: $gl-btn-line-height; @@ -190,12 +185,6 @@ } } -.project-buttons { - .nav > li:not(:last-child) { - margin-right: $gl-padding-8; - } -} - .git-empty { margin-bottom: 7px; diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss new file mode 100644 index 00000000000..9220fa82b46 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -0,0 +1,35 @@ +@import 'mixins_and_variables_and_functions'; + +.gl-token-selector-token-container { + display: flex; + align-items: center; +} + +#weight-widget-input:not(:hover, :focus), +#weight-widget-input[readonly] { + box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white); +} + +#weight-widget-input[readonly] { + background-color: var(--white, $white); +} + +.work-item-assignees { + .assign-myself { + display: none; + } + + .assignees-selector:hover .assign-myself { + display: block; + } +} + +.work-item-labels { + .gl-token { + padding-left: $gl-spacing-scale-1; + } + + .gl-token-close { + display: none; + } +} diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss index d34d309eea3..18158fab75f 100644 --- a/app/assets/stylesheets/pages/branches.scss +++ b/app/assets/stylesheets/pages/branches.scss @@ -1,9 +1,3 @@ -.content-list > .branch-item, -.branch-title { - display: flex; - align-items: center; -} - .branch-info { flex: auto; min-width: 0; diff --git a/app/assets/stylesheets/pages/colors.scss b/app/assets/stylesheets/pages/colors.scss new file mode 100644 index 00000000000..20e072b9903 --- /dev/null +++ b/app/assets/stylesheets/pages/colors.scss @@ -0,0 +1,24 @@ +.color-item { + @include gl-align-items-center; + @include gl-display-flex; +} + +.color-item-color { + @include gl-flex-shrink-0; + @include gl-mr-3; + @include gl-top-0; +} + +.right-sidebar-collapsed { + .color-item { + @include gl-pt-3; + } + + .color-item-color { + margin: 0; + } + + .color-item-text { + display: none; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 80b9e378252..c96d8ecc782 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -95,8 +95,14 @@ } } -.commits-row + .commits-row { - border-top: 1px solid $white-normal; +.commits-row { + + .commits-row { + border-top: 1px solid $white-normal; + } + + + .commits-empty { + display: none; + } } .text-expander { @@ -133,18 +139,6 @@ } } -.commit-detail { - display: flex; - justify-content: space-between; - align-items: start; - flex-grow: 1; - min-width: 0; - - .project-namespace { - color: $gl-text-color-tertiary; - } -} - .commit-content { padding-right: 10px; white-space: normal; diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 96ca9fbcb43..2e1bb9b9eac 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -209,7 +209,6 @@ table.pipeline-project-metrics tr td { } .title { - margin-top: -$gl-padding-8; // negative margin required for flex-wrap font-size: $gl-font-size; } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f3182af3047..51f964a4b70 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -176,11 +176,16 @@ } .gutter-toggle { + display: flex; + align-items: center; margin-left: 20px; - padding-left: 10px; + padding: 4px; + border-radius: 4px; + height: 24px; &:hover { color: $gl-text-color; + background: $gray-50; } &:hover, @@ -291,7 +296,7 @@ @include media-breakpoint-up(lg) { padding: 0; - form { + .issuable-context-form { --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); --top: var(--initial-top); @@ -338,7 +343,6 @@ } .gutter-toggle { - border-left: 1px solid $border-gray-normal; text-align: center; } @@ -405,8 +409,8 @@ width: 100%; height: $sidebar-toggle-height; margin-left: 0; - padding-left: 0; border-bottom: 1px solid $border-white-normal; + border-radius: 0; } a.gutter-toggle { @@ -709,10 +713,6 @@ line-height: 20px; padding: 0; } - - .issue-updated-at { - line-height: 20px; - } } @include media-breakpoint-down(xs) { @@ -736,7 +736,7 @@ .issuable-milestone, .issuable-info, .task-status, - .issuable-updated-at { + .issuable-timestamp { font-weight: $gl-font-weight-normal; color: $gl-text-color-secondary; @@ -991,4 +991,19 @@ bottom: -10%; } } + + &.timeline-event-note-form { + &::before { + top: -15% !important; // Override default positioning + height: 20%; + } + + &::after { + content: none; + } + } +} + +.timeline-event-note-form { + padding-left: 20px; } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 04e0ef6631e..c0a283ec643 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -124,8 +124,16 @@ ul.related-merge-requests > li gl-emoji { .new-branch-col { .discussion-filter-container { - &:not(:only-child) { - margin-right: $gl-padding-8; + &:not(:last-child) { + margin-right: $gl-spacing-scale-3; + } + } + + @include media-breakpoint-down(xs) { + width: 100%; + + > div:not(:last-child) { + margin-bottom: $gl-spacing-scale-3; } } } @@ -147,6 +155,16 @@ ul.related-merge-requests > li gl-emoji { .btn-group:not(.hidden) { display: flex; + + @include media-breakpoint-down(xs) { + .btn.btn-confirm { + @include gl-justify-content-start; + + &.dropdown-toggle { + @include gl-flex-grow-0; + } + } + } } .js-create-merge-request { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index a3fbedd87a9..96fe6caeea2 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -349,3 +349,36 @@ $comparison-empty-state-height: 62px; } } } + +.mr-experience-survey-wrapper { + // setting this explicitly because: + // diff-files-holder has z-index 203 + // z-index 9999 utility class breaks tooltips + z-index: 210; +} + +.mr-experience-survey-body { + width: 300px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.mr-experience-survey-legal { + order: 1; +} + +.mr-experience-survey-logo { + width: 16px; + + svg { + max-width: 100%; + } +} + +.survey-slide-up-enter { + transform: translateY(10px); + opacity: 0; +} + +.survey-slide-up-enter-active { + @include gl-transition-slow; +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 82e96dee4c6..4d0cf30a3b2 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -70,41 +70,6 @@ $system-note-svg-size: 16px; } } - .replies-toggle { - background-color: $gray-light; - padding: $gl-padding-8 $gl-padding; - border-top: 1px solid $gray-100; - border-bottom: 1px solid $gray-100; - - .collapse-replies-btn:hover { - color: $blue-600; - } - - &.collapsed { - color: $gl-text-color-secondary; - border-radius: 0 0 $border-radius-default $border-radius-default; - - img { - margin: -2px 4px 0 0; - } - - .author-link { - color: $gl-text-color; - } - } - - .user-avatar-link { - &:last-child img { - margin-right: $gl-padding-8; - } - } - - .btn-link { - border: 0; - vertical-align: baseline; - } - } - .discussion-toggle-replies { border-top: 0; border-radius: 4px 4px 0 0; diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index 518ec181e5e..c7d7aacceec 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -1,6 +1,6 @@ .application-theme { - $ui-gray-bg: #2e2e2e; - $ui-light-gray-bg: #dfdfdf; + $ui-gray-bg: #303030; + $ui-light-gray-bg: #f0f0f0; $ui-dark-mode-bg: #1f1f1f; .preview { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 3b76130dd1a..0d45beab983 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -548,14 +548,6 @@ pre.light-well { } } -.new-protected-branch, -.new-protected-tag { - label { - margin-top: 6px; - font-weight: $gl-font-weight-normal; - } -} - .protected-branches-list, .protected-tags-list { margin-bottom: 30px; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 8755db83d35..f1865a7dc40 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -4,7 +4,8 @@ $search-sidebar-min-width: 240px; $search-sidebar-max-width: 300px; $search-input-field-x-min-width: 200px; $search-input-field-min-width: 320px; -$search-input-field-max-width: 600px; +$search-input-field-max-width: 640px; +$search-keyboard-shortcut: '/'; $border-radius-medium: 3px; @@ -67,54 +68,53 @@ input[type='checkbox']:hover { } } -// This is a temporary workaround! -// the button in GitLab UI Search components need to be updated to not be the small size -// see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 -.header-search .gl-search-box-by-type-clear.btn-sm { - padding: 0.5rem !important; -} - .header-search { min-width: $search-input-field-min-width; + // This is a temporary workaround! + // the button in GitLab UI Search components need to be updated to not be the small size + // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 + .gl-search-box-by-type-clear.btn-sm { + padding: 0.5rem !important; + } + @include media-breakpoint-between(md, lg) { min-width: $search-input-field-x-min-width; } - input, - svg { - transition: border-color ease-in-out $default-transition-duration, - background-color ease-in-out $default-transition-duration; + &.is-active { + &.is-searching { + .in-search-scope-help { + position: absolute; + top: $gl-spacing-scale-2; + right: 2.125rem; + z-index: 2; + } + } } - &.is-not-active { - .btn.gl-clear-icon-button { + &.is-not-searching { + .in-search-scope-help { + display: none; + } + } + + .keyboard-shortcut-helper { + transform: translateY(calc(50% - 2px)); + box-shadow: none; + border-color: transparent; + } + + &.is-active { + .keyboard-shortcut-helper { display: none; } + } - &::after { - content: '/'; - display: inline-block; - position: absolute; - top: 0; - right: 8px; - transform: translateY(calc(50% - 4px)); - padding: 4px 5px; - font-size: $gl-font-size-small; - font-family: $monospace-font; - line-height: 1; - vertical-align: middle; - border-width: 0; - border-style: solid; - border-image: none; - border-radius: $border-radius-medium; - box-shadow: none; - white-space: pre-wrap; - box-sizing: border-box; - // Safari - word-wrap: break-word; - overflow-wrap: break-word; - word-break: keep-all; + &.is-not-active { + .btn.gl-clear-icon-button, + .in-search-scope-help { + display: none; } } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 0c7b74684cc..935595d1b3b 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -28,6 +28,7 @@ &:first-of-type { margin-top: 10px; + padding-top: 0; border: 0; } @@ -156,39 +157,33 @@ } .visibility-level-setting { - .form-check { - margin-bottom: 10px; - - .option-title { - font-weight: $gl-font-weight-normal; - display: inline-block; - color: $gl-text-color; - vertical-align: top; - } + .option-title { + font-weight: $gl-font-weight-normal; + display: inline-block; + color: $gl-text-color; + vertical-align: top; + } - .option-description, - .option-disabled-reason { - margin-left: 20px; - color: $project-option-descr-color; - margin-top: -5px; + .option-description, + .option-disabled-reason { + color: $project-option-descr-color; + } + + .option-disabled-reason { + display: none; + } + + .disabled { + svg { + opacity: 0.5; } - .option-disabled-reason { + .option-description { display: none; } - &.disabled { - svg { - opacity: 0.5; - } - - .option-description { - display: none; - } - - .option-disabled-reason { - display: block; - } + .option-disabled-reason { + display: block; } } } @@ -320,7 +315,8 @@ } .ci-variable-table, -.deploy-freeze-table { +.deploy-freeze-table, +.ci-secure-files-table { table { thead { border-bottom: 1px solid $white-normal; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 4cefa60b12a..801c9ea828f 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -2,10 +2,16 @@ // Please see the feedback issue for more details and help: // https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; +:root { + color-scheme: dark; +} body.gl-dark { + --gray-10: #1f1f1f; --gray-50: #303030; --gray-100: #404040; + --gray-200: #525252; --gray-600: #bfbfbf; + --gray-700: #dbdbdb; --gray-900: #fafafa; --green-100: #0d532a; --green-700: #91d4a8; @@ -61,6 +67,11 @@ a:not([href]):not([class]) { color: inherit; text-decoration: none; } +kbd { + font-family: "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; @@ -105,6 +116,18 @@ button::-moz-focus-inner, padding-left: 0; list-style: none; } +kbd { + padding: 0.2rem 0.4rem; + font-size: 90%; + color: #333; + background-color: #fafafa; + border-radius: 0.2rem; +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: 600; +} .container-fluid { width: 100%; padding-right: 15px; @@ -740,6 +763,22 @@ input { .form-control::placeholder { color: #868686; } +kbd { + display: inline-block; + padding: 3px 5px; + font-size: 0.6875rem; + line-height: 10px; + color: var(--gray-700, #dbdbdb); + vertical-align: middle; + background-color: var(--gray-10, #1f1f1f); + border-width: 1px; + border-style: solid; + border-color: var(--gray-100, #404040) var(--gray-100, #404040) + var(--gray-200, #525252); + border-image: none; + border-radius: 3px; + box-shadow: 0 -1px 0 var(--gray-200, #525252) inset; +} .navbar-gitlab { padding: 0 16px; z-index: 1000; @@ -1504,7 +1543,7 @@ svg.s16 { vertical-align: -3px; } .header-content .header-search-new { - max-width: 600px; + max-width: 640px; } .header-search { min-width: 320px; @@ -1514,29 +1553,10 @@ svg.s16 { min-width: 200px; } } -.header-search.is-not-active::after { - content: "/"; - display: inline-block; - position: absolute; - top: 0; - right: 8px; - transform: translateY(calc(50% - 4px)); - padding: 4px 5px; - font-size: 12px; - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", - "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - line-height: 1; - vertical-align: middle; - border-width: 0; - border-style: solid; - border-image: none; - border-radius: 3px; +.header-search .keyboard-shortcut-helper { + transform: translateY(calc(50% - 2px)); box-shadow: none; - white-space: pre-wrap; - box-sizing: border-box; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: keep-all; + border-color: transparent; } .search { margin: 0 8px; @@ -1697,6 +1717,9 @@ svg.s16 { .rect-avatar.s32 { border-radius: 4px; } +:root { + color-scheme: dark; +} body.gl-dark { --gray-10: #1f1f1f; --gray-50: #303030; @@ -1884,7 +1907,7 @@ body.gl-dark .header-search input::placeholder { body.gl-dark .header-search input:active::placeholder { color: #868686; } -body.gl-dark .header-search.is-not-active::after { +body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper { color: #fafafa; background-color: rgba(250, 250, 250, 0.2); } @@ -1938,6 +1961,9 @@ body.gl-dark .navbar-gitlab .search form .search-input { color: var(--gl-text-color); } +:root { + color-scheme: dark; +} body.gl-dark { --gray-10: #1f1f1f; --gray-50: #303030; @@ -2030,7 +2056,6 @@ body.gl-dark { --nav-active-bg: rgba(255, 255, 255, 0.08); } .tab-width-8 { - -moz-tab-size: 8; tab-size: 8; } .gl-sr-only { @@ -2084,6 +2109,12 @@ body.gl-dark { .gl-absolute { position: absolute; } +.gl-top-0 { + top: 0; +} +.gl-right-3 { + right: 0.5rem; +} .gl-w-full { width: 100%; } @@ -2119,6 +2150,9 @@ body.gl-dark { .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 index cb3c97f18a3..43ca5a512d5 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -46,6 +46,11 @@ a:not([href]):not([class]) { color: inherit; text-decoration: none; } +kbd { + font-family: "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; @@ -90,6 +95,18 @@ button::-moz-focus-inner, padding-left: 0; list-style: none; } +kbd { + padding: 0.2rem 0.4rem; + font-size: 90%; + color: #fff; + background-color: #303030; + border-radius: 0.2rem; +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: 600; +} .container-fluid { width: 100%; padding-right: 15px; @@ -725,6 +742,22 @@ input { .form-control::placeholder { color: #868686; } +kbd { + display: inline-block; + padding: 3px 5px; + font-size: 0.6875rem; + line-height: 10px; + color: var(--gray-700, #525252); + vertical-align: middle; + background-color: var(--gray-10, #f5f5f5); + border-width: 1px; + border-style: solid; + border-color: var(--gray-100, #dbdbdb) var(--gray-100, #dbdbdb) + var(--gray-200, #bfbfbf); + border-image: none; + border-radius: 3px; + box-shadow: 0 -1px 0 var(--gray-200, #bfbfbf) inset; +} .navbar-gitlab { padding: 0 16px; z-index: 1000; @@ -1489,7 +1522,7 @@ svg.s16 { vertical-align: -3px; } .header-content .header-search-new { - max-width: 600px; + max-width: 640px; } .header-search { min-width: 320px; @@ -1499,29 +1532,10 @@ svg.s16 { min-width: 200px; } } -.header-search.is-not-active::after { - content: "/"; - display: inline-block; - position: absolute; - top: 0; - right: 8px; - transform: translateY(calc(50% - 4px)); - padding: 4px 5px; - font-size: 12px; - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", - "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - line-height: 1; - vertical-align: middle; - border-width: 0; - border-style: solid; - border-image: none; - border-radius: 3px; +.header-search .keyboard-shortcut-helper { + transform: translateY(calc(50% - 2px)); box-shadow: none; - white-space: pre-wrap; - box-sizing: border-box; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: keep-all; + border-color: transparent; } .search { margin: 0 8px; @@ -1684,7 +1698,6 @@ svg.s16 { } .tab-width-8 { - -moz-tab-size: 8; tab-size: 8; } .gl-sr-only { @@ -1738,6 +1751,12 @@ svg.s16 { .gl-absolute { position: absolute; } +.gl-top-0 { + top: 0; +} +.gl-right-3 { + right: 0.5rem; +} .gl-w-full { width: 100%; } @@ -1773,6 +1792,9 @@ svg.s16 { .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/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index fe8a5aec1b3..e6e736ef47c 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -101,6 +101,10 @@ $white-dark: #444; $border-color: #4f4f4f; $nav-active-bg: rgba(255, 255, 255, 0.08); +:root { + color-scheme: dark; +} + body.gl-dark { --gray-10: #{$gray-10}; --gray-50: #{$gray-50}; diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index ad352f0022b..2b6221a6c87 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -159,7 +159,6 @@ color: rgba($search-and-nav-links, 0.8); } - input { background-color: transparent; color: rgba($search-and-nav-links, 0.8); @@ -177,9 +176,11 @@ } } - &.is-not-active::after { - color: $search-and-nav-links; - background-color: rgba($search-and-nav-links, 0.2); + &.is-not-active { + .keyboard-shortcut-helper { + color: $search-and-nav-links; + background-color: rgba($search-and-nav-links, 0.2); + } } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 27fcade548f..6bd05f90f26 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -367,50 +367,6 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 -webkit-backdrop-filter: blur(2px); // still required by Safari } -/* - * The styles from here to END-#1825 will be moved to @gitlab/ui by - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1825 - */ -.gl-lg-mx-12 { - @include media-breakpoint-up(lg) { - margin-left: $gl-spacing-scale-12; - margin-right: $gl-spacing-scale-12; - } -} - -.gl-lg-ml-12 { - @include media-breakpoint-up(lg) { - margin-left: $gl-spacing-scale-12; - } -} - -.gl-lg-mr-12 { - @include media-breakpoint-up(lg) { - margin-right: $gl-spacing-scale-12; - } -} - -.gl-lg-ml-10 { - @include media-breakpoint-up(lg) { - margin-left: $gl-spacing-scale-10; - } -} - -.gl-lg-mr-10 { - @include media-breakpoint-up(lg) { - margin-right: $gl-spacing-scale-10; - } -} - -.gl-lg-w-30p { - @include gl-media-breakpoint-up(lg) { - width: 30%; - } -} - -.gl-lg-w-40p { - @include gl-media-breakpoint-up(lg) { - width: 40%; - } +.gl-flex-flow-row-wrap { + flex-flow: row wrap; } -/* END-#1825 */ diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb new file mode 100644 index 00000000000..554e057ca83 --- /dev/null +++ b/app/channels/awareness_channel.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + REFRESH_INTERVAL = ENV.fetch("GITLAB_AWARENESS_REFRESH_INTERVAL_SEC", 60) + private_constant :REFRESH_INTERVAL + + # Produces a refresh interval value, based of the + # GITLAB_AWARENESS_REFRESH_INTERVAL_SEC environment variable or the given + # default. Makes sure, that the interval after a jitter is applied, is never + # less than half the predefined interval. + def self.refresh_interval(range: -10..10) + min = REFRESH_INTERVAL / 2.to_f + [min.to_i, REFRESH_INTERVAL.to_i + rand(range)].max.seconds + end + private_class_method :refresh_interval + + # keep clients updated about session membership + periodically every: self.refresh_interval do + transmit payload + end + + def subscribed + reject unless valid_subscription? + return if subscription_rejected? + + stream_for session, coder: ActiveSupport::JSON + + session.join(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + def unsubscribed + return if subscription_rejected? + + session.leave(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + # Allows a client to let the server know they are still around. This is not + # like a heartbeat mechanism. This can be triggered by any action that results + # in a meaningful "presence" update. Like scrolling the screen (debounce), + # window becoming active, user starting to type in a text field, etc. + def touch + session.touch!(current_user) + + transmit payload + end + + private + + def valid_subscription? + current_user.present? && path.present? + end + + def payload + { collaborators: collaborators } + end + + def collaborators + session.online_users_with_last_activity.map do |user, last_activity| + collaborator(user, last_activity) + end + end + + def collaborator(user, last_activity) + { + id: user.id, + name: user.name, + avatar_url: user.avatar_url(size: 36), + last_activity: last_activity, + last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( + Time.zone.now, last_activity + ) + } + end + + def session + @session ||= AwarenessSession.for(path) + end + + def path + params[:path] + end +end diff --git a/app/components/pajamas/spinner_component.html.haml b/app/components/pajamas/spinner_component.html.haml new file mode 100644 index 00000000000..aab9c5fdbf7 --- /dev/null +++ b/app/components/pajamas/spinner_component.html.haml @@ -0,0 +1,5 @@ +.gl-spinner-container{ class: @class } + - if @inline + %span{ class: spinner_class, aria: {label: @label} } + - else + %div{ class: spinner_class, aria: {label: @label} } diff --git a/app/components/pajamas/spinner_component.rb b/app/components/pajamas/spinner_component.rb new file mode 100644 index 00000000000..c7ffc1ec3da --- /dev/null +++ b/app/components/pajamas/spinner_component.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Pajamas + class SpinnerComponent < Pajamas::Component + # @param [String] class + # @param [Symbol] color + # @param [Boolean] inline + # @param [String] label + # @param [Symbol] size + def initialize(class: '', color: :dark, inline: false, label: _("Loading"), size: :sm) + @class = binding.local_variable_get(:class) + @color = filter_attribute(color.to_sym, COLOR_OPTIONS) + @inline = inline + @label = label.presence + @size = filter_attribute(size.to_sym, SIZE_OPTIONS) + end + + private + + def spinner_class + ["gl-spinner", "gl-spinner-#{@size}", "gl-spinner-#{@color}"] + end + + COLOR_OPTIONS = [:light, :dark].freeze + SIZE_OPTIONS = [:sm, :md, :lg, :xl].freeze + end +end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 7f95b136e4e..e05e87ffd89 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -28,7 +28,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :create_self_monitoring_project, :status_create_self_monitoring_project, :delete_self_monitoring_project, - :status_delete_self_monitoring_project + :status_delete_self_monitoring_project, + :reset_error_tracking_access_token ] feature_category :source_code_management, [:repository, :clear_repository_check_states] @@ -37,6 +38,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController feature_category :service_ping, [:usage_data, :service_usage_data] feature_category :integrations, [:integrations] feature_category :pages, [:lets_encrypt_terms_of_service] + feature_category :error_tracking, [:reset_error_tracking_access_token] VALID_SETTING_PANELS = %w(general repository ci_cd reporting metrics_and_profiling @@ -96,6 +98,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_back_or_default end + def reset_error_tracking_access_token + @application_setting.reset_error_tracking_access_token! + + redirect_to general_admin_application_settings_path, + notice: _('New error tracking access token has been generated!') + end + def clear_repository_check_states RepositoryCheck::ClearWorker.perform_async # rubocop:disable CodeReuse/Worker diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 865af244773..bf573d45852 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -5,7 +5,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController before_action :finder, only: [:edit, :update, :destroy] - feature_category :navigation + feature_category :onboarding urgency :low # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index 6f5475a4a78..810801d4209 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -8,40 +8,6 @@ class Admin::HooksController < Admin::ApplicationController feature_category :integrations urgency :low, [:test] - def index - @hooks = SystemHook.all.load - @hook = SystemHook.new - end - - def create - @hook = SystemHook.new(hook_params.to_h) - - if @hook.save - redirect_to admin_hooks_path, notice: _('Hook was successfully created.') - else - @hooks = SystemHook.all - render :index - end - end - - def edit - end - - def update - if hook.update(hook_params) - flash[:notice] = _('System hook was successfully updated.') - redirect_to admin_hooks_path - else - render 'edit' - end - end - - def destroy - destroy_hook(hook) - - redirect_to admin_hooks_path, status: :found - end - def test result = TestHooks::SystemService.new(hook, current_user, params[:trigger]).execute @@ -52,6 +18,10 @@ class Admin::HooksController < Admin::ApplicationController private + def relation + SystemHook + end + def hook @hook ||= SystemHook.find(params[:id]) end @@ -60,12 +30,11 @@ class Admin::HooksController < Admin::ApplicationController @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]) end - def hook_params - params.require(:hook).permit( - :enable_ssl_verification, - :token, - :url, - *SystemHook.triggers.values - ) + def hook_param_names + %i[enable_ssl_verification token url] + end + + def trigger_values + SystemHook.triggers.values end end diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb index 7ae930abb84..f81b02ad31f 100644 --- a/app/controllers/admin/system_info_controller.rb +++ b/app/controllers/admin/system_info_controller.rb @@ -12,7 +12,10 @@ class Admin::SystemInfoController < Admin::ApplicationController EXCLUDED_MOUNT_TYPES = [ 'autofs', 'binfmt_misc', + 'bpf', 'cgroup', + 'cgroup2', + 'configfs', 'debugfs', 'devfs', 'devpts', diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb index 908313bdb83..b451928e591 100644 --- a/app/controllers/admin/topics_controller.rb +++ b/app/controllers/admin/topics_controller.rb @@ -4,7 +4,7 @@ class Admin::TopicsController < Admin::ApplicationController include SendFileUpload include PreviewMarkdown - before_action :topic, only: [:edit, :update] + before_action :topic, only: [:edit, :update, :destroy] feature_category :projects @@ -37,6 +37,14 @@ class Admin::TopicsController < Admin::ApplicationController end end + def destroy + @topic.destroy! + + redirect_to admin_topics_path, + status: :found, + notice: _('Topic %{topic_name} was successfully removed.') % { topic_name: @topic.title_or_name } + end + private def topic diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 30760d472a4..71d9910b4b8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -179,6 +179,10 @@ class ApplicationController < ActionController::Base payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] + if Feature.enabled?(:log_response_length) + payload[:response_bytes] = response.body_parts.sum(&:bytesize) + end + store_cloudflare_headers!(payload, request) end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index a04fd09aa22..51150700860 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -50,7 +50,6 @@ class Clusters::ClustersController < Clusters::BaseController def show if params[:tab] == 'integrations' @prometheus_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_prometheus) - @elastic_stack_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_elastic_stack) end end diff --git a/app/controllers/concerns/google_analytics_csp.rb b/app/controllers/concerns/google_analytics_csp.rb new file mode 100644 index 00000000000..1a8e405928d --- /dev/null +++ b/app/controllers/concerns/google_analytics_csp.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module GoogleAnalyticsCSP + extend ActiveSupport::Concern + + included do + content_security_policy do |policy| + next unless helpers.google_tag_manager_enabled? || policy.directives.present? + + default_script_src = policy.directives['script-src'] || policy.directives['default-src'] + script_src_values = Array.wrap(default_script_src) | ['*.googletagmanager.com'] + policy.script_src(*script_src_values) + + default_img_src = policy.directives['img-src'] || policy.directives['default-src'] + img_src_values = Array.wrap(default_img_src) | ['*.google-analytics.com', '*.googletagmanager.com'] + policy.img_src(*img_src_values) + + default_connect_src = policy.directives['connect-src'] || policy.directives['default-src'] + connect_src_values = + Array.wrap(default_connect_src) | ['*.google-analytics.com', '*.analytics.google.com', '*.googletagmanager.com'] + policy.connect_src(*connect_src_values) + end + end +end diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb new file mode 100644 index 00000000000..70de72f15fc --- /dev/null +++ b/app/controllers/concerns/harbor/access.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Harbor + module Access + extend ActiveSupport::Concern + + included do + before_action :harbor_registry_enabled! + before_action :authorize_read_harbor_registry! + before_action do + push_frontend_feature_flag(:harbor_registry_integration) + end + + feature_category :integrations + end + + private + + def harbor_registry_enabled! + render_404 unless Feature.enabled?(:harbor_registry_integration) + end + + def authorize_read_harbor_registry! + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/harbor/artifact.rb b/app/controllers/concerns/harbor/artifact.rb new file mode 100644 index 00000000000..c9d7d26fbb9 --- /dev/null +++ b/app/controllers/concerns/harbor/artifact.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Harbor + module Artifact + def index + respond_to do |format| + format.json do + artifacts + end + end + end + + private + + def query_params + params.permit(:repository_id, :search, :sort, :page, :limit) + end + + def query + Gitlab::Harbor::Query.new(container.harbor_integration, query_params) + end + + def artifacts + unless query.valid? + return render( + json: { message: 'Invalid parameters', errors: query.errors }, + status: :unprocessable_entity + ) + end + + artifacts_json = ::Integrations::HarborSerializers::ArtifactSerializer.new + .with_pagination(request, response) + .represent(query.artifacts) + render json: artifacts_json + end + + def container + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/harbor/repository.rb b/app/controllers/concerns/harbor/repository.rb new file mode 100644 index 00000000000..0e541e2172e --- /dev/null +++ b/app/controllers/concerns/harbor/repository.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Harbor + module Repository + def index + respond_to do |format| + format.html + format.json do + repositories + end + end + end + + # The show action renders index to allow frontend routing to work on page refresh + def show + render :index + end + + private + + def query_params + params.permit(:search, :sort, :page, :limit) + end + + def query + Gitlab::Harbor::Query.new(container.harbor_integration, query_params) + end + + def repositories + unless query.valid? + return render( + json: { message: 'Invalid parameters', errors: query.errors }, + status: :unprocessable_entity + ) + end + + repositories_json = ::Integrations::HarborSerializers::RepositorySerializer.new + .with_pagination(request, response) + .represent( + query.repositories, + url: container.harbor_integration.url, + project_name: container.harbor_integration.project_name + ) + render json: repositories_json + end + + def container + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/harbor/tag.rb b/app/controllers/concerns/harbor/tag.rb new file mode 100644 index 00000000000..e0c00d1155a --- /dev/null +++ b/app/controllers/concerns/harbor/tag.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Harbor + module Tag + def index + respond_to do |format| + format.json do + tags + end + end + end + + private + + def query_params + params.permit(:repository_id, :artifact_id, :sort, :page, :limit) + end + + def query + Gitlab::Harbor::Query.new(container.harbor_integration, query_params) + end + + def tags + unless query.valid? + return render( + json: { message: 'Invalid parameters', errors: query.errors }, + status: :unprocessable_entity + ) + end + + tags_json = ::Integrations::HarborSerializers::TagSerializer.new + .with_pagination(request, response) + .represent(query.tags) + render json: tags_json + end + + def container + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb index 6a9d3d51f9b..fb26840168f 100644 --- a/app/controllers/concerns/integrations/hooks_execution.rb +++ b/app/controllers/concerns/integrations/hooks_execution.rb @@ -3,8 +3,68 @@ module Integrations::HooksExecution extend ActiveSupport::Concern + included do + attr_writer :hooks, :hook + end + + def index + self.hooks = relation.select(&:persisted?) + self.hook = relation.new + end + + def create + self.hook = relation.new(hook_params) + hook.save + + unless hook.valid? + self.hooks = relation.select(&:persisted?) + flash[:alert] = hook.errors.full_messages.join.html_safe + end + + redirect_to action: :index + end + + def update + if hook.update(hook_params) + flash[:notice] = _('Hook was successfully updated.') + redirect_to action: :index + else + render 'edit' + end + end + + def destroy + destroy_hook(hook) + + redirect_to action: :index, status: :found + end + + def edit + redirect_to(action: :index) unless hook + end + private + def hook_params + permitted = hook_param_names + trigger_values + permitted << { url_variables: [:key, :value] } + + ps = params.require(:hook).permit(*permitted).to_h + + ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables) + + if action_name == 'update' && ps.key?(:url_variables) + supplied = ps[:url_variables] + ps[:url_variables] = hook.url_variables.merge(supplied).compact + end + + ps + end + + def hook_param_names + %i[enable_ssl_verification token url push_events_branch_filter] + end + def destroy_hook(hook) result = WebHooks::DestroyService.new(current_user).execute(hook) diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index d256b331174..c3ad9d3dff3 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -6,7 +6,6 @@ module Integrations ALLOWED_PARAMS_CE = [ :active, - :add_pusher, :alert_events, :api_key, :api_token, diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 07850acd23d..a5e49b1b16a 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -184,7 +184,6 @@ module IssuableActions def paginated_discussions return if params[:per_page].blank? - return if issuable.instance_of?(Issue) && Feature.disabled?(:paginated_issue_discussions, project) return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, project) strong_memoize(:paginated_discussions) do diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 4841225de08..de38d26e3fe 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -24,12 +24,9 @@ module IssuableCollections show_alert_if_search_is_disabled @issuables = issuables_collection + set_pagination - unless pagination_disabled? - set_pagination - - return if redirect_out_of_range(@issuables, @total_pages) - end + return if redirect_out_of_range(@issuables, @total_pages) if params[:label_name].present? && @project labels_params = { project_id: @project.id, title: params[:label_name] } @@ -59,10 +56,6 @@ module IssuableCollections end # rubocop:enable Gitlab/ModuleWithInstanceVariables - def pagination_disabled? - false - end - # rubocop: disable CodeReuse/ActiveRecord def issuables_collection finder.execute.preload(preload_for_collection) diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index 0b51b3dd380..dc7ba8295b9 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -28,7 +28,10 @@ module ProductAnalyticsTracking def event_enabled?(event) events_to_ff = { g_analytics_valuestream: :route_hll_to_snowplow, - i_search_paid: :route_hll_to_snowplow_phase2 + + i_search_paid: :route_hll_to_snowplow_phase2, + i_search_total: :route_hll_to_snowplow_phase2, + i_search_advanced: :route_hll_to_snowplow_phase2 } Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source) diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb new file mode 100644 index 00000000000..1a3e7136481 --- /dev/null +++ b/app/controllers/concerns/verifies_with_email.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +# == VerifiesWithEmail +# +# Controller concern to handle verification by email +module VerifiesWithEmail + extend ActiveSupport::Concern + include ActionView::Helpers::DateHelper + + TOKEN_LENGTH = 6 + TOKEN_VALID_FOR_MINUTES = 60 + + included do + prepend_before_action :verify_with_email, only: :create, unless: -> { two_factor_enabled? } + end + + def verify_with_email + return unless user = find_user || find_verification_user + + if session[:verification_user_id] && token = verification_params[:verification_token].presence + # The verification token is submitted, verify it + verify_token(user, token) + elsif require_email_verification_enabled?(user) + # Limit the amount of password guesses, since we now display the email verification page + # when the password is correct, which could be a giveaway when brute-forced. + return render_sign_in_rate_limited if check_rate_limit!(:user_sign_in, scope: user) { true } + + if user.valid_password?(user_params[:password]) + # The user has logged in successfully. + if user.unlock_token + # Prompt for the token if it already has been set + prompt_for_email_verification(user) + elsif user.access_locked? || !AuthenticationEvent.initial_login_or_known_ip_address?(user, request.ip) + # require email verification if: + # - their account has been locked because of too many failed login attempts, or + # - they have logged in before, but never from the current ip address + send_verification_instructions(user) + prompt_for_email_verification(user) + end + end + end + end + + def resend_verification_code + return unless user = find_verification_user + + send_verification_instructions(user) + prompt_for_email_verification(user) + end + + def successful_verification + session.delete(:verification_user_id) + @redirect_url = after_sign_in_path_for(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables + + render layout: 'minimal' + end + + private + + def find_verification_user + return unless session[:verification_user_id] + + User.find_by_id(session[:verification_user_id]) + end + + # After successful verification and calling sign_in, devise redirects the + # user to this path. Override it to show the successful verified page. + def after_sign_in_path_for(resource) + if action_name == 'create' && session[:verification_user_id] + return users_successful_verification_path + end + + super + end + + def send_verification_instructions(user) + return if send_rate_limited?(user) + + raw_token, encrypted_token = generate_token + user.unlock_token = encrypted_token + user.lock_access!({ send_instructions: false }) + send_verification_instructions_email(user, raw_token) + end + + def send_verification_instructions_email(user, token) + return unless user.can?(:receive_notifications) + + Notify.verification_instructions_email( + user.id, + token: token, + expires_in: TOKEN_VALID_FOR_MINUTES).deliver_later + + log_verification(user, :instructions_sent) + end + + def verify_token(user, token) + return handle_verification_failure(user, :rate_limited) if verification_rate_limited?(user) + return handle_verification_failure(user, :invalid) unless valid_token?(user, token) + return handle_verification_failure(user, :expired) if expired_token?(user) + + handle_verification_success(user) + end + + def generate_token + raw_token = SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0') + encrypted_token = digest_token(raw_token) + [raw_token, encrypted_token] + end + + def digest_token(token) + Devise.token_generator.digest(User, :unlock_token, token) + end + + def render_sign_in_rate_limited + message = s_('IdentityVerification|Maximum login attempts exceeded. '\ + 'Wait %{interval} and try again.') % { interval: user_sign_in_interval } + redirect_to new_user_session_path, alert: message + end + + def user_sign_in_interval + interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:user_sign_in][:interval] + distance_of_time_in_words(interval_in_seconds) + end + + def verification_rate_limited?(user) + Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user.unlock_token) + end + + def send_rate_limited?(user) + Gitlab::ApplicationRateLimiter.throttled?(:email_verification_code_send, scope: user) + end + + def expired_token?(user) + user.locked_at < (Time.current - TOKEN_VALID_FOR_MINUTES.minutes) + end + + def valid_token?(user, token) + user.unlock_token == digest_token(token) + end + + def handle_verification_failure(user, reason) + message = case reason + when :rate_limited + s_("IdentityVerification|You've reached the maximum amount of tries. "\ + 'Wait %{interval} or resend a new code and try again.') % { interval: email_verification_interval } + when :expired + s_('IdentityVerification|The code has expired. Resend a new code and try again.') + when :invalid + s_('IdentityVerification|The code is incorrect. Enter it again, or resend a new code.') + end + + user.errors.add(:base, message) + log_verification(user, :failed_attempt, reason) + + prompt_for_email_verification(user) + end + + def email_verification_interval + interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval] + distance_of_time_in_words(interval_in_seconds) + end + + def handle_verification_success(user) + user.unlock_access! + log_verification(user, :successful) + + sign_in(user) + end + + def prompt_for_email_verification(user) + session[:verification_user_id] = user.id + self.resource = user + + render 'devise/sessions/email_verification' + end + + def verification_params + params.require(:user).permit(:verification_token) + end + + def log_verification(user, event, reason = nil) + Gitlab::AppLogger.info( + message: 'Email Verification', + event: event.to_s.titlecase, + username: user.username, + ip: request.ip, + reason: reason.to_s + ) + end + + def require_email_verification_enabled?(user) + Feature.enabled?(:require_email_verification, user) + end +end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 704453fbf44..713231cbc6f 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -4,6 +4,7 @@ class ConfirmationsController < Devise::ConfirmationsController include AcceptsPendingInvitations include GitlabRecaptcha include OneTrustCSP + include GoogleAnalyticsCSP prepend_before_action :check_recaptcha, only: :create before_action :load_recaptcha, only: :new diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index c71c101b434..67eeb43d5a2 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -82,6 +82,13 @@ class GraphqlController < ApplicationController render_error(exception.message, status: :unprocessable_entity) end + rescue_from ActiveRecord::QueryAborted do |exception| + log_exception(exception) + + error = "Request timed out. Please try a less complex query or a smaller set of records." + render_error(error, status: :service_unavailable) + end + override :feature_category def feature_category ::Gitlab::FeatureCategories.default.from_request(request) || super diff --git a/app/controllers/groups/harbor/application_controller.rb b/app/controllers/groups/harbor/application_controller.rb new file mode 100644 index 00000000000..cff767c8efd --- /dev/null +++ b/app/controllers/groups/harbor/application_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class ApplicationController < Groups::ApplicationController + layout 'group' + include ::Harbor::Access + + private + + def authorize_read_harbor_registry! + render_404 unless can?(current_user, :read_harbor_registry, @group) + end + end + end +end diff --git a/app/controllers/groups/harbor/artifacts_controller.rb b/app/controllers/groups/harbor/artifacts_controller.rb new file mode 100644 index 00000000000..b7570b44a2c --- /dev/null +++ b/app/controllers/groups/harbor/artifacts_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class ArtifactsController < ::Groups::Harbor::ApplicationController + include ::Harbor::Artifact + + private + + def container + @group + end + end + end +end diff --git a/app/controllers/groups/harbor/repositories_controller.rb b/app/controllers/groups/harbor/repositories_controller.rb index 364607f9b20..1ad38bd7103 100644 --- a/app/controllers/groups/harbor/repositories_controller.rb +++ b/app/controllers/groups/harbor/repositories_controller.rb @@ -2,22 +2,13 @@ module Groups module Harbor - class RepositoriesController < Groups::ApplicationController - feature_category :integrations - - before_action :harbor_registry_enabled! - before_action do - push_frontend_feature_flag(:harbor_registry_integration) - end - - def show - render :index - end + class RepositoriesController < ::Groups::Harbor::ApplicationController + include ::Harbor::Repository private - def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration) + def container + @group end end end diff --git a/app/controllers/groups/harbor/tags_controller.rb b/app/controllers/groups/harbor/tags_controller.rb new file mode 100644 index 00000000000..da43cb3f64c --- /dev/null +++ b/app/controllers/groups/harbor/tags_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class TagsController < ::Groups::Harbor::ApplicationController + include ::Harbor::Tag + + private + + def container + @group + end + end + end +end diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index cb7bf001918..bb2d08e487a 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -8,6 +8,10 @@ module Groups before_action :verify_container_registry_enabled! before_action :authorize_read_container_image! + before_action do + push_frontend_feature_flag(:container_registry_show_shortened_path, group) + end + feature_category :container_registry urgency :low diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 55707000cf8..75193309a4e 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -18,14 +18,14 @@ class Import::BitbucketController < Import::BaseController if auth_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(auth_state, params[:state]) go_to_bitbucket_for_permissions else - response = oauth_client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url) + response = oauth_client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url(namespace_id: params[:namespace_id])) session[:bitbucket_token] = response.token session[:bitbucket_expires_at] = response.expires_at session[:bitbucket_expires_in] = response.expires_in session[:bitbucket_refresh_token] = response.refresh_token - redirect_to status_import_bitbucket_url + redirect_to status_import_bitbucket_url(namespace_id: params[:namespace_id]) end end @@ -78,16 +78,15 @@ class Import::BitbucketController < Import::BaseController bitbucket_repos.reject { |repo| repo.valid? } end + def provider_url + nil + end + override :provider_name def provider_name :bitbucket end - override :provider_url - def provider_url - provider.url - end - private def oauth_client @@ -121,7 +120,7 @@ class Import::BitbucketController < Import::BaseController def go_to_bitbucket_for_permissions state = SecureRandom.base64(64) session[:bitbucket_auth_state] = state - redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url, state: state) + redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url(namespace_id: params[:namespace_id]), state: state) end def bitbucket_unauthorized(exception) diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index 00f3f0b08b2..12147196749 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -49,7 +49,7 @@ class Import::BitbucketServerController < Import::BaseController session[bitbucket_server_username_key] = params[:bitbucket_server_username] session[bitbucket_server_url_key] = params[:bitbucket_server_url] - redirect_to status_import_bitbucket_server_path + redirect_to status_import_bitbucket_server_path(namespace_id: params[:namespace_id]) end # We need to re-expose controller's internal method 'status' as action. @@ -115,7 +115,7 @@ class Import::BitbucketServerController < Import::BaseController unless session[bitbucket_server_url_key].present? && session[bitbucket_server_username_key].present? && session[personal_access_token_key].present? - redirect_to new_import_bitbucket_server_path + redirect_to new_import_bitbucket_server_path(namespace_id: params[:namespace_id]) end end @@ -170,9 +170,6 @@ class Import::BitbucketServerController < Import::BaseController } }, status: :unprocessable_entity end - format.html do - redirect_to new_import_bitbucket_server_path - end end end end diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 34f12aebb91..2d607fb7ff7 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -17,7 +17,7 @@ class Import::BulkImportsController < ApplicationController session[access_token_key] = configure_params[access_token_key]&.strip session[url_key] = configure_params[url_key] - redirect_to status_import_bulk_imports_url + redirect_to status_import_bulk_imports_url(namespace_id: params[:namespace_id]) end def status @@ -35,6 +35,12 @@ class Import::BulkImportsController < ApplicationController render json: json_response end format.html do + if params[:namespace_id] + @namespace = Namespace.find_by_id(params[:namespace_id]) + + render_404 unless current_user.can?(:create_subgroup, @namespace) + end + @source_url = session[url_key] end end diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index b949a99c250..7b580234227 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -17,12 +17,12 @@ class Import::FogbugzController < Import::BaseController res = Gitlab::FogbugzImport::Client.new(import_params.to_h.symbolize_keys) rescue StandardError # If the URI is invalid various errors can occur - return redirect_to new_import_fogbugz_path, alert: _('Could not connect to FogBugz, check your URL') + return redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id]), alert: _('Could not connect to FogBugz, check your URL') end session[:fogbugz_token] = res.get_token session[:fogbugz_uri] = params[:uri] - redirect_to new_user_map_import_fogbugz_path + redirect_to new_user_map_import_fogbugz_path(namespace_id: params[:namespace_id]) end def new_user_map @@ -41,12 +41,12 @@ class Import::FogbugzController < Import::BaseController flash[:notice] = _('The user map has been saved. Continue by selecting the projects you want to import.') - redirect_to status_import_fogbugz_path + redirect_to status_import_fogbugz_path(namespace_id: params[:namespace_id]) end def status unless client.valid? - return redirect_to new_import_fogbugz_path + return redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id]) end super @@ -106,7 +106,7 @@ class Import::FogbugzController < Import::BaseController end def fogbugz_unauthorized(exception) - redirect_to new_import_fogbugz_path, alert: exception.message + redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id]), alert: exception.message end def import_params diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index 399a92c59e0..4b4ac07b389 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -7,7 +7,7 @@ class Import::GiteaController < Import::GithubController def new if session[access_token_key].present? && provider_url.present? - redirect_to status_import_url(namespace_id: params[:namespace_id]) + redirect_to status_import_url end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 8dd40b6254e..9cc58ce542c 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -23,25 +23,24 @@ class Import::GithubController < Import::BaseController if !ci_cd_only? && github_import_configured? && logged_in_with_provider? go_to_provider_for_permissions elsif session[access_token_key] - redirect_to status_import_url(namespace_id: params[:namespace_id]) + redirect_to status_import_url end end def callback auth_state = session.delete(auth_state_key) - namespace_id = session.delete(:namespace_id) if auth_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(auth_state, params[:state]) provider_unauthorized else session[access_token_key] = get_token(params[:code]) - redirect_to status_import_url(namespace_id: namespace_id) + redirect_to status_import_url end end def personal_access_token session[access_token_key] = params[:personal_access_token]&.strip - redirect_to status_import_url(namespace_id: params[:namespace_id].presence) + redirect_to status_import_url end def status @@ -205,15 +204,15 @@ class Import::GithubController < Import::BaseController end def new_import_url - public_send("new_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("new_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: params[:namespace_id] })) # rubocop:disable GitlabSecurity/PublicSend end - def status_import_url(namespace_id: nil) - public_send("status_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: namespace_id })) # rubocop:disable GitlabSecurity/PublicSend + def status_import_url + public_send("status_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: params[:namespace_id].presence })) # rubocop:disable GitlabSecurity/PublicSend end def callback_import_url - public_send("users_import_#{provider_name}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("users_import_#{provider_name}_callback_url", extra_import_params.merge({ namespace_id: params[:namespace_id] })) # rubocop:disable GitlabSecurity/PublicSend end def provider_unauthorized @@ -255,7 +254,6 @@ class Import::GithubController < Import::BaseController def provider_auth if !ci_cd_only? && session[access_token_key].blank? - session[:namespace_id] = params[:namespace_id] go_to_provider_for_permissions end end diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index c846d9d225a..dd25698d0a9 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -12,8 +12,8 @@ class Import::GitlabController < Import::BaseController rescue_from OAuth2::Error, with: :gitlab_unauthorized def callback - session[:gitlab_access_token] = client.get_token(params[:code], callback_import_gitlab_url) - redirect_to status_import_gitlab_url + session[:gitlab_access_token] = client.get_token(params[:code], callback_import_gitlab_url(namespace_id: params[:namespace_id])) + redirect_to status_import_gitlab_url(namespace_id: params[:namespace_id]) end # We need to re-expose controller's internal method 'status' as action. @@ -79,7 +79,7 @@ class Import::GitlabController < Import::BaseController end def go_to_gitlab_for_permissions - redirect_to client.authorize_url(callback_import_gitlab_url) + redirect_to client.authorize_url(callback_import_gitlab_url(namespace_id: params[:namespace_id])) end def gitlab_unauthorized diff --git a/app/controllers/jira_connect/oauth_application_ids_controller.rb b/app/controllers/jira_connect/oauth_application_ids_controller.rb index 05c23210da2..a84b47f4c8b 100644 --- a/app/controllers/jira_connect/oauth_application_ids_controller.rb +++ b/app/controllers/jira_connect/oauth_application_ids_controller.rb @@ -5,9 +5,10 @@ module JiraConnect feature_category :integrations skip_before_action :authenticate_user! + skip_before_action :verify_authenticity_token def show - if Feature.enabled?(:jira_connect_oauth_self_managed) && jira_connect_application_key.present? + if show_application_id? render json: { application_id: jira_connect_application_key } else head :not_found @@ -16,6 +17,12 @@ module JiraConnect private + def show_application_id? + return if Gitlab.com? + + Feature.enabled?(:jira_connect_oauth_self_managed) && jira_connect_application_key.present? + end + def jira_connect_application_key Gitlab::CurrentSettings.jira_connect_application_key.presence end diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index 2ba9f8264e1..623113f8413 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -25,6 +25,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController before_action :allow_rendering_in_iframe, only: :index before_action :verify_qsh_claim!, only: :index + before_action :allow_self_managed_content_security_policy, only: :index before_action :authenticate_user!, only: :create def index @@ -62,6 +63,13 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController private + def allow_self_managed_content_security_policy + return unless current_jira_installation.instance_url? + + request.content_security_policy.directives['connect-src'] ||= [] + request.content_security_policy.directives['connect-src'] << Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/oauth_application_ids') + end + def create_service JiraConnectSubscriptions::CreateService.new(current_jira_installation, current_user, namespace_path: params['namespace_path'], jira_user: jira_user) end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index 6aa46b8e4c3..955dfe58449 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -3,10 +3,12 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController extend ::Gitlab::Utils::Override + before_action :check_action_name_in_available_providers + def self.define_providers! return unless Gitlab::Auth::Ldap::Config.sign_in_enabled? - Gitlab::Auth::Ldap::Config.available_servers.each do |server| + Gitlab::Auth::Ldap::Config.servers.each do |server| alias_method server['provider_name'], :ldap end end @@ -36,6 +38,18 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController redirect_to new_user_session_path end + + private + + def check_action_name_in_available_providers + render_404 unless available_providers.include?(action_name) + end + + def available_providers + Gitlab::Auth::Ldap::Config.available_servers.map do |server| + server['provider_name'] + end + end end Ldap::OmniauthCallbacksController.prepend_mod_with('Ldap::OmniauthCallbacksController') diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 3724bb0d925..a996bad3fac 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -52,10 +52,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController end def set_index_vars - @applications = current_user.oauth_applications + @applications = current_user.oauth_applications.load @authorized_tokens = current_user.oauth_authorized_tokens - @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) - @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + .latest_per_application + .preload_application # Don't overwrite a value possibly set by `create` @application ||= Doorkeeper::Application.new diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index c9c51289d3a..2e9fbb1d0d9 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -54,8 +54,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController # limit scopes when signing in with GitLab def downgrade_scopes! - return unless Feature.enabled?(:omniauth_login_minimal_scopes, current_user) - auth_type = params.delete('gl_auth_type') return unless auth_type == 'login' diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 265fa505b2a..1a8908e8571 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -14,6 +14,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController name: params[:name], scopes: scopes ) + + respond_to do |format| + format.html + format.json do + render json: @active_personal_access_tokens + end + end end def create @@ -56,6 +63,28 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def active_personal_access_tokens tokens = finder(state: 'active', sort: 'expires_at_asc').execute + + if Feature.enabled?('access_token_pagination') + tokens = tokens.page(page) + add_pagination_headers(tokens) + end + ::API::Entities::PersonalAccessTokenWithDetails.represent(tokens) end + + def add_pagination_headers(relation) + Gitlab::Pagination::OffsetHeaderBuilder.new( + request_context: self, + per_page: relation.limit_value, + page: relation.current_page, + next_page: relation.next_page, + prev_page: relation.prev_page, + total: relation.total_count, + params: params.permit(:page) + ).execute + end + + def page + (params[:page] || 1).to_i + end end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 64ced43311a..5bfda526fb0 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -25,7 +25,7 @@ class Projects::BlameController < Projects::ApplicationController blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page)) - @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path).fabricate! + @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! render locals: { blame_pagination: blame_service.pagination } end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index a9561fb9312..97aae56c4ec 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -42,7 +42,6 @@ class Projects::BlobController < Projects::ApplicationController urgency :low, [:create, :show, :edit, :update, :diff] before_action do - push_frontend_feature_flag(:refactor_blob_viewer, @project) push_frontend_feature_flag(:highlight_js, @project) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index ac3c85f3b40..7ef9fd9daed 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -24,9 +24,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } - before_action do - push_frontend_feature_flag(:monitor_logging, project) - end after_action :expire_etag_cache, only: [:cancel_auto_stop] feature_category :continuous_delivery diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index 980e9bdcdad..050b26a40c7 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -12,7 +12,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController def admin_project_google_cloud! unless can?(current_user, :admin_project_google_cloud, project) - track_event('admin_project_google_cloud!', 'access_denied', 'invalid_user') + track_event('admin_project_google_cloud!', 'error_access_denied', 'invalid_user') access_denied! end end @@ -20,7 +20,11 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController def google_oauth2_enabled! config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') if config.app_id.blank? || config.app_secret.blank? - track_event('google_oauth2_enabled!', 'access_denied', { reason: 'google_oauth2_not_configured', config: config }) + track_event( + 'google_oauth2_enabled!', + 'error_access_denied', + { reason: 'google_oauth2_not_configured', config: config } + ) access_denied! 'This GitLab instance not configured for Google Oauth2.' end end @@ -31,7 +35,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project) feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project unless feature_is_enabled - track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled') + track_event('feature_flag_enabled!', 'error_access_denied', 'feature_flag_not_enabled') access_denied! end end @@ -42,7 +46,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController return if is_token_valid - return_url = project_google_cloud_index_path(project) + return_url = project_google_cloud_configuration_path(project) state = generate_session_key_redirect(request.url, return_url) @authorize_url = GoogleApi::CloudPlatform::Client.new(nil, callback_google_api_auth_url, @@ -65,12 +69,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] end - def handle_gcp_error(action, error) - track_event(action, 'gcp_error', error) - @js_data = { screen: 'gcp_error', error: error.to_s }.to_json - render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error' - end - def track_event(action, label, property) options = { label: label, project: project, user: current_user } diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb new file mode 100644 index 00000000000..fa672058247 --- /dev/null +++ b/app/controllers/projects/google_cloud/configuration_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Projects + module GoogleCloud + class ConfigurationController < Projects::GoogleCloud::BaseController + def index + @google_cloud_path = project_google_cloud_configuration_path(project) + js_data = { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project), + serviceAccounts: ::GoogleCloud::ServiceAccountsService.new(project).find_for_project, + createServiceAccountUrl: project_google_cloud_service_accounts_path(project), + emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), + configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), + gcpRegions: gcp_regions, + revokeOauthUrl: revoke_oauth_url + } + @js_data = js_data.to_json + track_event('configuration#index', 'success', js_data) + end + + private + + def gcp_regions + params = { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY } + list = ::Ci::VariablesFinder.new(project, params).execute + list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } } + end + + def revoke_oauth_url + google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil + end + end + end +end diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb new file mode 100644 index 00000000000..711409e7550 --- /dev/null +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Projects + module GoogleCloud + class DatabasesController < Projects::GoogleCloud::BaseController + def index + @google_cloud_path = project_google_cloud_configuration_path(project) + js_data = { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project) + } + @js_data = js_data.to_json + track_event('databases#index', 'success', js_data) + end + end + end +end diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index 4867d344c5a..4aa17b36fad 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -3,32 +3,47 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::BaseController before_action :validate_gcp_token! + def index + @google_cloud_path = project_google_cloud_configuration_path(project) + js_data = { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project), + enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project), + enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project) + } + @js_data = js_data.to_json + track_event('deployments#index', 'success', js_data) + end + def cloud_run - params = { token_in_session: token_in_session } + params = { google_oauth2_token: token_in_session } enable_cloud_run_response = GoogleCloud::EnableCloudRunService .new(project, current_user, params).execute if enable_cloud_run_response[:status] == :error - track_event('deployments#cloud_run', 'enable_cloud_run_error', enable_cloud_run_response) + track_event('deployments#cloud_run', 'error_enable_cloud_run', enable_cloud_run_response) flash[:error] = enable_cloud_run_response[:message] - redirect_to project_google_cloud_index_path(project) + redirect_to project_google_cloud_deployments_path(project) else params = { action: GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } generate_pipeline_response = GoogleCloud::GeneratePipelineService .new(project, current_user, params).execute if generate_pipeline_response[:status] == :error - track_event('deployments#cloud_run', 'generate_pipeline_error', generate_pipeline_response) + track_event('deployments#cloud_run', 'error_generate_pipeline', generate_pipeline_response) flash[:error] = 'Failed to generate pipeline' - redirect_to project_google_cloud_index_path(project) + redirect_to project_google_cloud_deployments_path(project) else cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name]) - track_event('deployments#cloud_run', 'cloud_run_success', cloud_run_mr_params) + track_event('deployments#cloud_run', 'success', cloud_run_mr_params) redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params) end end - rescue Google::Apis::ClientError => error - handle_gcp_error('deployments#cloud_run', error) + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error + track_event('deployments#cloud_run', 'error_gcp', error) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + redirect_to project_google_cloud_deployments_path(project) end def cloud_storage diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb index beeb91cfd80..3fbe9a96284 100644 --- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -6,8 +6,10 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC # Source https://cloud.google.com/run/docs/locations 2022-01-30 AVAILABLE_REGIONS = %w[asia-east1 asia-northeast1 asia-southeast1 europe-north1 europe-west1 europe-west4 us-central1 us-east1 us-east4 us-west1].freeze + GCP_REGION_CI_VAR_KEY = 'GCP_REGION' + def index - @google_cloud_path = project_google_cloud_index_path(project) + @google_cloud_path = project_google_cloud_configuration_path(project) params = { per_page: 50 } branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) @@ -16,16 +18,16 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC screen: 'gcp_regions_form', availableRegions: AVAILABLE_REGIONS, refs: refs, - cancelPath: project_google_cloud_index_path(project) + cancelPath: project_google_cloud_configuration_path(project) } @js_data = js_data.to_json - track_event('gcp_regions#index', 'form_render', js_data) + track_event('gcp_regions#index', 'success', js_data) end def create permitted_params = params.permit(:ref, :gcp_region) response = GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) - track_event('gcp_regions#create', 'form_submit', response) - redirect_to project_google_cloud_index_path(project), notice: _('GCP region configured') + track_event('gcp_regions#create', 'success', response) + redirect_to project_google_cloud_configuration_path(project), notice: _('GCP region configured') end end diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb index 03d1474707b..1a9a2daf4f2 100644 --- a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb +++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb @@ -8,16 +8,15 @@ class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::Base response = google_api_client.revoke_authorizations if response.success? - status = 'success' redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') } + track_event('revoke_oauth#create', 'success', response.to_json) else - status = 'failed' redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') } + track_event('revoke_oauth#create', 'error', response.to_json) end session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token) - track_event('revoke_oauth#create', 'create', status) - redirect_to project_google_cloud_index_path(project), redirect_message + redirect_to project_google_cloud_configuration_path(project), redirect_message end end diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index 5d8b2030d5c..dbd83be19db 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -4,14 +4,15 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: before_action :validate_gcp_token! def index - @google_cloud_path = project_google_cloud_index_path(project) + @google_cloud_path = project_google_cloud_configuration_path(project) google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) gcp_projects = google_api_client.list_projects if gcp_projects.empty? @js_data = { screen: 'no_gcp_projects' }.to_json - track_event('service_accounts#index', 'form_error', 'no_gcp_projects') - render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects' + track_event('service_accounts#index', 'error_form', 'no_gcp_projects') + flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project') + redirect_to project_google_cloud_configuration_path(project) else params = { per_page: 50 } branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) @@ -21,14 +22,16 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: screen: 'service_accounts_form', gcpProjects: gcp_projects, refs: refs, - cancelPath: project_google_cloud_index_path(project) + cancelPath: project_google_cloud_configuration_path(project) } @js_data = js_data.to_json - track_event('service_accounts#index', 'form_success', js_data) + track_event('service_accounts#index', 'success', js_data) end - rescue Google::Apis::ClientError => error - handle_gcp_error('service_accounts#index', error) + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error + track_event('service_accounts#index', 'error_gcp', error) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + redirect_to project_google_cloud_configuration_path(project) end def create @@ -42,9 +45,11 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: environment_name: permitted_params[:ref] ).execute - track_event('service_accounts#create', 'form_submit', response) - redirect_to project_google_cloud_index_path(project), notice: response.message + track_event('service_accounts#create', 'success', response) + redirect_to project_google_cloud_configuration_path(project), notice: response.message rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error - handle_gcp_error('service_accounts#create', error) + track_event('service_accounts#create', 'error_gcp', error) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + redirect_to project_google_cloud_configuration_path(project) end end diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb deleted file mode 100644 index 49bb4bec859..00000000000 --- a/app/controllers/projects/google_cloud_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController - GCP_REGION_CI_VAR_KEY = 'GCP_REGION' - - def index - js_data = { - screen: 'home', - serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, - createServiceAccountUrl: project_google_cloud_service_accounts_path(project), - enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project), - enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project), - emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), - configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), - gcpRegions: gcp_regions, - revokeOauthUrl: revoke_oauth_url - } - @js_data = js_data.to_json - track_event('google_cloud#index', 'index', js_data) - end - - private - - def gcp_regions - list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute - list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } } - end - - def revoke_oauth_url - google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) - .validate_token(expires_at_in_session) - google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil - end -end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 6007e09f109..08eebfa0e4b 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -9,7 +9,7 @@ class Projects::GroupLinksController < Projects::ApplicationController def update group_link = @project.project_group_links.find(params[:id]) - Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params) + Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params) if group_link.expires? render json: { diff --git a/app/controllers/projects/harbor/application_controller.rb b/app/controllers/projects/harbor/application_controller.rb index e6e694783fa..9271ec560dc 100644 --- a/app/controllers/projects/harbor/application_controller.rb +++ b/app/controllers/projects/harbor/application_controller.rb @@ -4,18 +4,12 @@ module Projects module Harbor class ApplicationController < Projects::ApplicationController layout 'project' - - before_action :harbor_registry_enabled! - before_action do - push_frontend_feature_flag(:harbor_registry_integration) - end - - feature_category :integrations + include ::Harbor::Access private - def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration) + def authorize_read_harbor_registry! + render_404 unless can?(current_user, :read_harbor_registry, @project) end end end diff --git a/app/controllers/projects/harbor/artifacts_controller.rb b/app/controllers/projects/harbor/artifacts_controller.rb new file mode 100644 index 00000000000..ce36f181b42 --- /dev/null +++ b/app/controllers/projects/harbor/artifacts_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + module Harbor + class ArtifactsController < ::Projects::Harbor::ApplicationController + include ::Harbor::Artifact + + private + + def container + @project + end + end + end +end diff --git a/app/controllers/projects/harbor/repositories_controller.rb b/app/controllers/projects/harbor/repositories_controller.rb index dd3e3dc1978..4db13331bf0 100644 --- a/app/controllers/projects/harbor/repositories_controller.rb +++ b/app/controllers/projects/harbor/repositories_controller.rb @@ -3,8 +3,12 @@ module Projects module Harbor class RepositoriesController < ::Projects::Harbor::ApplicationController - def show - render :index + include ::Harbor::Repository + + private + + def container + @project end end end diff --git a/app/controllers/projects/harbor/tags_controller.rb b/app/controllers/projects/harbor/tags_controller.rb new file mode 100644 index 00000000000..f49c5ac7768 --- /dev/null +++ b/app/controllers/projects/harbor/tags_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + module Harbor + class TagsController < ::Projects::Harbor::ApplicationController + include ::Harbor::Tag + + private + + def container + @project + end + end + end +end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 99eba32e00f..50f388324f1 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -15,52 +15,21 @@ class Projects::HooksController < Projects::ApplicationController feature_category :integrations urgency :low, [:test] - def index - @hooks = @project.hooks.load - @hook = ProjectHook.new - end - - def create - @hook = @project.hooks.new(hook_params) - @hook.save - - unless @hook.valid? - @hooks = @project.hooks.select(&:persisted?) - flash[:alert] = @hook.errors.full_messages.join.html_safe - end - - redirect_to action: :index - end - - def edit - redirect_to(action: :index) unless hook - end - - def update - if hook.update(hook_params) - flash[:notice] = _('Hook was successfully updated.') - redirect_to action: :index - else - render 'edit' - end - end - def test - result = TestHooks::ProjectService.new(hook, current_user, params[:trigger]).execute + trigger = params.fetch(:trigger, ::ProjectHook.triggers.each_value.first.to_s) + result = TestHooks::ProjectService.new(hook, current_user, trigger).execute set_hook_execution_notice(result) redirect_back_or_default(default: { action: :index }) end - def destroy - destroy_hook(hook) + private - redirect_to action: :index, status: :found + def relation + @project.hooks end - private - def hook @hook ||= @project.hooks.find(params[:id]) end @@ -69,13 +38,7 @@ class Projects::HooksController < Projects::ApplicationController @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]) end - def hook_params - params.require(:hook).permit( - :enable_ssl_verification, - :token, - :url, - :push_events_branch_filter, - *ProjectHook.triggers.values - ) + def trigger_values + ProjectHook.triggers.values end end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 70eab792b40..f9fa8046962 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -8,6 +8,9 @@ class Projects::IncidentsController < Projects::ApplicationController before_action :load_incident, only: [:show] before_action do push_frontend_feature_flag(:incident_timeline, @project) + push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) + push_frontend_feature_flag(:work_items_mvc_2) + push_frontend_feature_flag(:work_items_hierarchy, @project) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f974b16468c..f1c9e2b2653 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -22,7 +22,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } before_action :redirect_if_task, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } - after_action :log_issue_show, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } + after_action :log_issue_show, only: :show before_action :set_issuables_index, if: ->(c) { SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request? @@ -41,14 +41,11 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_download_code!, only: [:related_branches] before_action do - push_frontend_feature_flag(:contacts_autocomplete, project&.group) push_frontend_feature_flag(:incident_timeline, project) end before_action only: :show do - push_frontend_feature_flag(:confidential_notes, project&.group) push_frontend_feature_flag(:issue_assignees_widget, project) - push_frontend_feature_flag(:paginated_issue_discussions, project) push_frontend_feature_flag(:realtime_labels, project) push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) push_frontend_feature_flag(:work_items_mvc_2) diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 9574c5d5849..ad59f421c06 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -5,28 +5,22 @@ class Projects::JobsController < Projects::ApplicationController include ContinueParams include ProjectStatsRefreshConflictsGuard - urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :status, :erase, :raw] + urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw] before_action :find_job_as_build, except: [:index, :play, :show] before_action :find_job_as_processable, only: [:play, :show] before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build! before_action :authorize_update_build!, - except: [:index, :show, :status, :raw, :trace, :erase, :cancel, :unschedule] + except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule] 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 before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize - before_action :push_jobs_table_vue, only: [:index] - before_action :push_jobs_table_vue_search, only: [:index] + before_action :push_job_log_search, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] - before_action do - push_frontend_feature_flag(:infinitely_collapsible_sections, @project) - push_frontend_feature_flag(:trigger_job_retry_action, @project) - end - layout 'project' feature_category :continuous_integration @@ -125,12 +119,6 @@ class Projects::JobsController < Projects::ApplicationController end end - def status - render json: Ci::JobSerializer - .new(project: @project, current_user: @current_user) - .represent_status(@build.present(current_user: current_user)) - end - def erase if @build.erase(erased_by: current_user) redirect_to project_job_path(project, @build), @@ -261,11 +249,7 @@ class Projects::JobsController < Projects::ApplicationController ::Gitlab::Workhorse.channel_websocket(service) end - def push_jobs_table_vue - push_frontend_feature_flag(:jobs_table_vue, @project) - end - - def push_jobs_table_vue_search - push_frontend_feature_flag(:jobs_table_vue_search, @project) + def push_job_log_search + push_frontend_feature_flag(:job_log_search, @project) end end diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb deleted file mode 100644 index 0f751db2064..00000000000 --- a/app/controllers/projects/logs_controller.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -module Projects - class LogsController < Projects::ApplicationController - include ::Gitlab::Utils::StrongMemoize - - before_action :authorize_read_pod_logs! - before_action :ensure_deployments, only: %i(k8s elasticsearch) - - feature_category :logging - urgency :low - - def index - return render_404 unless Feature.enabled?(:monitor_logging, project) - - if environment || cluster - render :index - else - render :empty_logs - end - end - - def k8s - render_logs(::PodLogs::KubernetesService, k8s_params) - end - - def elasticsearch - render_logs(::PodLogs::ElasticsearchService, elasticsearch_params) - end - - private - - def render_logs(service, permitted_params) - ::Gitlab::PollingInterval.set_header(response, interval: 3_000) - - result = service.new(cluster, namespace, params: permitted_params).execute - - if result.nil? - head :accepted - elsif result[:status] == :success - render json: result - else - render status: :bad_request, json: result - end - end - - # cluster is selected either via environment or directly by id - def cluster_params - params.permit(:environment_name, :cluster_id) - end - - def k8s_params - params.permit(:container_name, :pod_name) - end - - def elasticsearch_params - params.permit(:container_name, :pod_name, :search, :start_time, :end_time, :cursor) - end - - def environment - strong_memoize(:environment) do - if cluster_params.key?(:environment_name) - ::Environments::EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).execute.first - else - project.default_environment - end - end - end - - def cluster - strong_memoize(:cluster) do - if gitlab_managed_apps_logs? - clusters = ClusterAncestorsFinder.new(project, current_user).execute - clusters.find { |cluster| cluster.id == cluster_params[:cluster_id].to_i } - else - environment&.deployment_platform&.cluster - end - end - end - - def namespace - if gitlab_managed_apps_logs? - Gitlab::Kubernetes::Helm::NAMESPACE - else - environment.deployment_namespace - end - end - - def ensure_deployments - return if gitlab_managed_apps_logs? - return if cluster && namespace.present? - - render status: :bad_request, json: { - status: :error, - message: _('Environment does not have deployments') - } - end - - def gitlab_managed_apps_logs? - cluster_params.key?(:cluster_id) - end - end -end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d420e136316..a2f018c013b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -32,10 +32,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show] do - push_frontend_feature_flag(:file_identifier_hash) push_frontend_feature_flag(:merge_request_widget_graphql, project) push_frontend_feature_flag(:core_security_mr_widget_counts, project) - push_frontend_feature_flag(:confidential_notes, project) push_frontend_feature_flag(:restructured_mr_widget, project) push_frontend_feature_flag(:refactor_mr_widgets_extensions, project) push_frontend_feature_flag(:refactor_code_quality_extension, project) @@ -44,10 +42,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:issue_assignees_widget, @project) push_frontend_feature_flag(:realtime_labels, project) push_frontend_feature_flag(:refactor_security_extension, @project) + push_frontend_feature_flag(:refactor_code_quality_inline_findings, project) push_frontend_feature_flag(:mr_attention_requests, current_user) push_frontend_feature_flag(:moved_mr_sidebar, project) push_frontend_feature_flag(:paginated_mr_discussions, project) push_frontend_feature_flag(:mr_review_submit_comment, project) + push_frontend_feature_flag(:mr_experience_survey, project) end before_action do @@ -86,6 +86,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :rebase, :discussions, :pipelines, + :coverage_reports, :test_reports, :codequality_mr_diff_reports, :codequality_reports, diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb index f2f276071a0..b78ee6ca917 100644 --- a/app/controllers/projects/metrics_dashboard_controller.rb +++ b/app/controllers/projects/metrics_dashboard_controller.rb @@ -12,7 +12,6 @@ module Projects before_action do push_frontend_feature_flag(:prometheus_computed_alerts) push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) - push_frontend_feature_flag(:monitor_logging, project) end feature_category :metrics diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index e5b2dd14f69..8ac370b1bd4 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -7,6 +7,7 @@ module Projects before_action :authorize_read_build! before_action :builds, only: [:show] + before_action :validate_test_reports!, only: [:show] feature_category :code_testing @@ -23,19 +24,21 @@ module Projects def show respond_to do |format| format.json do - if pipeline.has_expired_test_reports? - render json: { errors: 'Test report artifacts have expired' }, status: :not_found - else - render json: TestSuiteSerializer - .new(project: project, current_user: @current_user) - .represent(test_suite, details: true) - end + render json: TestSuiteSerializer + .new(project: project, current_user: @current_user) + .represent(test_suite, details: true) end end end private + def validate_test_reports! + unless pipeline.has_test_reports? + render json: { errors: 'Test report artifacts not found' }, status: :not_found + end + end + def builds @builds ||= pipeline.latest_builds.id_in(build_ids).presence || render_404 end @@ -48,7 +51,7 @@ module Projects def test_suite suite = builds.sum do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load! diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index adc3a912a91..b2aa1d9f4ca 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -38,6 +38,8 @@ class Projects::PipelinesController < Projects::ApplicationController track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? } track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? } track_redis_hll_event :charts, name: 'p_analytics_ci_cd_lead_time', if: -> { should_track_ci_cd_lead_time? } + track_redis_hll_event :charts, name: 'p_analytics_ci_cd_time_to_restore_service', if: -> { should_track_ci_cd_time_to_restore_service? } + track_redis_hll_event :charts, name: 'p_analytics_ci_cd_change_failure_rate', if: -> { should_track_ci_cd_change_failure_rate? } wrap_parameters Ci::Pipeline @@ -174,7 +176,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def stage - @stage = pipeline.legacy_stage(params[:stage]) + @stage = pipeline.stage(params[:stage]) return not_found unless @stage render json: StageSerializer @@ -361,6 +363,14 @@ class Projects::PipelinesController < Projects::ApplicationController def should_track_ci_cd_lead_time? params[:chart] == 'lead-time' end + + def should_track_ci_cd_time_to_restore_service? + params[:chart] == 'time-to-restore-service' + end + + def should_track_ci_cd_change_failure_rate? + params[:chart] == 'change-failure-rate' + end end Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 168e703c87d..cd9c6efb106 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -13,9 +13,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController def index @sort = params[:sort].presence || sort_value_name - - @group_links = @project.project_group_links - @group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present? + @include_relations ||= requested_relations(:groups_with_inherited_permissions) if can?(current_user, :admin_project_member, @project) @invited_members = present_members(invited_members) diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index ad3b2bc98e7..87cb8e4781f 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -8,6 +8,10 @@ module Projects before_action :authorize_update_container_image!, only: [:destroy] + before_action do + push_frontend_feature_flag(:container_registry_show_shortened_path, project) + end + def index respond_to do |format| format.html { ensure_root_container_repository! } diff --git a/app/controllers/projects/service_ping_controller.rb b/app/controllers/projects/service_ping_controller.rb index d8f1785d95e..43c249afd8e 100644 --- a/app/controllers/projects/service_ping_controller.rb +++ b/app/controllers/projects/service_ping_controller.rb @@ -17,7 +17,8 @@ class Projects::ServicePingController < Projects::ApplicationController return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_success_count - Gitlab::UsageDataCounters::EditorUniqueCounter.track_live_preview_edit_action(author: current_user) + Gitlab::UsageDataCounters::EditorUniqueCounter.track_live_preview_edit_action(author: current_user, + project: project) head(200) end diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb index 3365da65de8..cee9e9feb7b 100644 --- a/app/controllers/projects/settings/integrations_controller.rb +++ b/app/controllers/projects/settings/integrations_controller.rb @@ -11,6 +11,7 @@ module Projects before_action :integration, only: [:edit, :update, :test] before_action :default_integration, only: [:edit, :update] before_action :web_hook_logs, only: [:edit, :update] + before_action -> { check_rate_limit!(:project_testing_integration, scope: [@project, current_user]) }, only: :test respond_to :html @@ -88,7 +89,7 @@ module Projects unless result[:success] return { error: true, - message: s_('Integrations|Connection failed. Please check your settings.'), + message: s_('Integrations|Connection failed. Check your integration settings.'), service_response: result[:message].to_s, test_failed: true } @@ -98,7 +99,7 @@ module Projects rescue *Gitlab::HTTP::HTTP_ERRORS => e { error: true, - message: s_('Integrations|Connection failed. Please check your settings.'), + message: s_('Integrations|Connection failed. Check your integration settings.'), service_response: e.message, test_failed: true } diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 77d7f3570f3..478d9f0b38e 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -14,7 +14,6 @@ module Projects respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token] helper_method :error_tracking_setting - helper_method :tracing_setting feature_category :incident_management urgency :low @@ -60,19 +59,9 @@ module Projects ::Gitlab::Tracking::IncidentManagement.track_from_params( update_params[:incident_management_setting_attributes] ) - track_tracing_external_url end end - def track_tracing_external_url - external_url_previous_change = project&.tracing_setting&.external_url_previous_change - - return unless external_url_previous_change - return unless external_url_previous_change[0].blank? && external_url_previous_change[1].present? - - ::Gitlab::Tracking.event('project:operations:tracing', 'external_url_populated', user: current_user, project: project, namespace: project.namespace) - end - def alerting_params { alerting_setting_attributes: { regenerate_token: true } } end @@ -124,10 +113,6 @@ module Projects project.build_error_tracking_setting end - def tracing_setting - @tracing_setting ||= project.tracing_setting || project.build_tracing_setting - end - def update_params params.require(:project).permit(permitted_project_params) end @@ -147,8 +132,7 @@ module Projects project: [:slug, :name, :organization_slug, :organization_name, :sentry_project_id] ], - grafana_integration_attributes: [:token, :grafana_url, :enabled], - tracing_setting_attributes: [:external_url] + grafana_integration_attributes: [:token, :grafana_url, :enabled] } end end diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb index b852673d82a..adeadf2133e 100644 --- a/app/controllers/projects/tags/releases_controller.rb +++ b/app/controllers/projects/tags/releases_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# TODO: remove this file together with FF https://gitlab.com/gitlab-org/gitlab/-/issues/366244 +# also delete view/routes class Projects::Tags::ReleasesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 432497850f2..847b1baca10 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -53,7 +53,7 @@ class Projects::TagsController < Projects::ApplicationController return render_404 unless @tag - @release = @project.releases.find_or_initialize_by(tag: @tag.name) + @release = @project.releases.find_by(tag: @tag.name) @commit = @repository.commit(@tag.dereferenced_target) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/tracings_controller.rb b/app/controllers/projects/tracings_controller.rb deleted file mode 100644 index b5c1354c4a9..00000000000 --- a/app/controllers/projects/tracings_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Projects - class TracingsController < Projects::ApplicationController - content_security_policy do |p| - next if p.directives.blank? - - global_frame_src = p.frame_src - - p.frame_src -> { frame_src_csp_policy(global_frame_src) } - end - - before_action :authorize_update_environment! - - feature_category :tracing - urgency :low - - def show - render_404 unless Feature.enabled?(:monitor_tracing, @project) - end - - private - - def frame_src_csp_policy(global_frame_src) - external_url = @project&.tracing_setting&.external_url - - external_url.presence || global_frame_src - end - end -end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index ed14f66847c..ce51cbb6677 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController before_action do push_frontend_feature_flag(:lazy_load_commits, @project) - push_frontend_feature_flag(:refactor_blob_viewer, @project) push_frontend_feature_flag(:highlight_js, @project) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1e0ef1ad337..37e472050a0 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -36,7 +36,6 @@ class ProjectsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:lazy_load_commits, @project) - push_frontend_feature_flag(:refactor_blob_viewer, @project) push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:increase_page_size_exponentially, @project) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index a2b25acae64..4e18e6a3b20 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -3,6 +3,7 @@ module Registrations class WelcomeController < ApplicationController include OneTrustCSP + include GoogleAnalyticsCSP layout 'minimal' skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update] diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 206580d205a..bb16c2d2098 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -7,6 +7,7 @@ class RegistrationsController < Devise::RegistrationsController include InvisibleCaptchaOnSignup include OneTrustCSP include BizibleCSP + include GoogleAnalyticsCSP layout 'devise' @@ -220,7 +221,7 @@ class RegistrationsController < Devise::RegistrationsController return unless member - Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s) + Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s, user: resource) end def context_user diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 7d251ba555c..7a7e63f5fc4 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -9,7 +9,7 @@ class SearchController < ApplicationController RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze - track_redis_hll_event :show, name: 'i_search_total' + track_event :show, name: 'i_search_total', destinations: [:redis_hll, :snowplow] around_action :allow_gitaly_ref_name_caching @@ -42,13 +42,19 @@ class SearchController < ApplicationController @sort = params[:sort] || default_sort @search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate! - @scope = @search_service.scope - @without_count = @search_service.without_count? - @show_snippets = @search_service.show_snippets? - @search_results = @search_service.search_results - @search_objects = @search_service.search_objects - @search_highlight = @search_service.search_highlight - @aggregations = @search_service.search_aggregations + + @search_level = @search_service.level + @search_type = search_type + + @global_search_duration_s = Benchmark.realtime do + @scope = @search_service.scope + @without_count = @search_service.without_count? + @show_snippets = @search_service.show_snippets? + @search_results = @search_service.search_results + @search_objects = @search_service.search_objects + @search_highlight = @search_service.search_highlight + @aggregations = @search_service.search_aggregations + end increment_search_counters end @@ -144,7 +150,9 @@ class SearchController < ApplicationController payload[:metadata]['meta.search.filters.state'] = params[:state] payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results] payload[:metadata]['meta.search.project_ids'] = params[:project_ids] - payload[:metadata]['meta.search.search_level'] = params[:search_level] + payload[:metadata]['meta.search.type'] = @search_type if @search_type.present? + payload[:metadata]['meta.search.level'] = @search_level if @search_level.present? + payload[:metadata][:global_search_duration_s] = @global_search_duration_s if @global_search_duration_s.present? if search_service.abuse_detected? payload[:metadata]['abuse.confidence'] = Gitlab::Abuse.confidence(:certain) @@ -203,6 +211,14 @@ class SearchController < ApplicationController render status: :request_timeout end end + + def tracking_namespace_source + search_service.project&.namespace || search_service.group + end + + def search_type + 'basic' + end end SearchController.prepend_mod_with('SearchController') diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 9000e9c39de..6195d152f00 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -11,6 +11,8 @@ class SessionsController < Devise::SessionsController include Gitlab::Utils::StrongMemoize include OneTrustCSP include BizibleCSP + include VerifiesWithEmail + include GoogleAnalyticsCSP skip_before_action :check_two_factor_requirement, only: [:destroy] skip_before_action :check_password_expiration, only: [:destroy] diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb index f7eb2aad9dc..f36b140f3a2 100644 --- a/app/controllers/users/terms_controller.rb +++ b/app/controllers/users/terms_controller.rb @@ -4,6 +4,7 @@ module Users class TermsController < ApplicationController include InternalRedirect include OneTrustCSP + include GoogleAnalyticsCSP skip_before_action :authenticate_user!, only: [:index] skip_before_action :enforce_terms! @@ -13,6 +14,10 @@ module Users before_action :terms + before_action only: [:index] do + push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) + end + layout 'terms' feature_category :user_management diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2799479d922..eaf08cd421b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -58,7 +58,9 @@ class UsersController < ApplicationController # Get all keys of a user(params[:username]) in a text format # Helpful for sysadmins to put in respective servers def ssh_keys - render plain: user.all_ssh_keys.join("\n") + keys = user.all_ssh_keys.join("\n") + keys << "\n" unless keys.empty? + render plain: keys end def activity @@ -74,7 +76,9 @@ class UsersController < ApplicationController # Get all gpg keys of a user(params[:username]) in a text format def gpg_keys - render plain: user.gpg_keys.select(&:verified?).map(&:key).join("\n") + keys = user.gpg_keys.filter_map { |gpg_key| gpg_key.key if gpg_key.verified? }.join("\n") + keys << "\n" unless keys.empty? + render plain: keys end def groups diff --git a/app/events/pages/page_deleted_event.rb b/app/events/pages/page_deleted_event.rb index b1ea14a6ec5..5787506121a 100644 --- a/app/events/pages/page_deleted_event.rb +++ b/app/events/pages/page_deleted_event.rb @@ -7,7 +7,8 @@ module Pages 'type' => 'object', 'properties' => { 'project_id' => { 'type' => 'integer' }, - 'namespace_id' => { 'type' => 'integer' } + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' } }, 'required' => %w[project_id namespace_id] } diff --git a/app/events/pages/page_deployed_event.rb b/app/events/pages/page_deployed_event.rb new file mode 100644 index 00000000000..52e53772a51 --- /dev/null +++ b/app/events/pages/page_deployed_event.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pages + class PageDeployedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' } + }, + 'required' => %w[project_id namespace_id root_namespace_id] + } + end + end +end diff --git a/app/events/projects/project_created_event.rb b/app/events/projects/project_created_event.rb new file mode 100644 index 00000000000..abac772dfc9 --- /dev/null +++ b/app/events/projects/project_created_event.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Projects + class ProjectCreatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' } + }, + 'required' => %w[project_id namespace_id root_namespace_id] + } + end + end +end diff --git a/app/events/projects/project_deleted_event.rb b/app/events/projects/project_deleted_event.rb index ac58c5c6755..fe0c832fe0f 100644 --- a/app/events/projects/project_deleted_event.rb +++ b/app/events/projects/project_deleted_event.rb @@ -7,7 +7,8 @@ module Projects 'type' => 'object', 'properties' => { 'project_id' => { 'type' => 'integer' }, - 'namespace_id' => { 'type' => 'integer' } + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' } }, 'required' => %w[project_id namespace_id] } diff --git a/app/events/projects/project_path_changed_event.rb b/app/events/projects/project_path_changed_event.rb new file mode 100644 index 00000000000..965f9258d3f --- /dev/null +++ b/app/events/projects/project_path_changed_event.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + class ProjectPathChangedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'old_path' => { 'type' => 'string' }, + 'new_path' => { 'type' => 'string' } + }, + 'required' => %w[project_id namespace_id root_namespace_id old_path new_path] + } + end + end +end diff --git a/app/experiments/security_actions_continuous_onboarding_experiment.rb b/app/experiments/security_actions_continuous_onboarding_experiment.rb new file mode 100644 index 00000000000..6adfbedc744 --- /dev/null +++ b/app/experiments/security_actions_continuous_onboarding_experiment.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SecurityActionsContinuousOnboardingExperiment < ApplicationExperiment + def control_behavior + end + + def candidate_behavior + end +end diff --git a/app/finders/ci/auth_job_finder.rb b/app/finders/ci/auth_job_finder.rb index 2dbdcb3c472..360afe5a7ab 100644 --- a/app/finders/ci/auth_job_finder.rb +++ b/app/finders/ci/auth_job_finder.rb @@ -29,7 +29,7 @@ module Ci private - attr_reader :token, :require_running, :raise_on_missing + attr_reader :token def find_job_by_token ::Ci::Build.find_by_token(token) diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 356915722fe..4f9244d9825 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -17,6 +17,7 @@ module Ci search! filter_by_active! filter_by_status! + filter_by_upgrade_status! filter_by_runner_type! filter_by_tag_list! sort! @@ -67,6 +68,13 @@ module Ci filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES) end + def filter_by_upgrade_status! + return unless @params.key?(:upgrade_status) + return unless Ci::RunnerVersion.statuses.key?(@params[:upgrade_status]) + + @runners = @runners.with_upgrade_status(@params[:upgrade_status]) + end + def filter_by_runner_type! filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES) end diff --git a/app/finders/clusters/agents_finder.rb b/app/finders/clusters/agents_finder.rb index d0b1240157c..14277db3f85 100644 --- a/app/finders/clusters/agents_finder.rb +++ b/app/finders/clusters/agents_finder.rb @@ -4,8 +4,8 @@ module Clusters class AgentsFinder include FinderMethods - def initialize(project, current_user, params: {}) - @project = project + def initialize(object, current_user, params: {}) + @object = object @current_user = current_user @params = params end @@ -13,18 +13,25 @@ module Clusters def execute return ::Clusters::Agent.none unless can_read_cluster_agents? - agents = project.cluster_agents - agents = agents.with_name(params[:name]) if params[:name].present? + agents = filter_clusters(object.cluster_agents) agents.ordered_by_name end private - attr_reader :project, :current_user, :params + attr_reader :object, :current_user, :params + + def filter_clusters(agents) + agents = agents.with_name(params[:name]) if params[:name].present? + + agents + end def can_read_cluster_agents? - current_user.can?(:read_cluster, project) + current_user&.can?(:read_cluster, object) end end end + +Clusters::AgentsFinder.prepend_mod_with('Clusters::AgentsFinder') diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb index a351d30229e..eccc7d3f2bb 100644 --- a/app/finders/contributed_projects_finder.rb +++ b/app/finders/contributed_projects_finder.rb @@ -11,12 +11,15 @@ class ContributedProjectsFinder < UnionFinder # current_user - When given the list of the projects is limited to those only # visible by this user. # + # ignore_visibility - When true the list of projects will include all contributed + # projects, regardless of their visibility to the current_user + # # Returns an ActiveRecord::Relation. - def execute(current_user = nil) + def execute(current_user = nil, ignore_visibility: false) # Do not show contributed projects if the user profile is private. return Project.none unless can_read_profile?(current_user) - segments = all_projects(current_user) + segments = all_projects(current_user, ignore_visibility) find_union(segments, Project).with_namespace.order_id_desc end @@ -27,7 +30,9 @@ class ContributedProjectsFinder < UnionFinder Ability.allowed?(current_user, :read_user_profile, @user) end - def all_projects(current_user) + def all_projects(current_user, ignore_visibility) + return [@user.contributed_projects] if ignore_visibility + projects = [] projects << @user.contributed_projects.visible_to_user(current_user) if current_user diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb index f4aed413867..90367638dcf 100644 --- a/app/finders/groups/user_groups_finder.rb +++ b/app/finders/groups/user_groups_finder.rb @@ -35,7 +35,7 @@ module Groups attr_reader :current_user, :target_user, :params def sort(items) - items.order(path: :asc, id: :asc) # rubocop: disable CodeReuse/ActiveRecord + items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord end def by_search(items) @@ -47,6 +47,8 @@ module Groups def by_permission_scope if permission_scope_create_projects? target_user.manageable_groups(include_groups_with_developer_maintainer_access: true) + elsif permission_scope_transfer_projects? + target_user.manageable_groups(include_groups_with_developer_maintainer_access: false) else target_user.groups end @@ -55,5 +57,9 @@ module Groups def permission_scope_create_projects? params[:permission_scope] == :create_projects end + + def permission_scope_transfer_projects? + params[:permission_scope] == :transfer_projects + end end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 6bbbc237e62..8ecf0c158e0 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -316,7 +316,12 @@ class IssuableFinder # rubocop: disable CodeReuse/ActiveRecord def by_project(items) - if params.project? || params.projects + # When finding issues for multiple projects it's more efficient + # to use a JOIN instead of running a sub-query + # See https://gitlab.com/gitlab-org/gitlab/-/commit/8591cc02be6b12ed60f763a5e0147f2cbbca99e1 + if params.projects.is_a?(ActiveRecord::Relation) + items.merge(params.projects.reorder(nil)).join_project + elsif params.projects items.of_projects(params.projects).references_project else items.none @@ -431,7 +436,7 @@ class IssuableFinder elsif not_params.filter_by_started_milestone? items.joins(:milestone).merge(Milestone.not_started) else - items.without_particular_milestone(not_params[:milestone_title]) + items.without_particular_milestones(not_params[:milestone_title]) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index f6db150c5d8..6b8dcd61d29 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -16,6 +16,7 @@ # visibility_level: int # tag: string[] - deprecated, use 'topic' instead # topic: string[] +# topic_id: int # personal: boolean # search: string # search_namespaces: boolean @@ -81,6 +82,7 @@ class ProjectsFinder < UnionFinder collection = by_trending(collection) collection = by_visibility_level(collection) collection = by_topics(collection) + collection = by_topic_id(collection) collection = by_search(collection) collection = by_archived(collection) collection = by_custom_attributes(collection) @@ -186,12 +188,21 @@ class ProjectsFinder < UnionFinder topics = params[:topic].instance_of?(String) ? params[:topic].split(',') : params[:topic] topics.map(&:strip).uniq.reject(&:empty?).each do |topic| - items = items.with_topic(topic) + items = items.with_topic_by_name(topic) end items end + def by_topic_id(items) + return items unless params[:topic_id].present? + + topic = Projects::Topic.find_by(id: params[:topic_id]) # rubocop: disable CodeReuse/ActiveRecord + return Project.none unless topic + + items.with_topic(topic) + end + def by_search(items) params[:search] ||= params[:name] diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index b1e12721712..bf20a2c2c3d 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -41,6 +41,7 @@ class SnippetsFinder < UnionFinder include FinderMethods include Gitlab::Utils::StrongMemoize + include CreatedAtFilter attr_reader :current_user, :params @@ -69,6 +70,7 @@ class SnippetsFinder < UnionFinder items = init_collection items = by_ids(items) items = items.with_optional_visibility(visibility_from_scope) + items = by_created_at(items) items.order_by(sort_param) end diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index 0f7bf893bb2..3e06dbb2e2c 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -47,24 +47,6 @@ class UserRecentEventsFinder end # rubocop: disable CodeReuse/ActiveRecord - def execute_optimized_multi(users) - Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( - scope: Event.reorder(id: :desc), - array_scope: User.select(:id).where(id: users), - # Event model has a default scope { reorder(nil) } - # When a relation is rordered and used as a target when merging scope, - # its order takes a precedence and _overwrites_ the original scope's order. - # Thus we have to explicitly provide `reorder` for array_mapping_scope here. - array_mapping_scope: -> (author_id_expression) { Event.where(Event.arel_table[:author_id].eq(author_id_expression)).reorder(id: :desc) }, - finder_query: -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) } - ) - .execute - .limit(limit) - .offset(params[:offset] || 0) - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord def execute_multi users = [] @target_user.each do |user| @@ -73,26 +55,18 @@ class UserRecentEventsFinder return Event.none if users.empty? - if Feature.enabled?(:optimized_followed_users_queries, current_user) - array_data = { - scope_ids: users, - scope_model: User, - mapping_column: :author_id - } - query_builder_params = event_filter.in_operator_query_builder_params(array_data) - - Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder - .new(**query_builder_params) - .execute - .limit(limit) - .offset(params[:offset] || 0) - else - if event_filter.filter == EventFilter::ALL - execute_optimized_multi(users) - else - event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0)) - end - end + array_data = { + scope_ids: users, + scope_model: User, + mapping_column: :author_id + } + query_builder_params = event_filter.in_operator_query_builder_params(array_data) + + Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder + .new(**query_builder_params) + .execute + .limit(limit) + .offset(params[:offset] || 0) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index 6a91a097a17..cbe1cfb4099 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -15,6 +15,15 @@ module Mutations argument :title, GraphQL::Types::String, required: false, description: copy_field_description(Types::WorkItemType, :title) + argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType, + required: false, + description: 'Input for description widget.' + argument :weight_widget, ::Types::WorkItems::Widgets::WeightInputType, + required: false, + description: 'Input for weight widget.' + argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyUpdateInputType, + required: false, + description: 'Input for hierarchy widget.' end end end diff --git a/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb new file mode 100644 index 00000000000..445b2eb6441 --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + module Widgetable + extend ActiveSupport::Concern + + def extract_widget_params!(work_item_type, attributes) + # Get the list of widgets for the work item's type to extract only the supported attributes + widget_keys = ::WorkItems::Type.available_widgets.map(&:api_symbol) + widget_params = attributes.extract!(*widget_keys) + + not_supported_keys = widget_params.keys - work_item_type.widgets.map(&:api_symbol) + if not_supported_keys.present? + raise Gitlab::Graphql::Errors::ArgumentError, + "Following widget keys are not supported by #{work_item_type.name} type: #{not_supported_keys}" + end + + # Cannot use prepare to use `.to_h` on each input due to + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865 + widget_params.transform_values { |values| values.to_h } + end + end + end +end diff --git a/app/graphql/mutations/notes/create/diff_note.rb b/app/graphql/mutations/notes/create/diff_note.rb index 019e7cb8623..7b8c06fd104 100644 --- a/app/graphql/mutations/notes/create/diff_note.rb +++ b/app/graphql/mutations/notes/create/diff_note.rb @@ -32,7 +32,8 @@ module Mutations def create_note_params(noteable, args) super(noteable, args).merge({ type: 'DiffNote', - position: position(noteable, args) + position: position(noteable, args), + merge_request_diff_head_sha: args[:position][:head_sha] }) end diff --git a/app/graphql/mutations/pages/base.rb b/app/graphql/mutations/pages/base.rb new file mode 100644 index 00000000000..5eb8ecdf0ba --- /dev/null +++ b/app/graphql/mutations/pages/base.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Mutations + module Pages + class Base < BaseMutation + include FindsProject + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path of the project.' + end + end +end diff --git a/app/graphql/mutations/pages/mark_onboarding_complete.rb b/app/graphql/mutations/pages/mark_onboarding_complete.rb new file mode 100644 index 00000000000..2f5ce5db54a --- /dev/null +++ b/app/graphql/mutations/pages/mark_onboarding_complete.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Pages + class MarkOnboardingComplete < Base + graphql_name 'PagesMarkOnboardingComplete' + + field :onboarding_complete, + Boolean, + null: false, + description: "Indicates the new onboarding_complete state of the project's Pages metadata." + + authorize :admin_project + + def resolve(project_path:) + project = authorized_find!(project_path) + + project.mark_pages_onboarding_complete + + { + onboarding_complete: project.pages_metadatum.onboarding_complete, + errors: errors_on_object(project) + } + end + end + end +end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 2921a77b86d..96ac3f8a113 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -54,7 +54,7 @@ module Mutations # Only when the user is not an api user and the operation was successful if !api_user? && service_response.success? - ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user) + ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user, project: project) end snippet = service_response.payload[:snippet] diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index 2a2941c5328..39843a3714a 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -43,7 +43,7 @@ module Mutations # Only when the user is not an api user and the operation was successful if !api_user? && service_response.success? - ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user) + ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user, project: snippet.project) end snippet = service_response.payload[:snippet] diff --git a/app/graphql/mutations/user_callouts/create.rb b/app/graphql/mutations/user_callouts/create.rb index 1be99ea0ecd..7f372053e84 100644 --- a/app/graphql/mutations/user_callouts/create.rb +++ b/app/graphql/mutations/user_callouts/create.rb @@ -15,7 +15,7 @@ module Mutations description: 'User callout dismissed.' def resolve(feature_name:) - callout = Users::DismissCalloutService.new( + callout = ::Users::DismissCalloutService.new( container: nil, current_user: current_user, params: { feature_name: feature_name } ).execute errors = errors_on_object(callout) diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 2ae26ed0e1a..350153eaf19 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -7,6 +7,7 @@ module Mutations include Mutations::SpamProtection include FindsProject + include Mutations::WorkItems::Widgetable description "Creates a work item. Available only when feature flag `work_items` is enabled." @@ -15,6 +16,9 @@ module Mutations argument :description, GraphQL::Types::String, required: false, description: copy_field_description(Types::WorkItemType, :description) + argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyCreateInputType, + required: false, + description: 'Input for hierarchy widget.' argument :project_path, GraphQL::Types::ID, required: true, description: 'Full path of the project the work item is associated with.' @@ -36,10 +40,18 @@ module Mutations return { errors: ['`work_items` feature flag disabled for this project'] } end + spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) params = global_id_compatibility_params(attributes).merge(author_id: current_user.id) + type = ::WorkItems::Type.find(attributes[:work_item_type_id]) + widget_params = extract_widget_params!(type, params) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - create_result = ::WorkItems::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute + create_result = ::WorkItems::CreateService.new( + project: project, + current_user: current_user, + params: params, + spam_params: spam_params, + widget_params: widget_params + ).execute check_spam_action_response!(create_result[:work_item]) if create_result[:work_item] diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index c495da00f41..5d8c574877a 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -9,6 +9,7 @@ module Mutations include Mutations::SpamProtection include Mutations::WorkItems::UpdateArguments + include Mutations::WorkItems::Widgetable authorize :update_work_item @@ -24,19 +25,21 @@ module Mutations end spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) + widget_params = extract_widget_params!(work_item.work_item_type, attributes) - ::WorkItems::UpdateService.new( + update_result = ::WorkItems::UpdateService.new( project: work_item.project, current_user: current_user, params: attributes, + widget_params: widget_params, spam_params: spam_params ).execute(work_item) check_spam_action_response!(work_item) { - work_item: work_item.valid? ? work_item : nil, - errors: errors_on_object(work_item) + work_item: (update_result[:work_item] if update_result[:status] == :success), + errors: Array.wrap(update_result[:message]) } end diff --git a/app/graphql/mutations/work_items/update_widgets.rb b/app/graphql/mutations/work_items/update_widgets.rb index d19da0abaac..7037b7e5a2a 100644 --- a/app/graphql/mutations/work_items/update_widgets.rb +++ b/app/graphql/mutations/work_items/update_widgets.rb @@ -2,6 +2,7 @@ module Mutations module WorkItems + # TODO: Deprecate in favor of using WorkItemUpdate. See https://gitlab.com/gitlab-org/gitlab/-/issues/366300 class UpdateWidgets < BaseMutation graphql_name 'WorkItemUpdateWidgets' description "Updates the attributes of a work item's widgets by global ID." \ diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql index 264878ccaa2..5f995eb958b 100644 --- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql +++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql @@ -32,6 +32,10 @@ query getProjectContainerRepositories( createdAt expirationPolicyStartedAt expirationPolicyCleanupStatus + project { + id + path + } __typename } pageInfo { @@ -67,6 +71,10 @@ query getProjectContainerRepositories( createdAt expirationPolicyStartedAt expirationPolicyCleanupStatus + project { + id + path + } __typename } pageInfo { diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql index bcb07ae3182..914be3a72c1 100644 --- a/app/graphql/queries/repository/path_last_commit.query.graphql +++ b/app/graphql/queries/repository/path_last_commit.query.graphql @@ -4,43 +4,46 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { id repository { __typename - tree(path: $path, ref: $ref) { + paginatedTree(path: $path, ref: $ref) { __typename - lastCommit { + nodes { __typename - id - sha - title - titleHtml - descriptionHtml - message - webPath - authoredDate - authorName - authorGravatar - author { + lastCommit { __typename id - name - avatarUrl + sha + title + titleHtml + descriptionHtml + message webPath - } - signatureHtml - pipelines(ref: $ref, first: 1) { - __typename - edges { + authoredDate + authorName + authorGravatar + author { + __typename + id + name + avatarUrl + webPath + } + signatureHtml + pipelines(ref: $ref, first: 1) { __typename - node { + edges { __typename - id - detailedStatus { + node { __typename id - detailsPath - icon - tooltip - text - group + detailedStatus { + __typename + id + detailsPath + icon + tooltip + text + group + } } } } diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb index df138a15538..91f29948ad0 100644 --- a/app/graphql/resolvers/ci/jobs_resolver.rb +++ b/app/graphql/resolvers/ci/jobs_resolver.rb @@ -15,9 +15,15 @@ module Resolvers required: false, description: 'Filter jobs by status.' - def resolve(statuses: nil, security_report_types: []) + argument :retried, ::GraphQL::Types::Boolean, + required: false, + description: 'Filter jobs by retry-status.' + + def resolve(statuses: nil, security_report_types: [], retried: nil) jobs = init_collection(security_report_types) jobs = jobs.with_status(statuses) if statuses.present? + jobs = jobs.retried if retried + jobs = jobs.latest if retried == false jobs end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index e221dfea4d0..64738608b60 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -36,6 +36,10 @@ module Resolvers required: false, description: 'Sort order of results.' + argument :upgrade_status, ::Types::Ci::RunnerUpgradeStatusTypeEnum, + required: false, + description: 'Filter by upgrade status.' + def resolve_with_lookahead(**args) apply_lookahead( ::Ci::RunnersFinder @@ -54,6 +58,7 @@ module Resolvers status_status: params[:status]&.to_s, type_type: params[:type], tag_name: params[:tag_list], + upgrade_status: params[:upgrade_status], search: params[:search], sort: params[:sort]&.to_s, preload: { diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb index 5d61d9e986b..f758e217b47 100644 --- a/app/graphql/resolvers/ci/test_suite_resolver.rb +++ b/app/graphql/resolvers/ci/test_suite_resolver.rb @@ -28,7 +28,7 @@ module Resolvers def load_test_suite_data(builds) suite = builds.sum do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, pipeline.project).load! diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb index 28618bef807..0b9eb361dbd 100644 --- a/app/graphql/resolvers/clusters/agents_resolver.rb +++ b/app/graphql/resolvers/clusters/agents_resolver.rb @@ -15,12 +15,10 @@ module Resolvers description: 'Name of the cluster agent.' end - alias_method :project, :object - def resolve_with_lookahead(**args) apply_lookahead( ::Clusters::AgentsFinder - .new(project, current_user, params: args) + .new(object, current_user, params: args) .execute ) end @@ -36,3 +34,5 @@ module Resolvers end end end + +Resolvers::Clusters::AgentsResolver.prepend_mod_with('Resolvers::Clusters::AgentsResolver') diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb index f0be1b6e9a5..0653cd27b4d 100644 --- a/app/graphql/resolvers/todo_resolver.rb +++ b/app/graphql/resolvers/todo_resolver.rb @@ -2,68 +2,16 @@ module Resolvers class TodoResolver < BaseResolver - type Types::TodoType.connection_type, null: true + description 'Retrieve a single to-do item' - alias_method :target, :object + type Types::TodoType, null: true - argument :action, [Types::TodoActionEnum], - required: false, - description: 'Action to be filtered.' + argument :id, Types::GlobalIDType[Todo], + required: true, + description: 'ID of the to-do item.' - argument :author_id, [GraphQL::Types::ID], - required: false, - description: 'ID of an author.' - - argument :project_id, [GraphQL::Types::ID], - required: false, - description: 'ID of a project.' - - argument :group_id, [GraphQL::Types::ID], - required: false, - description: 'ID of a group.' - - argument :state, [Types::TodoStateEnum], - required: false, - description: 'State of the todo.' - - argument :type, [Types::TodoTargetEnum], - required: false, - description: 'Type of the todo.' - - before_connection_authorization do |nodes, current_user| - Preloaders::UserMaxAccessLevelInProjectsPreloader.new( - nodes.map(&:project).compact, - current_user - ).execute - end - - def resolve(**args) - return Todo.none unless current_user.present? && target.present? - return Todo.none if target.is_a?(User) && target != current_user - - TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations - end - - private - - def todo_finder_params(args) - { - state: args[:state], - type: args[:type], - group_id: args[:group_id], - author_id: args[:author_id], - action_id: args[:action], - project_id: args[:project_id] - }.merge(target_params) - end - - def target_params - return {} unless TodosFinder::TODO_TYPES.include?(target.class.name) - - { - type: target.class.name, - target_id: target.id - } + def resolve(id:) + GitlabSchema.find_by_gid(id) end end end diff --git a/app/graphql/resolvers/todos_resolver.rb b/app/graphql/resolvers/todos_resolver.rb new file mode 100644 index 00000000000..3e8dddb4859 --- /dev/null +++ b/app/graphql/resolvers/todos_resolver.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Resolvers + class TodosResolver < BaseResolver + type Types::TodoType.connection_type, null: true + + alias_method :target, :object + + argument :action, [Types::TodoActionEnum], + required: false, + description: 'Action to be filtered.' + + argument :author_id, [GraphQL::Types::ID], + required: false, + description: 'ID of an author.' + + argument :project_id, [GraphQL::Types::ID], + required: false, + description: 'ID of a project.' + + argument :group_id, [GraphQL::Types::ID], + required: false, + description: 'ID of a group.' + + argument :state, [Types::TodoStateEnum], + required: false, + description: 'State of the todo.' + + argument :type, [Types::TodoTargetEnum], + required: false, + description: 'Type of the todo.' + + before_connection_authorization do |nodes, current_user| + Preloaders::UserMaxAccessLevelInProjectsPreloader.new( + nodes.map(&:project).compact, + current_user + ).execute + end + + def resolve(**args) + return Todo.none unless current_user.present? && target.present? + return Todo.none if target.is_a?(User) && target != current_user + + TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations + end + + private + + def todo_finder_params(args) + { + state: args[:state], + type: args[:type], + group_id: args[:group_id], + author_id: args[:author_id], + action_id: args[:action], + project_id: args[:project_id] + }.merge(target_params) + end + + def target_params + return {} unless TodosFinder::TODO_TYPES.include?(target.class.name) + + { + type: target.class.name, + target_id: target.id + } + end + end +end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 43b7bbb419f..a0d19229d3d 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -116,7 +116,7 @@ module Types null: true, description: 'Runbook for the alert as defined in alert details.' - field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver + field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodosResolver field :details_url, GraphQL::Types::String, diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index b20a671179b..42b55f47f92 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -70,6 +70,8 @@ module Types description: 'Downstream pipeline for a bridge.' field :manual_job, GraphQL::Types::Boolean, null: true, description: 'Whether the job has a manual action.' + field :manual_variables, VariableType.connection_type, null: true, + description: 'Variables added to a manual job when the job is triggered.' field :playable, GraphQL::Types::Boolean, null: false, method: :playable?, description: 'Indicates the job can be played.' field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, @@ -78,6 +80,8 @@ module Types description: 'Ref name of the job.' field :ref_path, GraphQL::Types::String, null: true, description: 'Path to the ref.' + field :retried, GraphQL::Types::Boolean, null: true, + description: 'Indicates that the job has been retried.' field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?, description: 'Indicates the job can be retried.' field :scheduling_type, GraphQL::Types::String, null: true, @@ -188,6 +192,14 @@ module Types def triggered object.try(:trigger_request) end + + def manual_variables + if object.manual? && object.respond_to?(:job_variables) + object.job_variables + else + [] + end + end end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 949e216a982..ac5ffd39407 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -159,12 +159,19 @@ module Types owner_ids = runner_owner_ids_by_runner_id.values.flatten.uniq owners = assoc_type.where(id: owner_ids).index_by(&:id) + # Preload projects namespaces to avoid N+1 queries when checking the `read_project` policy for each + preload_projects_namespaces(owners.values) if assoc_type == Project + runner_ids.each do |runner_id| loader.call(runner_id, runner_owner_ids_by_runner_id[runner_id]&.map { |owner_id| owners[owner_id] } || []) end end end # rubocop: enable CodeReuse/ActiveRecord + + def preload_projects_namespaces(_projects) + # overridden in EE + end end end end diff --git a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb b/app/graphql/types/ci/runner_upgrade_status_type_enum.rb index 02feafe3df9..8e32eee5e6e 100644 --- a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb +++ b/app/graphql/types/ci/runner_upgrade_status_type_enum.rb @@ -5,10 +5,15 @@ module Types class RunnerUpgradeStatusTypeEnum < BaseEnum graphql_name 'CiRunnerUpgradeStatusType' - value 'UNKNOWN', description: 'Upgrade status is unknown.', value: :unknown + ::Ci::RunnerVersion::STATUS_DESCRIPTIONS.each do |status, description| + status_name_src = + if status == :invalid_version + :invalid + else + status + end - Gitlab::Ci::RunnerUpgradeCheck::STATUSES.each do |status, description| - value status.to_s.upcase, description: description, value: status + value status_name_src.to_s.upcase, description: description, value: status end end end diff --git a/app/graphql/types/ci/variable_type.rb b/app/graphql/types/ci/variable_type.rb new file mode 100644 index 00000000000..63f89b6d207 --- /dev/null +++ b/app/graphql/types/ci/variable_type.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class VariableType < BaseObject + graphql_name 'CiVariable' + + field :id, GraphQL::Types::ID, null: false, + description: 'ID of the variable.' + + field :key, GraphQL::Types::String, null: true, + description: 'Name of the variable.' + + field :value, GraphQL::Types::String, null: true, + description: 'Value of the variable.' + + field :variable_type, ::Types::Ci::VariableTypeEnum, null: true, + description: 'Type of the variable.' + + field :protected, GraphQL::Types::Boolean, null: true, + description: 'Indicates whether the variable is protected.' + + field :masked, GraphQL::Types::Boolean, null: true, + description: 'Indicates whether the variable is masked.' + + field :raw, GraphQL::Types::Boolean, null: true, + description: 'Indicates whether the variable is raw.' + + field :environment_scope, GraphQL::Types::String, null: true, + description: 'Scope defining the environments in which the variable can be used.' + + def environment_scope + if object.respond_to?(:environment_scope) + object.environment_scope + end + end + end + end +end diff --git a/app/graphql/types/ci/variable_type_enum.rb b/app/graphql/types/ci/variable_type_enum.rb new file mode 100644 index 00000000000..44430754a2e --- /dev/null +++ b/app/graphql/types/ci/variable_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class VariableTypeEnum < BaseEnum + graphql_name 'CiVariableType' + + ::Ci::Variable.variable_types.keys.each do |variable_type| + value variable_type.upcase, value: variable_type, description: "#{variable_type.humanize} type." + end + end + end +end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 49971d52a30..52e9f808066 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -194,6 +194,13 @@ module Types complexity: 5, resolver: Resolvers::GroupsResolver + field :ci_variables, + Types::Ci::VariableType.connection_type, + null: true, + description: "List of the group's CI/CD variables.", + authorize: :admin_group, + method: :variables + field :runners, Types::Ci::RunnerType.connection_type, null: true, resolver: Resolvers::Ci::GroupRunnersResolver, diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb index b18c8b90e96..bc21b802179 100644 --- a/app/graphql/types/issue_type_enum.rb +++ b/app/graphql/types/issue_type_enum.rb @@ -8,5 +8,9 @@ module Types ::WorkItems::Type.allowed_types_for_issues.each do |issue_type| value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type" end + + value 'TASK', value: 'task', + description: 'Task issue type. Available only when feature flag `work_items` is enabled.', + deprecated: { milestone: '15.2', reason: :alpha } end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 8642957af02..46ab3f3f432 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -148,6 +148,7 @@ module Types mount_mutation Mutations::WorkItems::UpdateTask, deprecated: { milestone: '15.1', reason: :alpha } mount_mutation Mutations::SavedReplies::Create mount_mutation Mutations::SavedReplies::Update + mount_mutation Mutations::Pages::MarkOnboardingComplete mount_mutation Mutations::SavedReplies::Destroy end end diff --git a/app/graphql/types/permission_types/group_enum.rb b/app/graphql/types/permission_types/group_enum.rb index cc4f5e9f1f0..8b0fee8898c 100644 --- a/app/graphql/types/permission_types/group_enum.rb +++ b/app/graphql/types/permission_types/group_enum.rb @@ -7,6 +7,8 @@ module Types description 'User permission on groups' value 'CREATE_PROJECTS', value: :create_projects, description: 'Groups where the user can create projects.' + value 'TRANSFER_PROJECTS', value: :transfer_projects, + description: 'Groups where the user can transfer projects to.' end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 603d5ead540..7e3800c6a13 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -70,10 +70,10 @@ module Types description: 'Indicates if shared runners are enabled for the project.' field :service_desk_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the project has service desk enabled.' + description: 'Indicates if the project has Service Desk enabled.' field :service_desk_address, GraphQL::Types::String, null: true, - description: 'E-mail address of the service desk.' + description: 'E-mail address of the Service Desk.' field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'URL to avatar image file of the project.' @@ -220,6 +220,13 @@ module Types description: 'Build pipeline counts of the project.', resolver: Resolvers::Ci::ProjectPipelineCountsResolver + field :ci_variables, + Types::Ci::VariableType.connection_type, + null: true, + description: "List of the project's CI/CD variables.", + authorize: :admin_build, + method: :variables + field :ci_cd_settings, Types::Ci::CiCdSettingType, null: true, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 46d121f6552..9207a867639 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -123,6 +123,11 @@ module Types resolver: Resolvers::Ci::RunnersResolver, description: "Find runners visible to the current user." + field :ci_variables, + Types::Ci::VariableType.connection_type, + null: true, + description: "List of the instance's CI/CD variables." + field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1 field :timelogs, Types::TimelogType.connection_type, @@ -136,6 +141,10 @@ module Types null: true, resolver: Resolvers::BoardListResolver + field :todo, + null: true, + resolver: Resolvers::TodoResolver + field :topics, Types::Projects::TopicType.connection_type, null: true, resolver: Resolvers::TopicsResolver, @@ -174,6 +183,12 @@ module Types application_settings end + def ci_variables + return unless current_user.can_admin_all_resources? + + ::Ci::InstanceVariable.all + end + def application_settings Gitlab::CurrentSettings.current_application_settings end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index 43dc0c4ce85..d906c577aa5 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -40,6 +40,8 @@ module Types authorize: :download_code field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?, description: 'Indicates the release is an upcoming release.' + field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?, + description: 'Indicates the release is an historical release.' field :author, Types::UserType, null: true, description: 'User that created the release.' diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 1c8a1352c72..edbc8aee9c5 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -88,7 +88,7 @@ module Types null: true, description: 'Personal namespace of the user.' - field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.' + field :todos, resolver: Resolvers::TodosResolver, description: 'To-do items of the user.' # Merge request field: MRs can be authored, assigned, or assigned-for-review: field :authored_merge_requests, diff --git a/app/graphql/types/work_item_id_type.rb b/app/graphql/types/work_item_id_type.rb index ddcf3416014..bb01f865414 100644 --- a/app/graphql/types/work_item_id_type.rb +++ b/app/graphql/types/work_item_id_type.rb @@ -27,6 +27,7 @@ module Types def coerce_input(string, ctx) gid = super + return if gid.nil? # Always return a WorkItemID even if an Issue Global ID is provided as input return work_item_gid(gid) if suitable?(gid) diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index f3cf1d74829..1b752393296 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -16,13 +16,19 @@ module Types ::Types::WorkItems::Widgets::DescriptionType when ::WorkItems::Widgets::Hierarchy ::Types::WorkItems::Widgets::HierarchyType + when ::WorkItems::Widgets::Assignees + ::Types::WorkItems::Widgets::AssigneesType + when ::WorkItems::Widgets::Weight + ::Types::WorkItems::Widgets::WeightType else raise "Unknown GraphQL type for widget #{object}" end end orphan_types ::Types::WorkItems::Widgets::DescriptionType, - ::Types::WorkItems::Widgets::HierarchyType + ::Types::WorkItems::Widgets::HierarchyType, + ::Types::WorkItems::Widgets::AssigneesType, + ::Types::WorkItems::Widgets::WeightType end end end diff --git a/app/graphql/types/work_items/widgets/assignees_type.rb b/app/graphql/types/work_items/widgets/assignees_type.rb new file mode 100644 index 00000000000..08ee06fdfa0 --- /dev/null +++ b/app/graphql/types/work_items/widgets/assignees_type.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # Disabling widget level authorization as it might be too granular + # and we already authorize the parent work item + # rubocop:disable Graphql/AuthorizeTypes + class AssigneesType < BaseObject + graphql_name 'WorkItemWidgetAssignees' + description 'Represents an assignees widget' + + implements Types::WorkItems::WidgetInterface + + field :assignees, Types::UserType.connection_type, null: true, + description: 'Assignees of the work item.' + + field :allows_multiple_assignees, GraphQL::Types::Boolean, null: true, method: :allows_multiple_assignees?, + description: 'Indicates whether multiple assignees are allowed.' + + field :can_invite_members, GraphQL::Types::Boolean, null: false, resolver_method: :can_invite_members?, + description: 'Indicates whether the current user can invite members to the work item\'s project.' + + def can_invite_members? + Ability.allowed?(current_user, :admin_project_member, object.work_item.project) + end + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb b/app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb new file mode 100644 index 00000000000..cee6d69cd0c --- /dev/null +++ b/app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class HierarchyCreateInputType < BaseInputObject + graphql_name 'WorkItemWidgetHierarchyCreateInput' + + argument :parent_id, ::Types::GlobalIDType[::WorkItem], + required: false, + loads: ::Types::WorkItemType, + description: 'Global ID of the parent work item.' + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb new file mode 100644 index 00000000000..e1a9ebb76e9 --- /dev/null +++ b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class HierarchyUpdateInputType < BaseInputObject + graphql_name 'WorkItemWidgetHierarchyUpdateInput' + + argument :parent_id, ::Types::GlobalIDType[::WorkItem], + required: false, + loads: ::Types::WorkItemType, + description: 'Global ID of the parent work item. Use `null` to remove the association.' + + argument :children_ids, [::Types::GlobalIDType[::WorkItem]], + required: false, + description: 'Global IDs of children work items.', + loads: ::Types::WorkItemType, + as: :children + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/weight_input_type.rb b/app/graphql/types/work_items/widgets/weight_input_type.rb new file mode 100644 index 00000000000..a01c63222a5 --- /dev/null +++ b/app/graphql/types/work_items/widgets/weight_input_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class WeightInputType < BaseInputObject + graphql_name 'WorkItemWidgetWeightInput' + + argument :weight, GraphQL::Types::Int, + required: true, + description: 'Weight of the work item.' + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/weight_type.rb b/app/graphql/types/work_items/widgets/weight_type.rb new file mode 100644 index 00000000000..c8eaf560268 --- /dev/null +++ b/app/graphql/types/work_items/widgets/weight_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # Disabling widget level authorization as it might be too granular + # and we already authorize the parent work item + # rubocop:disable Graphql/AuthorizeTypes + class WeightType < BaseObject + graphql_name 'WorkItemWidgetWeight' + description 'Represents a weight widget' + + implements Types::WorkItems::WidgetInterface + + field :weight, GraphQL::Types::Int, null: true, + description: 'Weight of the work item.' + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index cd31d2c75ab..321a6e9395e 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -238,6 +238,8 @@ module ApplicationSettingsHelper :email_author_in_body, :enabled_git_access_protocol, :enforce_terms, + :error_tracking_enabled, + :error_tracking_api_url, :external_pipeline_validation_service_timeout, :external_pipeline_validation_service_token, :external_pipeline_validation_service_url, @@ -402,6 +404,7 @@ module ApplicationSettingsHelper :wiki_page_max_content_bytes, :container_registry_delete_tags_service_timeout, :rate_limiting_response_text, + :package_registry_cleanup_policies_worker_capacity, :container_registry_expiration_policies_worker_capacity, :container_registry_cleanup_tags_service_max_list_size, :container_registry_import_max_tags_count, diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index bb3f7b5aa79..d044a93213a 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -18,6 +18,7 @@ module Ci "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), + "ci-lint-path" => project_ci_lint_path(project), "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'), "initial-branch-name" => initial_branch, @@ -32,6 +33,7 @@ module Ci "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, "runner-help-page-path" => help_page_path('ci/runners/index'), + "simulate-pipeline-help-page-path" => help_page_path('ci/lint', anchor: 'simulate-a-pipeline'), "total-branches" => total_branches, "validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'), "yml-help-page-path" => help_page_path('ci/yaml/index') diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 3c3179f6fbe..33b771eef69 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -184,7 +184,7 @@ module CommitsHelper def diff_mode_swap_button(mode, file_hash) icon = mode == 'raw' ? 'doc-code' : 'doc-text' - entity = mode == 'raw' ? 'toHideBtn' : 'toShowBtn' + entity = mode == 'raw' ? 'rawButton' : 'renderedButton' title = "Display #{mode} diff" link_to("##{mode}-diff-#{file_hash}", diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 71c8296ad2e..457502347ee 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -33,6 +33,7 @@ module DiffHelper if action_name == 'diff_for_path' options[:expanded] = true options[:paths] = params.values_at(:old_path, :new_path) + options[:use_extra_viewer_as_main] = false end options @@ -227,6 +228,7 @@ module DiffHelper def conflicts(allow_tree_conflicts: false) return unless options[:merge_ref_head_diff] + return unless merge_request.cannot_be_merged? conflicts_service = MergeRequests::Conflicts::ListService.new(merge_request, allow_tree_conflicts: allow_tree_conflicts) # rubocop:disable CodeReuse/ServiceClass diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index c23d905a008..54733fa9101 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -116,19 +116,16 @@ module EmailsHelper end end - # "You are receiving this email because #{reason} on #{gitlab_host}." - def notification_reason_text(reason) - gitlab_host = Gitlab.config.gitlab.host - - case reason - when NotificationReason::OWN_ACTIVITY - _("You're receiving this email because of your activity on %{host}.") % { host: gitlab_host } - when NotificationReason::ASSIGNED - _("You're receiving this email because you have been assigned an item on %{host}.") % { host: gitlab_host } - when NotificationReason::MENTIONED - _("You're receiving this email because you have been mentioned on %{host}.") % { host: gitlab_host } + # "You are receiving this email because ... on #{host}. ..." + def notification_reason_text(reason: nil, show_manage_notifications_link: false, show_help_link: false, manage_label_subscriptions_url: nil, unsubscribe_url: nil, format: :text) + if unsubscribe_url && show_manage_notifications_link && show_help_link + notification_reason_text_with_unsubscribe_and_manage_notifications_and_help_links(reason: reason, unsubscribe_url: unsubscribe_url, format: format) + elsif !reason && manage_label_subscriptions_url && show_help_link + notification_reason_text_with_manage_label_subscriptions_and_help_links(manage_label_subscriptions_url: manage_label_subscriptions_url, format: format) + elsif show_manage_notifications_link && show_help_link + notification_reason_text_with_manage_notifications_and_help_links(reason: reason, format: format) else - _("You're receiving this email because of your account on %{host}.") % { host: gitlab_host } + notification_reason_text_without_links(reason: reason, format: format) end end @@ -259,9 +256,7 @@ module EmailsHelper end def instance_access_request_text(user, format: nil) - gitlab_host = Gitlab.config.gitlab.host - - _('%{username} has asked for a GitLab account on your instance %{host}:') % { username: sanitize_name(user.name), host: gitlab_host } + _('%{username} has asked for a GitLab account on your instance %{host}:').html_safe % { username: sanitize_name(user.name), host: gitlab_host_link(format) } end def instance_access_request_link(user, format: nil) @@ -276,6 +271,14 @@ module EmailsHelper end end + def link_start(url) + '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: url } + end + + def link_end + '</a>'.html_safe + end + def contact_your_administrator_text _('Please contact your administrator with any questions.') end @@ -317,6 +320,75 @@ module EmailsHelper def email_header_and_footer_enabled? current_appearance&.email_header_and_footer_enabled? end + + def gitlab_host_link(format) + case format + when :html + generate_link(Gitlab.config.gitlab.host, Gitlab.config.gitlab.url) + when :text + Gitlab.config.gitlab.host + end + end + + def notification_reason_text_with_unsubscribe_and_manage_notifications_and_help_links(reason:, unsubscribe_url:, format:) + unsubscribe_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: unsubscribe_url } + unsubscribe_link_end = '</a>'.html_safe + + manage_notifications_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="mng-notif-link">'.html_safe % { url: profile_notifications_url } + manage_notifications_link_end = '</a>'.html_safe + + help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="help-link">'.html_safe % { url: help_url } + help_link_end = '</a>'.html_safe + + case reason + when NotificationReason::OWN_ACTIVITY + _("You're receiving this email because of your activity on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread · %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + when NotificationReason::ASSIGNED + _("You're receiving this email because you have been assigned an item on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread · %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + when NotificationReason::MENTIONED + _("You're receiving this email because you have been mentioned on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread · %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + else + _("You're receiving this email because of your account on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread · %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + end + end + + def notification_reason_text_with_manage_label_subscriptions_and_help_links(manage_label_subscriptions_url:, format:) + manage_label_subscriptions_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="mng-notif-link">'.html_safe % { url: manage_label_subscriptions_url } + manage_label_subscriptions_link_end = '</a>'.html_safe + + help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="help-link">'.html_safe % { url: help_url } + help_link_end = '</a>'.html_safe + + _("You're receiving this email because of your account on %{host}. %{manage_label_subscriptions_link_start}Manage label subscriptions%{manage_label_subscriptions_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), manage_label_subscriptions_link_start: manage_label_subscriptions_link_start, manage_label_subscriptions_link_end: manage_label_subscriptions_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + end + + def notification_reason_text_with_manage_notifications_and_help_links(reason:, format:) + manage_notifications_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="mng-notif-link">'.html_safe % { url: profile_notifications_url } + manage_notifications_link_end = '</a>'.html_safe + + help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="help-link">'.html_safe % { url: help_url } + help_link_end = '</a>'.html_safe + + case reason + when NotificationReason::MENTIONED + _("You're receiving this email because you have been mentioned on %{host}. %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + else + _("You're receiving this email because of your account on %{host}. %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} · %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end } + end + end + + def notification_reason_text_without_links(reason:, format:) + case reason + when NotificationReason::OWN_ACTIVITY + _("You're receiving this email because of your activity on %{host}.").html_safe % { host: gitlab_host_link(format) } + when NotificationReason::ASSIGNED + _("You're receiving this email because you have been assigned an item on %{host}.").html_safe % { host: gitlab_host_link(format) } + when NotificationReason::MENTIONED + _("You're receiving this email because you have been mentioned on %{host}.").html_safe % { host: gitlab_host_link(format) } + else + _("You're receiving this email because of your account on %{host}.").html_safe % { host: gitlab_host_link(format) } + end + end end EmailsHelper.prepend_mod_with('EmailsHelper') diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 59d43c51db2..2623e32dbc8 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -36,7 +36,6 @@ module EnvironmentsHelper "environment_name": environment.name, "environments_path": api_v4_projects_environments_path(id: project.id), "environment_id": environment.id, - "cluster_applications_documentation_path" => help_page_path('user/clusters/integrations.md', anchor: 'elastic-stack-cluster-integration'), "clusters_path": project_clusters_path(project, format: :json) } end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 37b23345d2a..2021961772a 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -9,7 +9,7 @@ module Groups::GroupMembersHelper { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } end - def group_members_app_data(group, members:, invited:, access_requests:, include_relations:, search:) + def group_members_app_data(group, members:, invited:, access_requests:, banned:, include_relations:, search:) { user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }), group: group_group_links_list_data(group, include_relations, search), diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 9ea9509bc28..9d152416b2e 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -126,7 +126,7 @@ module GroupsHelper group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group) end - def show_thanks_for_purchase_banner? + def show_thanks_for_purchase_alert? params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0 end diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 8d5523464c7..a1512d40235 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -216,7 +216,7 @@ module IntegrationsHelper end def fields_for_integration(integration) - Integrations::FieldSerializer.new(integration: integration).represent(integration.global_fields).to_json + Integrations::FieldSerializer.new(integration: integration).represent(integration.form_fields).to_json end def integration_level(integration) diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb index 890f7f099df..421cf84f98c 100644 --- a/app/helpers/learn_gitlab_helper.rb +++ b/app/helpers/learn_gitlab_helper.rb @@ -4,6 +4,7 @@ module LearnGitlabHelper IMAGE_PATH_PLAN = "learn_gitlab/section_plan.svg" IMAGE_PATH_DEPLOY = "learn_gitlab/section_deploy.svg" IMAGE_PATH_WORKSPACE = "learn_gitlab/section_workspace.svg" + LICENSE_SCANNING_RUN_URL = 'https://docs.gitlab.com/ee/user/compliance/license_compliance/index.html' def learn_gitlab_enabled?(project) return false unless current_user @@ -64,7 +65,7 @@ module LearnGitlabHelper git_write: project_path(project), merge_request_created: project_merge_requests_path(project), user_added: project_members_url(project), - security_scan_enabled: project_security_configuration_path(project) + **deploy_section_action_urls(project) ) end @@ -72,6 +73,23 @@ module LearnGitlabHelper LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } end + def deploy_section_action_urls(project) + experiment(:security_actions_continuous_onboarding, + namespace: project.namespace, + user: current_user, + sticky_to: current_user + ) do |e| + e.control { { security_scan_enabled: project_security_configuration_path(project) } } + e.candidate do + { + license_scanning_run: LICENSE_SCANNING_RUN_URL, + secure_dependency_scanning_run: project_security_configuration_path(project, anchor: 'dependency-scanning'), + secure_dast_run: project_security_configuration_path(project, anchor: 'dast') + } + end + end.run + end + def learn_gitlab_project @learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 777d485797f..6077a059f6f 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -8,8 +8,8 @@ module MarkupHelper # Let's increase the render timeout # For a smaller one, a test that renders the blob content statically fails - # We can consider removing this custom timeout when refactor_blob_viewer FF is removed: - # https://gitlab.com/gitlab-org/gitlab/-/issues/324351 + # We can consider removing this custom timeout when markup_rendering_timeout FF is removed: + # https://gitlab.com/gitlab-org/gitlab/-/issues/365358 RENDER_TIMEOUT = 5.seconds def plain?(filename) diff --git a/app/helpers/namespace_storage_limit_alert_helper.rb b/app/helpers/namespace_storage_limit_alert_helper.rb deleted file mode 100644 index ed11f89a7dd..00000000000 --- a/app/helpers/namespace_storage_limit_alert_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module NamespaceStorageLimitAlertHelper - # Overridden in EE - def display_namespace_storage_limit_alert! - end -end - -NamespaceStorageLimitAlertHelper.prepend_mod_with('NamespaceStorageLimitAlertHelper') diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index 469d6c1a7eb..fb8fafe59f3 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -16,7 +16,7 @@ module Nav menu_sections.push(general_menu_section) { - title: _("Create new"), + title: _("Create new..."), menu_sections: menu_sections.select { |x| x.fetch(:menu_items).any? } } end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 20d40626449..ec64746d6b6 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -53,4 +53,14 @@ module PackagesHelper project.container_expiration_policy.nil? && project.container_repositories.exists? end + + def show_container_registry_settings(project) + Gitlab.config.registry.enabled && + Ability.allowed?(current_user, :admin_container_image, project) + end + + def show_package_registry_settings(project) + Gitlab.config.packages.enabled && + Ability.allowed?(current_user, :admin_package, project) + end end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index eeee8290914..3b3fe13e58a 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -4,7 +4,7 @@ module Projects module PipelineHelper extend ::Ci::BuildsHelper - def js_pipeline_tabs_data(project, pipeline) + def js_pipeline_tabs_data(project, pipeline, _user) { can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json, failed_jobs_count: pipeline.failed_builds.count, diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb index d5cc2b72ae9..51a7d3e35d0 100644 --- a/app/helpers/projects/project_members_helper.rb +++ b/app/helpers/projects/project_members_helper.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true module Projects::ProjectMembersHelper - def project_members_app_data_json(project, members:, group_links:, invited:, access_requests:) + def project_members_app_data_json(project, members:, invited:, access_requests:, include_relations:, search:) { user: project_members_list_data(project, members, { param_name: :page, params: { search_groups: nil } }), - group: project_group_links_list_data(project, group_links), + group: project_group_links_list_data(project, include_relations, search), invite: project_members_list_data(project, invited.nil? ? [] : invited), access_request: project_members_list_data(project, access_requests.nil? ? [] : access_requests), source_id: project.id, @@ -57,10 +57,29 @@ module Projects::ProjectMembersHelper } end - def project_group_links_list_data(project, group_links) + def project_group_links_list_data(project, include_relations, search) + members = [] + + if include_relations.include?(:direct) + project_group_links = project.project_group_links + project_group_links = project_group_links.search(search) if search + members += project_group_links_serialized(project, project_group_links) + end + + if include_relations.include?(:inherited) + group_group_links = project.group_group_links.distinct_on_shared_with_group_id_with_group_access + group_group_links = group_group_links.search(search) if search + members += group_group_links_serialized(project, group_group_links) + end + + if project_group_links.present? && group_group_links.present? + members = members.sort_by { |m| -m.dig(:access_level, :integer_value).to_i } + .uniq { |m| m.dig(:shared_with_group, :id) } + end + { - members: project_group_links_serialized(project, group_links), - pagination: members_pagination_data(group_links), + members: members, + pagination: members_pagination_data(members), member_path: project_group_link_path(project, ':id') } end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 95e91a7ba27..2ece3e87500 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -377,7 +377,7 @@ module ProjectsHelper canDisableEmails: can_disable_emails?(project, current_user), canChangeVisibilityLevel: can_change_visibility_level?(project, current_user), allowedVisibilityOptions: project_allowed_visibility_levels(project), - visibilityHelpPath: help_page_path('public_access/public_access'), + visibilityHelpPath: help_page_path('user/public_access'), registryAvailable: Gitlab.config.registry.enabled, registryHelpPath: help_page_path('user/packages/container_registry/index'), lfsAvailable: Gitlab.config.lfs.enabled, @@ -388,7 +388,8 @@ module ProjectsHelper pagesAccessControlEnabled: Gitlab.config.pages.access_control, pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?, pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control'), - issuesHelpPath: help_page_path('user/project/issues/index') + issuesHelpPath: help_page_path('user/project/issues/index'), + membersPagePath: project_project_members_path(project) } end @@ -684,7 +685,6 @@ module ProjectsHelper product_analytics metrics_dashboard feature_flags - tracings terraform ] end diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index a516ac85131..50089c7edab 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -53,12 +53,14 @@ module ReleasesHelper def data_for_edit_release_page new_edit_pages_shared_data.merge( tag_name: @release.tag, - releases_page_path: project_releases_path(@project, anchor: @release.tag) + releases_page_path: project_releases_path(@project, anchor: @release.tag), + delete_release_docs_path: releases_help_page_path(anchor: 'delete-a-release') ) end def data_for_new_release_page new_edit_pages_shared_data.merge( + tag_name: params[:tag_name], default_branch: @project.default_branch, releases_page_path: project_releases_path(@project) ) @@ -81,7 +83,8 @@ module ReleasesHelper release_assets_docs_path: releases_help_page_path(anchor: 'release-assets'), manage_milestones_path: project_milestones_path(@project), new_milestone_path: new_project_milestone_path(@project), - edit_release_docs_path: releases_help_page_path(anchor: 'edit-a-release') + edit_release_docs_path: releases_help_page_path(anchor: 'edit-a-release'), + upcoming_release_docs_path: releases_help_page_path(anchor: 'upcoming-releases') } end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index c8750cd9b52..ecbcaec27bc 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module SearchHelper + # params which should persist when a new tab is selected SEARCH_GENERIC_PARAMS = [ :search, :scope, @@ -129,7 +130,7 @@ module SearchHelper end def search_service - @search_service ||= ::SearchService.new(current_user, params.merge(confidential: Gitlab::Utils.to_boolean(params[:confidential]))) + @search_service ||= ::SearchService.new(current_user, sanitized_search_params) end def search_sort_options @@ -169,7 +170,7 @@ module SearchHelper # search_context exposes a bit too much data to the frontend, this controls what data we share and when. def header_search_context {}.tap do |hash| - hash[:group] = { id: search_context.group.id, name: search_context.group.name } if search_context.for_group? + hash[:group] = { id: search_context.group.id, name: search_context.group.name, full_name: search_context.group.full_name } if search_context.for_group? hash[:group_metadata] = search_context.group_metadata if search_context.for_group? hash[:project] = { id: search_context.project.id, name: search_context.project.name } if search_context.for_project? @@ -207,10 +208,10 @@ module SearchHelper { category: "Help", label: _("API Help"), url: help_page_path("api/index") }, { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") }, { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") }, - { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") }, + { category: "Help", label: _("Public Access Help"), url: help_page_path("user/public_access") }, { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/index") }, - { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/index") }, - { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") }, + { category: "Help", label: _("SSH Keys Help"), url: help_page_path("user/ssh") }, + { category: "Help", label: _("System Hooks Help"), url: help_page_path("administration/system_hooks") }, { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") } ] end @@ -481,6 +482,13 @@ module SearchHelper def feature_flag_tab_enabled?(flag) @group || Feature.enabled?(flag, current_user, type: :ops) end + + def sanitized_search_params + sanitized_params = params.dup + sanitized_params[:confidential] = Gitlab::Utils.to_boolean(sanitized_params[:confidential]) if sanitized_params.key?(:confidential) + + sanitized_params + end end SearchHelper.prepend_mod_with('SearchHelper') diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index f0389000eb3..129180d1ccf 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -39,4 +39,16 @@ module SessionsHelper # 2. https://github.com/redis-store/redis-store/blob/3acfa95f4eb6260c714fdb00a3d84be8eedc13b2/lib/redis/store/ttl.rb#L32 request.env['rack.session.options'][:expire_after] = expiry_s end + + def send_rate_limited?(user) + Gitlab::ApplicationRateLimiter.peek(:email_verification_code_send, scope: user) + end + + def obfuscated_email(email) + regex = ::Gitlab::UntrustedRegexp.new('^(..?)(.*)(@.?)(.*)(\..*)$') + match = regex.match(email) + return email unless match + + match[1] + '*' * match[2].length + match[3] + '*' * match[4].length + match[5] + end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 6f15cc7f4ec..ef79e2bc86f 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -254,6 +254,7 @@ module SortingHelper options = [ { value: sort_value_priority, text: sort_title_priority, href: page_filter_path(sort: sort_value_priority) }, { value: sort_value_created_date, text: sort_title_created_date, href: page_filter_path(sort: sort_value_created_date) }, + { value: sort_value_closed_date, text: sort_title_closed_date, href: page_filter_path(sort: sort_value_closed_date) }, { value: sort_value_recently_updated, text: sort_title_recently_updated, href: page_filter_path(sort: sort_value_recently_updated) }, { value: sort_value_milestone, text: sort_title_milestone, href: page_filter_path(sort: sort_value_milestone) } ] @@ -261,7 +262,7 @@ module SortingHelper options.concat([due_date_option]) if viewing_issues options.concat([popularity_option, label_priority_option]) - options.concat([merged_option, closed_option]) if viewing_merge_requests + options.concat([merged_option]) if viewing_merge_requests options.concat([relative_position_option]) if viewing_issues options.concat([title_option]) @@ -287,10 +288,6 @@ module SortingHelper { value: sort_value_merged_date, text: sort_title_merged_date, href: page_filter_path(sort: sort_value_merged_date) } end - def closed_option - { value: sort_value_closed_date, text: sort_title_closed_date, href: page_filter_path(sort: sort_value_closed_date) } - end - def relative_position_option { value: sort_value_relative_position, text: sort_title_relative_position, href: page_filter_path(sort: sort_value_relative_position) } end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index 38ae9b5b634..ca81d5af4af 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -27,10 +27,11 @@ module StorageHelper def storage_enforcement_banner_info(namespace) root_ancestor = namespace.root_ancestor - return unless can?(current_user, :admin_namespace, root_ancestor) + return unless can?(current_user, :maintain_namespace, root_ancestor) return if root_ancestor.paid? return unless future_enforcement_date?(root_ancestor) return if user_dismissed_storage_enforcement_banner?(root_ancestor) + return unless ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor) { text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \ diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 8529959f73c..f87125af07d 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -66,7 +66,13 @@ module TodosHelper return _('design') if todo.for_design? return _('alert') if todo.for_alert? - todo.target_type.titleize.downcase + target_type = if todo.for_issue_or_work_item? + todo.target.issue_type + else + todo.target_type + end + + target_type.titleize.downcase end def todo_target_path(todo) @@ -80,6 +86,9 @@ module TodosHelper todos_design_path(todo, path_options) elsif todo.for_alert? details_project_alert_management_path(todo.project, todo.target) + elsif todo.for_issue_or_work_item? + path_options[:only_path] = true + Gitlab::UrlBuilder.build(todo.target, **path_options) else path = [todo.resource_parent, todo.target] diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 2fef4ae98a9..370dbb10462 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -58,14 +58,6 @@ module TreeHelper "#{username}-#{ref}-patch-#{epoch}" end - def tree_edit_project(project = @project) - if can?(current_user, :push_code, project) - project - elsif current_user && current_user.already_forked?(project) - current_user.fork_of(project) - end - end - def edit_in_new_fork_notice_now _("You're not allowed to make changes to this project directly. "\ "A fork of this project is being created that you can make changes in, so you can submit a merge request.") @@ -111,16 +103,6 @@ module TreeHelper end end - def up_dir_path - file = File.join(@path, "..") - tree_join(@ref, file) - end - - # returns the relative path of the first subdir that doesn't have only one directory descendant - def flatten_tree(root_path, tree) - tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') - end - def selected_branch @branch_name || tree_edit_branch end diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index b8231b02ac1..3dd6b3f4a80 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -9,9 +9,9 @@ module Users FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' - MINUTE_LIMIT_BANNER = 'minute_limit_banner' SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze + WEB_HOOK_DISABLED = 'web_hook_disabled' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -61,16 +61,31 @@ module Users !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) end - def minute_limit_banner_dismissed? - user_dismissed?(MINUTE_LIMIT_BANNER) + def web_hook_disabled_dismissed?(project) + return false unless project + + last_failure = Gitlab::Redis::SharedState.with do |redis| + key = "web_hooks:last_failure:project-#{project.id}" + redis.get(key) + end + + last_failure = DateTime.parse(last_failure) if last_failure + + user_dismissed?(WEB_HOOK_DISABLED, last_failure, namespace: project.namespace) end private - def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) + def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, namespace: nil) return false unless current_user - current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + query = { feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than } + + if namespace + current_user.dismissed_callout_for_namespace?(namespace: namespace, **query) + else + current_user.dismissed_callout?(**query) + end end end end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index e46aa6a446c..4ea2512bc67 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -15,7 +15,7 @@ module UsersHelper end def user_email_help_text(user) - return 'We also use email for avatar detection if no avatar is uploaded' unless user.unconfirmed_email.present? + return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present? confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index b49bd33a30b..1baeeda0ef7 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -126,7 +126,7 @@ module VisibilityLevelHelper def project_visibility_level_description(level) case level when Gitlab::VisibilityLevel::PRIVATE - _("Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.") + _("Project access must be granted explicitly to each user. If this project is part of a group, access is granted to members of the group.") when Gitlab::VisibilityLevel::INTERNAL _("The project can be accessed by any logged in user except external users.") when Gitlab::VisibilityLevel::PUBLIC diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb new file mode 100644 index 00000000000..95122750c2f --- /dev/null +++ b/app/helpers/web_hooks/web_hooks_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module WebHooks + module WebHooksHelper + EXPIRY_TTL = 1.hour + + def show_project_hook_failed_callout?(project:) + return false unless current_user + return false unless Feature.enabled?(:webhooks_failed_callout, project) + return false unless Feature.enabled?(:web_hooks_disable_failed, project) + return false unless Ability.allowed?(current_user, :read_web_hooks, project) + + # Assumes include of Users::CalloutsHelper + return false if web_hook_disabled_dismissed?(project) + + any_project_hook_failed?(project) # Most expensive query last + end + + private + + def any_project_hook_failed?(project) + Rails.cache.fetch("any_web_hook_failed:#{project.id}", expires_in: EXPIRY_TTL) do + ProjectHook.for_projects(project).disabled.exists? + end + end + end +end diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb index f44dd448a35..9d02d4132a1 100644 --- a/app/mailers/emails/admin_notification.rb +++ b/app/mailers/emails/admin_notification.rb @@ -16,11 +16,16 @@ module Emails mail to: email, subject: "Unsubscribed from GitLab administrator notifications" end - def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:) + def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:, group: nil) admin = User.find(admin_id) @user = User.find(user_id) @max_project_downloads = max_project_downloads @within_minutes = within_seconds / 60 + @ban_scope = if group.present? + _('your group (%{group_name})' % { group_name: group.name }) + else + _('your GitLab instance') + end Gitlab::I18n.with_locale(admin.preferred_language) do email_with_layout( diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb new file mode 100644 index 00000000000..2fc8cae06fe --- /dev/null +++ b/app/mailers/emails/identity_verification.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Emails + module IdentityVerification + def verification_instructions_email(user_id, token:, expires_in:) + @token = token + @expires_in_minutes = expires_in + @password_link = edit_profile_password_url + @two_fa_link = help_page_url('user/profile/account/two_factor_authentication') + + user = User.find(user_id) + email_with_layout(to: user.email, subject: s_('IdentityVerification|Verify your identity')) + end + end +end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 83d37a365de..6a2b447f4a0 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -15,15 +15,15 @@ module Emails end # existing_commits - an array containing the first and last commits - def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], total_new_commits_count: nil, existing_commits: [], total_existing_commits_count: nil) + def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits:, total_new_commits_count:, existing_commits:, total_existing_commits_count:) setup_merge_request_mail(merge_request_id, recipient_id) @new_commits = new_commits - @total_new_commits_count = total_new_commits_count || @new_commits.length + @total_new_commits_count = total_new_commits_count @total_stripped_new_commits_count = @total_new_commits_count - @new_commits.length @existing_commits = existing_commits - @total_existing_commits_count = total_existing_commits_count || @existing_commits.length + @total_existing_commits_count = total_existing_commits_count @updated_by_user = User.find(updated_by_user_id) diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index b70ce1d3655..ed7681e595f 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -23,6 +23,7 @@ class Notify < ApplicationMailer include Emails::ServiceDesk include Emails::InProductMarketing include Emails::AdminNotification + include Emails::IdentityVerification helper TimeboxesHelper helper MergeRequestsHelper diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 61456ef79c8..be8d96012cc 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -205,10 +205,18 @@ class NotifyPreview < ActionMailer::Preview Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message end - def user_auto_banned_email + def user_auto_banned_instance_email ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600).message end + def user_auto_banned_namespace_email + ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600, group: group).message + end + + def verification_instructions_email + Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message + end + private def project @@ -239,6 +247,10 @@ class NotifyPreview < ActionMailer::Preview @user ||= User.last end + def group + @group ||= Group.last + end + def member @member ||= Member.last end diff --git a/app/models/ability.rb b/app/models/ability.rb index a185448d5ea..b15143c8c9c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -26,6 +26,13 @@ class Ability end end + # A list of users that can read confidential notes in a project + def users_that_can_read_internal_notes(users, note_parent) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :reporter_access, note_parent) } + end + end + # Returns an Array of Issues that can be read by the given user. # # issues - The issues to reduce down to those readable by the user. diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 6acdc02c799..17b46f929c3 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -28,6 +28,7 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :required + add_authentication_token_field :error_tracking_access_token, encrypted: :required belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id' belongs_to :push_rule @@ -171,6 +172,11 @@ class ApplicationSetting < ApplicationRecord validates :kroki_formats, json_schema: { filename: 'application_setting_kroki_formats' } + validates :metrics_method_call_threshold, + numericality: { greater_than_or_equal_to: 0 }, + presence: true, + if: :prometheus_metrics_enabled + validates :plantuml_url, presence: true, if: :plantuml_enabled @@ -393,6 +399,7 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :packages_cleanup_package_file_worker_capacity, + :package_registry_cleanup_policies_worker_capacity, allow_nil: false, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -589,6 +596,14 @@ class ApplicationSetting < ApplicationRecord presence: true, length: { maximum: 255 }, if: :sentry_enabled? + validates :error_tracking_enabled, + inclusion: { in: [true, false], message: _('must be a boolean value') } + validates :error_tracking_api_url, + presence: true, + addressable_url: true, + length: { maximum: 255 }, + if: :error_tracking_enabled? + validates :users_get_by_id_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :users_get_by_id_limit_allowlist, @@ -653,6 +668,7 @@ class ApplicationSetting < ApplicationRecord before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token + before_save :ensure_error_tracking_access_token after_commit do reset_memoized_terms diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index a89ea05fb62..e9a0a156121 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -217,6 +217,7 @@ module ApplicationSettingImplementation user_show_add_ssh_key_message: true, valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES, wiki_page_max_content_bytes: 50.megabytes, + package_registry_cleanup_policies_worker_capacity: 2, container_registry_delete_tags_service_timeout: 250, container_registry_expiration_policies_worker_capacity: 4, container_registry_cleanup_tags_service_max_list_size: 200, @@ -445,6 +446,10 @@ module ApplicationSettingImplementation ensure_health_check_access_token! end + def error_tracking_access_token + ensure_error_tracking_access_token! + end + def usage_ping_can_be_configured? Settings.gitlab.usage_ping_enabled end diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index 1e822629ba1..0ed197f32df 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -25,4 +25,9 @@ class AuthenticationEvent < ApplicationRecord def self.providers STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s) end + + def self.initial_login_or_known_ip_address?(user, ip_address) + !where(user_id: user).exists? || + where(user_id: user, ip_address: ip_address).success.exists? + end end diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb new file mode 100644 index 00000000000..a84a3454a27 --- /dev/null +++ b/app/models/awareness_session.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +# A Redis backed session store for real-time collaboration. A session is defined +# by its documents and the users that join this session. An online user can have +# two states within the session: "active" and "away". +# +# By design, session must eventually be cleaned up. If this doesn't happen +# explicitly, all keys used within the session model must have an expiry +# timestamp set. +class AwarenessSession # rubocop:disable Gitlab/NamespacedClass + # An awareness session expires automatically after 1 hour of no activity + SESSION_LIFETIME = 1.hour + private_constant :SESSION_LIFETIME + + # Expire user awareness keys after some time of inactivity + USER_LIFETIME = 1.hour + private_constant :USER_LIFETIME + + PRESENCE_LIFETIME = 10.minutes + private_constant :PRESENCE_LIFETIME + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + class << self + def for(value = nil) + # Creates a unique value for situations where we have no unique value to + # create a session with. This could be when creating a new issue, a new + # merge request, etc. + value = SecureRandom.uuid unless value.present? + + # We use SHA-256 based session identifiers (similar to abbreviated git + # hashes). There is always a chance for Hash collisions (birthday + # problem), we therefore have to pick a good tradeoff between the amount + # of data stored and the probability of a collision. + # + # The approximate probability for a collision can be calculated: + # + # p ~= n^2 / 2m + # ~= (2^18)^2 / (2 * 16^15) + # ~= 2^36 / 2^61 + # + # n is the number of awareness sessions and m the number of possibilities + # for each item. For a hex number, this is 16^c, where c is the number of + # characters. With 260k (~2^18) sessions, the probability for a collision + # is ~2^-25. + # + # The number of 15 is selected carefully. The integer representation fits + # nicely into a signed 64 bit integer and eventually allows Redis to + # optimize its memory usage. 16 chars would exceed the space for + # this datatype. + id = Digest::SHA256.hexdigest(value.to_s)[0, 15] + + AwarenessSession.new(id) + end + end + + def initialize(id) + @id = id + end + + def join(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.sadd(user_key, id_i) + pipeline.expire(user_key, USER_LIFETIME.to_i) + + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # We also mark for expiry when a session key is created (first user joins), + # because some users might never actively leave a session and the key could + # therefore become stale, w/o us noticing. + reset_session_expiry(pipeline) + end + end + + nil + end + + def leave(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.srem(user_key, id_i) + pipeline.zrem(users_key, user.id) + end + + # cleanup orphan sessions and users + # + # this needs to be a second pipeline due to the delete operations being + # dependent on the result of the cardinality checks + user_sessions_count, session_users_count = redis.pipelined do |pipeline| + pipeline.scard(user_key) + pipeline.zcard(users_key) + end + + redis.pipelined do |pipeline| + pipeline.del(user_key) unless user_sessions_count > 0 + + unless session_users_count > 0 + pipeline.del(users_key) + @id = nil + end + end + end + + nil + end + + def present?(user, threshold: PRESENCE_LIFETIME) + with_redis do |redis| + user_timestamp = redis.zscore(users_key, user.id) + break false unless user_timestamp.present? + + timestamp - user_timestamp < threshold + end + end + + def away?(user, threshold: PRESENCE_LIFETIME) + !present?(user, threshold: threshold) + end + + # Updates the last_activity timestamp for a user in this session + def touch!(user) + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # extend the session lifetime due to user activity + reset_session_expiry(pipeline) + end + end + + nil + end + + def size + with_redis do |redis| + redis.zcard(users_key) + end + end + + def to_param + id&.to_s + end + + def to_s + "awareness_session=#{id}" + end + + def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) + users_with_last_activity.filter do |_user, last_activity| + user_online?(last_activity, threshold: threshold) + end + end + + def users + User.where(id: user_ids) + end + + def users_with_last_activity + # where in (x, y, [...z]) is a set and does not maintain any order, we need + # to make sure to establish a stable order for both, the pairs returned from + # redis and the ActiveRecord query. Using IDs in ascending order. + user_ids, last_activities = user_ids_with_last_activity + .sort_by(&:first) + .transpose + + return [] if user_ids.blank? + + users = User.where(id: user_ids).order(id: :asc) + users.zip(last_activities) + end + + private + + attr_reader :id + + def user_online?(last_activity, threshold:) + last_activity.to_i + threshold.to_i > Time.zone.now.to_i + end + + # converts session id from hex to integer representation + def id_i + Integer(id, 16) if id.present? + end + + def users_key + "#{KEY_NAMESPACE}:session:#{id}:users" + end + + def user_sessions_key(user_id) + "#{KEY_NAMESPACE}:user:#{user_id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end + + def timestamp + Time.now.to_i + end + + def user_ids + with_redis do |redis| + redis.zrange(users_key, 0, -1) + end + end + + # Returns an array of tuples, where the first element in the tuple represents + # the user ID and the second part the last_activity timestamp. + def user_ids_with_last_activity + pairs = with_redis do |redis| + redis.zrange(users_key, 0, -1, with_scores: true) + end + + # map data type of score (float) to Time + pairs.map do |user_id, score| + [user_id, Time.zone.at(score.to_i)] + end + end + + # We want sessions to cleanup automatically after a certain period of + # inactivity. This sets the expiry timestamp for this session to + # [SESSION_LIFETIME]. + def reset_session_expiry(redis) + redis.expire(users_key, SESSION_LIFETIME) + + nil + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e35198ba31f..7f9697d0424 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -2,6 +2,7 @@ module Ci class Build < Ci::Processable + prepend Ci::BulkInsertableTags include Ci::Metadatable include Ci::Contextable include TokenAuthenticatable @@ -14,8 +15,6 @@ module Ci extend ::Gitlab::Utils::Override - BuildArchivedError = Class.new(StandardError) - belongs_to :project, inverse_of: :builds belongs_to :runner belongs_to :trigger_request @@ -30,10 +29,6 @@ module Ci return_exit_code: -> (build) { build.exit_codes_defined? } }.freeze - DEFAULT_RETRIES = { - scheduler_failure: 2 - }.freeze - DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD' RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute @@ -172,7 +167,6 @@ module Ci end scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) } - scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) } scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) } scope :last_month, -> { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } @@ -187,13 +181,6 @@ module Ci joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) end - scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } - - scope :preload_project_and_pipeline_project, -> do - preload(Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE, - pipeline: Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE) - end - scope :with_coverage, -> { where.not(coverage: nil) } scope :without_coverage, -> { where(coverage: nil) } scope :with_coverage_regex, -> { where.not(coverage_regex: nil) } @@ -207,7 +194,7 @@ module Ci after_save :stick_build_if_status_changed after_create unless: :importing? do |build| - run_after_commit { BuildHooksWorker.perform_async(build.id) } + run_after_commit { BuildHooksWorker.perform_async(build) } end class << self @@ -217,10 +204,6 @@ module Ci ActiveModel::Name.new(self, nil, 'job') end - def first_pending - pending.unstarted.order('created_at ASC').first - end - def with_preloads preload(:job_artifacts_archive, :job_artifacts, :tags, project: [:namespace]) end @@ -302,7 +285,7 @@ module Ci build.run_after_commit do BuildQueueWorker.perform_async(id) - BuildHooksWorker.perform_async(id) + BuildHooksWorker.perform_async(build) end end @@ -330,7 +313,7 @@ module Ci build.run_after_commit do build.ensure_persistent_ref - BuildHooksWorker.perform_async(id) + BuildHooksWorker.perform_async(build) end end @@ -338,11 +321,7 @@ module Ci build.run_after_commit do build.run_status_commit_hooks! - if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project) - Ci::BuildFinishedWorker.perform_async(id) - else - ::BuildFinishedWorker.perform_async(id) - end + Ci::BuildFinishedWorker.perform_async(id) end end @@ -446,10 +425,6 @@ module Ci true end - def save_tags - super unless Thread.current['ci_bulk_insert_tags'] - end - def archived? return true if degenerated? @@ -556,10 +531,6 @@ module Ci self.options.dig(:environment, :deployment_tier) if self.options end - def outdated_deployment? - success? && !deployment.try(:last?) - end - def triggered_by?(current_user) user == current_user end @@ -1162,6 +1133,14 @@ module Ci Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment? end + def track_verify_usage + Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id) if user_id.present? && count_user_verification? + end + + def count_user_verification? + has_environment? && environment_action == 'verify' + end + def each_report(report_types) job_artifacts_for_types(report_types).each do |report_artifact| report_artifact.each_blob do |blob| diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb index 2c08fc4c8bf..b674c1b1a0e 100644 --- a/app/models/ci/build_report_result.rb +++ b/app/models/ci/build_report_result.rb @@ -39,9 +39,5 @@ module Ci def suite_error tests.dig("suite_error") end - - def tests_total - [tests_success, tests_failed, tests_errored, tests_skipped].sum - end end end diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb index e5cb2026503..0105366d99b 100644 --- a/app/models/ci/group.rb +++ b/app/models/ci/group.rb @@ -50,8 +50,7 @@ module Ci def status_struct strong_memoize(:status_struct) do - Gitlab::Ci::Status::Composite - .new(@jobs, project: project) + Gitlab::Ci::Status::Composite.new(@jobs) end end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 0af5533613f..e11edbda6dc 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -19,5 +19,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } scope :for_groups, ->(group_ids) { where(group_id: group_ids) } + + def audit_details + key + end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 81943cfa651..ee7175a4f69 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -322,7 +322,7 @@ module Ci def expire_in=(value) self.expire_at = if value - ::Gitlab::Ci::Build::Artifacts::ExpireInParser.new(value).seconds_from_now + ::Gitlab::Ci::Build::DurationParser.new(value).seconds_from_now end end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb deleted file mode 100644 index ffd3d3fcd88..00000000000 --- a/app/models/ci/legacy_stage.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module Ci - # Currently this is artificial object, constructed dynamically - # We should migrate this object to actual database record in the future - class LegacyStage - include StaticModel - include Presentable - - attr_reader :pipeline, :name - - delegate :project, to: :pipeline - - def initialize(pipeline, name:, status: nil, warnings: nil) - @pipeline = pipeline - @name = name - @status = status - # support ints and booleans - @has_warnings = ActiveRecord::Type::Boolean.new.cast(warnings) - end - - def groups - @groups ||= Ci::Group.fabricate(project, self) - end - - def to_param - name - end - - def statuses_count - @statuses_count ||= statuses.count - end - - def status - @status ||= statuses.latest.composite_status(project: project) - end - - def detailed_status(current_user) - Gitlab::Ci::Status::Stage::Factory - .new(self, current_user) - .fabricate! - end - - def latest_statuses - statuses.ordered.latest - end - - def statuses - @statuses ||= pipeline.statuses.where(stage: name) - end - - def builds - @builds ||= pipeline.builds.where(stage: name) - end - - def success? - status.to_s == 'success' - end - - def has_warnings? - # lazilly calculate the warnings - if @has_warnings.nil? - @has_warnings = statuses.latest.failed_but_allowed.any? - end - - @has_warnings - end - - def manual_playable? - %[manual scheduled skipped].include?(status.to_s) - end - end -end diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index d900a056242..0fa6a234a3d 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -30,10 +30,6 @@ module Ci self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id) end - def maintain_denormalized_data? - ::Feature.enabled?(:ci_pending_builds_maintain_denormalized_data) - end - private def args_from_build(build) @@ -43,13 +39,13 @@ module Ci build: build, project: project, protected: build.protected?, - namespace: project.namespace + namespace: project.namespace, + tag_ids: build.tags_ids, + instance_runners_enabled: shared_runners_enabled?(project) } - if maintain_denormalized_data? - args.store(:tag_ids, build.tags_ids) - args.store(:instance_runners_enabled, shared_runners_enabled?(project)) - args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project) + if group_runners_enabled?(project) + args.store(:namespace_traversal_ids, project.namespace.traversal_ids) end args diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 5d316906bd3..78b55680b5e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -27,8 +27,6 @@ module Ci DEFAULT_CONFIG_PATH = CONFIG_EXTENSION CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze - BridgeStatusError = Class.new(StandardError) - paginates_per 15 sha_attribute :source_sha @@ -133,6 +131,7 @@ module Ci validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create after_create :keep_around_commits, unless: :importing? + after_find :observe_age_in_minutes, unless: :importing? use_fast_destroy :job_artifacts use_fast_destroy :build_trace_chunks @@ -241,6 +240,13 @@ module Ci pipeline.run_after_commit do unless pipeline.user&.blocked? + Gitlab::AppLogger.info( + message: "Enqueuing hooks for Pipeline #{pipeline.id}: #{pipeline.status}", + class: self.class.name, + pipeline_id: pipeline.id, + project_id: pipeline.project_id, + pipeline_status: pipeline.status) + PipelineHooksWorker.perform_async(pipeline.id) end @@ -332,8 +338,8 @@ module Ci scope :for_id, -> (id) { where(id: id) } scope :for_iid, -> (iid) { where(iid: iid) } scope :for_project, -> (project_id) { where(project_id: project_id) } - scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) } - scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) } + scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) } + scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) } scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } scope :with_pipeline_source, -> (source) { where(source: source) } @@ -490,40 +496,16 @@ module Ci .pluck(:stage, :stage_idx).map(&:first) end - def legacy_stage(name) - stage = Ci::LegacyStage.new(self, name: name) - stage unless stage.statuses_count == 0 - end - def ref_exists? project.repository.ref_exists?(git_ref) rescue Gitlab::Git::Repository::NoRepository false end - def legacy_stages_using_composite_status - stages = latest_statuses_ordered_by_stage.group_by(&:stage) - - stages.map do |stage_name, jobs| - composite_status = Gitlab::Ci::Status::Composite - .new(jobs) - - Ci::LegacyStage.new(self, - name: stage_name, - status: composite_status.status, - warnings: composite_status.warnings?) - end - end - def triggered_pipelines_with_preloads triggered_pipelines.preload(:source_job) end - # TODO: Remove usage of this method in templates - def legacy_stages - legacy_stages_using_composite_status - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -1004,6 +986,10 @@ module Ci object_hierarchy(project_condition: :same).base_and_descendants end + def self_and_descendants_complete? + self_and_descendants.all?(&:complete?) + end + # Follow the parent-child relationships and return the top-level parent def root_ancestor return self unless child? @@ -1078,7 +1064,11 @@ module Ci end def has_reports?(reports_scope) - complete? && latest_report_builds(reports_scope).exists? + if Feature.enabled?(:mr_show_reports_immediately, project, type: :development) + latest_report_builds(reports_scope).exists? + else + complete? && latest_report_builds(reports_scope).exists? + end end def has_coverage_reports? @@ -1100,7 +1090,7 @@ module Ci end def test_reports - Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| + Gitlab::Ci::Reports::TestReport.new.tap do |test_reports| latest_test_report_builds.find_each do |build| build.collect_test_reports!(test_reports) end @@ -1222,6 +1212,10 @@ module Ci Gitlab::Utils.slugify(source_ref.to_s) end + def stage(name) + stages.find_by(name: name) + end + def find_stage_by_name!(name) stages.find_by!(name: name) end @@ -1307,10 +1301,20 @@ module Ci end end - def has_expired_test_reports? - strong_memoize(:has_expired_test_reports) do - has_reports?(::Ci::JobArtifact.test_reports.expired) + def has_test_reports? + strong_memoize(:has_test_reports) do + has_reports?(::Ci::JobArtifact.test_reports) + end + end + + def age_in_minutes + return 0 unless persisted? + + unless has_attribute?(:created_at) + raise ArgumentError, 'pipeline not fully loaded' end + + (Time.current - created_at).ceil / 60 end private @@ -1363,6 +1367,21 @@ module Ci project.repository.keep_around(self.sha, self.before_sha) end + def observe_age_in_minutes + return unless age_metric_enabled? + return unless persisted? && has_attribute?(:created_at) + + ::Gitlab::Ci::Pipeline::Metrics + .pipeline_age_histogram + .observe({}, age_in_minutes) + end + + def age_metric_enabled? + ::Gitlab::SafeRequestStore.fetch(:age_metric_enabled) do + ::Feature.enabled?(:ci_pipeline_age_histogram, type: :ops) + end + end + # Without using `unscoped`, caller scope is also included into the query. # Using `unscoped` here will be redundant after Rails 6.1 def object_hierarchy(options = {}) diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 2284a05bcc9..cdc3d69f754 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -51,6 +51,23 @@ module Ci def find_by_file_type(file_type) find_by(file_type: file_type) end + + def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:) + transaction do + pipeline.pipeline_artifacts.find_by_file_type(file_type)&.destroy! + + pipeline.pipeline_artifacts.create!( + file_type: file_type, + project_id: pipeline.project_id, + size: size, + file: file, + file_format: REPORT_TYPES[file_type], + expire_at: EXPIRATION_DATE.from_now + ) + end + rescue ActiveRecord::ActiveRecordError => err + Gitlab::ErrorTracking.track_and_raise_exception(err, { pipeline_id: pipeline.id, file_type: file_type }) + end end def present diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 61194c9b7d1..f41ad890184 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -2,6 +2,7 @@ module Ci class Runner < Ci::ApplicationRecord + prepend Ci::BulkInsertableTags include Gitlab::SQL::Pattern include RedisCacheable include ChronicDurationAttribute @@ -14,6 +15,8 @@ module Ci include Presentable include EachBatch + ignore_column :semver, remove_with: '15.3', remove_after: '2022-07-22' + add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced? enum access_level: { @@ -75,9 +78,9 @@ module Ci has_many :groups, through: :runner_namespaces, disable_joins: true has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build' + has_one :runner_version, primary_key: :version, foreign_key: :version, class_name: 'Ci::RunnerVersion' before_save :ensure_token - before_save :update_semver, if: -> { version_changed? } scope :active, -> (value = true) { where(active: value) } scope :paused, -> { active(false) } @@ -430,7 +433,6 @@ module Ci values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {} values[:contacted_at] = Time.current values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown) - values[:semver] = semver_from_version(values[:version]) cache_attributes(values) @@ -451,16 +453,6 @@ module Ci read_attribute(:contacted_at) end - def semver_from_version(version) - parsed_runner_version = ::Gitlab::VersionInfo.parse(version) - - parsed_runner_version.valid? ? parsed_runner_version.to_s : nil - end - - def update_semver - self.semver = semver_from_version(self.version) - end - def namespace_ids strong_memoize(:namespace_ids) do runner_namespaces.pluck(:namespace_id).compact @@ -484,6 +476,10 @@ module Ci private + scope :with_upgrade_status, ->(upgrade_status) do + Ci::Runner.joins(:runner_version).where(runner_version: { status: upgrade_status }) + end + EXECUTOR_NAME_TO_TYPES = { 'unknown' => :unknown, 'custom' => :custom, diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb new file mode 100644 index 00000000000..6b2d0060c9b --- /dev/null +++ b/app/models/ci/runner_version.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Ci + class RunnerVersion < Ci::ApplicationRecord + include EachBatch + include EnumWithNil + + enum_with_nil status: { + not_processed: nil, + invalid_version: -1, + unknown: 0, + not_available: 1, + available: 2, + recommended: 3 + } + + STATUS_DESCRIPTIONS = { + invalid_version: 'Runner version is not valid.', + unknown: 'Upgrade status is unknown.', + not_available: 'Upgrade is not available for the runner.', + available: 'Upgrade is available for the runner.', + recommended: 'Upgrade is available and recommended for the runner.' + }.freeze + + # Override auto generated negative scope (from available) so the scope has expected behavior + scope :not_available, -> { where(status: :not_available) } + + # This scope returns all versions that might need recalculating. For instance, once a version is considered + # :recommended, it normally doesn't change status even if the instance is upgraded + scope :potentially_outdated, -> { where(status: [nil, :not_available, :available, :unknown]) } + + validates :version, length: { maximum: 2048 } + end +end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 8c4e97ac840..f03d1e96a4b 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -142,7 +142,7 @@ module Ci end def latest_stage_status - statuses.latest.composite_status(project: project) || 'skipped' + statuses.latest.composite_status || 'skipped' end end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 5bf5ae51ec8..c4db4754c52 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,6 +4,9 @@ module Ci class Trigger < Ci::ApplicationRecord include Presentable include Limitable + include IgnorableColumns + + ignore_column :ref, remove_with: '15.4', remove_after: '2022-08-22' self.limit_name = 'pipeline_triggers' self.limit_scope = :project diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 1e91f248fc4..c80c2ebe69a 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -18,5 +18,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } + + def audit_details + key + end end end diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index fb12ce7d292..3478bb69707 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -53,3 +53,5 @@ module Clusters end end end + +Clusters::Agent.prepend_mod_with('Clusters::Agent') diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb deleted file mode 100644 index 73c731aab1a..00000000000 --- a/app/models/clusters/applications/elastic_stack.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class ElasticStack < ApplicationRecord - include ::Clusters::Concerns::ElasticsearchClient - - VERSION = '3.0.0' - - self.table_name = 'clusters_applications_elastic_stacks' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - - default_value_for :version, VERSION - - after_destroy do - cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) - end - - state_machine :status do - after_transition any => [:installed] do |application| - application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: true, chart_version: application.version) - end - - after_transition any => [:uninstalled] do |application| - application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) - end - end - - def chart - 'elastic-stack/elastic-stack' - end - - def repository - 'https://charts.gitlab.io' - end - - def install_command - helm_command_module::InstallCommand.new( - name: 'elastic-stack', - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - repository: repository, - files: files, - preinstall: migrate_to_3_script, - postinstall: post_install_script - ) - end - - def uninstall_command - helm_command_module::DeleteCommand.new( - name: 'elastic-stack', - rbac: cluster.platform_kubernetes_rbac?, - files: files, - postdelete: post_delete_script - ) - end - - def files - super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh")) - end - - def chart_above_v2? - Gem::Version.new(version) >= Gem::Version.new('2.0.0') - end - - def chart_above_v3? - Gem::Version.new(version) >= Gem::Version.new('3.0.0') - end - - private - - def service_name - chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' - end - - def pvc_selector - chart_above_v3? ? "app=elastic-stack-elasticsearch-master" : "release=elastic-stack" - end - - def post_install_script - [ - "timeout 60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200" - ] - end - - def post_delete_script - [ - Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", pvc_selector, "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) - ] - end - - def migrate_to_3_script - return [] if !updating? || chart_above_v3? - - # Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack - # and is not compatible with pre-existing resources. We first remove them. - [ - helm_command_module::DeleteCommand.new( - name: 'elastic-stack', - rbac: cluster.platform_kubernetes_rbac?, - files: files - ).delete_command, - Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) - ] - end - end - end -end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 014f7530357..ad1e7dc305f 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -20,7 +20,6 @@ module Clusters Clusters::Applications::Runner.application_name => Clusters::Applications::Runner, Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, - Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack, Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium }.freeze DEFAULT_ENVIRONMENT = '*' @@ -51,7 +50,6 @@ module Clusters has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true has_one :integration_prometheus, class_name: 'Clusters::Integrations::Prometheus', inverse_of: :cluster - has_one :integration_elastic_stack, class_name: 'Clusters::Integrations::ElasticStack', inverse_of: :cluster def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName application = APPLICATIONS[name.to_s] @@ -66,7 +64,6 @@ module Clusters has_one_cluster_application :runner has_one_cluster_application :jupyter has_one_cluster_application :knative - has_one_cluster_application :elastic_stack has_one_cluster_application :cilium has_many :kubernetes_namespaces @@ -102,7 +99,6 @@ module Clusters delegate :available?, to: :application_helm, prefix: true, allow_nil: true delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true - delegate :available?, to: :integration_elastic_stack, prefix: true, allow_nil: true delegate :available?, to: :integration_prometheus, prefix: true, allow_nil: true delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true @@ -136,7 +132,6 @@ module Clusters scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) } scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) } - scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) } scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } scope :managed, -> { where(managed: true) } @@ -271,10 +266,6 @@ module Clusters integration_prometheus || build_integration_prometheus end - def find_or_build_integration_elastic_stack - integration_elastic_stack || build_integration_elastic_stack - end - def provider if gcp? provider_gcp @@ -309,18 +300,6 @@ module Clusters platform_kubernetes&.kubeclient if kubernetes? end - def elastic_stack_adapter - integration_elastic_stack - end - - def elasticsearch_client - elastic_stack_adapter&.elasticsearch_client - end - - def elastic_stack_available? - !!integration_elastic_stack_available? - end - def kubernetes_namespace_for(environment, deployable: environment.last_deployable) if deployable && environment.project_id != deployable.project_id raise ArgumentError, 'environment.project_id must match deployable.project_id' diff --git a/app/models/clusters/concerns/elasticsearch_client.rb b/app/models/clusters/concerns/elasticsearch_client.rb deleted file mode 100644 index e9aab7897a8..00000000000 --- a/app/models/clusters/concerns/elasticsearch_client.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Concerns - module ElasticsearchClient - include ::Gitlab::Utils::StrongMemoize - - ELASTICSEARCH_PORT = 9200 - ELASTICSEARCH_NAMESPACE = 'gitlab-managed-apps' - - def elasticsearch_client(timeout: nil) - strong_memoize(:elasticsearch_client) do - kube_client = cluster&.kubeclient&.core_client - next unless kube_client - - proxy_url = kube_client.proxy_url('service', service_name, ELASTICSEARCH_PORT, ELASTICSEARCH_NAMESPACE) - - Elasticsearch::Client.new(url: proxy_url, adapter: :net_http) do |faraday| - # ensures headers containing auth data are appended to original client options - faraday.headers.merge!(kube_client.headers) - # ensure TLS certs are properly verified - faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] - faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] - faraday.options.timeout = timeout unless timeout.nil? - end - - rescue Kubeclient::HttpError => error - # If users have mistakenly set parameters or removed the depended clusters, - # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. - # We check for a nil client in downstream use and behaviour is equivalent to an empty state - log_exception(error, :failed_to_create_elasticsearch_client) - - nil - end - end - end - end -end diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb deleted file mode 100644 index 97d73d252b9..00000000000 --- a/app/models/clusters/integrations/elastic_stack.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Integrations - class ElasticStack < ApplicationRecord - include ::Clusters::Concerns::ElasticsearchClient - include ::Clusters::Concerns::KubernetesLogger - - self.table_name = 'clusters_integration_elasticstack' - self.primary_key = :cluster_id - - belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id - - validates :cluster, presence: true - validates :enabled, inclusion: { in: [true, false] } - - scope :enabled, -> { where(enabled: true) } - - def available? - enabled - end - - def service_name - chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' - end - - def chart_above_v2? - return true if chart_version.nil? - - Gem::Version.new(chart_version) >= Gem::Version.new('2.0.0') - end - - def chart_above_v3? - return true if chart_version.nil? - - Gem::Version.new(chart_version) >= Gem::Version.new('3.0.0') - end - end - end -end diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb index 0d6177beae7..899529ff49f 100644 --- a/app/models/clusters/integrations/prometheus.rb +++ b/app/models/clusters/integrations/prometheus.rb @@ -55,23 +55,13 @@ module Clusters private def activate_project_integrations - if Feature.enabled?(:rename_integrations_workers) - ::Clusters::Applications::ActivateIntegrationWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - else - ::Clusters::Applications::ActivateServiceWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - end + ::Clusters::Applications::ActivateIntegrationWorker + .perform_async(cluster_id, ::Integrations::Prometheus.to_param) end def deactivate_project_integrations - if Feature.enabled?(:rename_integrations_workers) - ::Clusters::Applications::DeactivateIntegrationWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - else - ::Clusters::Applications::DeactivateServiceWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - end + ::Clusters::Applications::DeactivateIntegrationWorker + .perform_async(cluster_id, ::Integrations::Prometheus.to_param) end end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index ac9d8c39bd2..afe4927ee73 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -8,9 +8,12 @@ class CommitStatus < Ci::ApplicationRecord include EnumWithNil include BulkInsertableAssociations include TaggableQueries + include IgnorableColumns self.table_name = 'ci_builds' + ignore_column :token, remove_with: '15.4', remove_after: '2022-08-22' + belongs_to :user belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id @@ -220,10 +223,6 @@ class CommitStatus < Ci::ApplicationRecord false end - def self.bulk_insert_tags!(statuses) - Gitlab::Ci::Tags::BulkInsert.new(statuses).insert! - end - def locking_enabled? will_save_change_to_status? end @@ -325,5 +324,3 @@ class CommitStatus < Ci::ApplicationRecord script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? end end - -CommitStatus.prepend_mod_with('CommitStatus') diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb new file mode 100644 index 00000000000..da87d87e838 --- /dev/null +++ b/app/models/concerns/awareness.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Awareness + extend ActiveSupport::Concern + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + def join(session) + session.join(self) + + nil + end + + def leave(session) + session.leave(self) + + nil + end + + def session_ids + with_redis do |redis| + redis + .smembers(user_sessions_key) + # converts session ids from (internal) integer to hex presentation + .map { |key| key.to_i.to_s(16) } + end + end + + private + + def user_sessions_key + "#{KEY_NAMESPACE}:user:#{id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 99dbe464a7c..9ee0fd1db1d 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -172,7 +172,7 @@ module CacheMarkdownField refs = all_references(self.author) references = {} - references[:mentioned_users_ids] = refs.mentioned_user_ids.presence + references[:mentioned_users_ids] = mentioned_filtered_user_ids_for(refs) references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence @@ -185,6 +185,13 @@ module CacheMarkdownField true end + # Overriden on objects that needs to filter + # mentioned users that cannot read them, for example, + # guest users that are referenced on a confidential note. + def mentioned_filtered_user_ids_for(refs) + refs.mentioned_user_ids.presence + end + def mentionable_attributes_changed?(changes = saved_changes) return false unless is_a?(Mentionable) diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index 78340cf967b..fb4ea4206f4 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -30,6 +30,8 @@ module Ci raise NotSupportedAdapterError, 'This file format requires a dedicated adapter' end + ::Gitlab::ApplicationContext.push(artifact: file.model) + file.open do |stream| file_format_adapter_class.new(stream).each_blob(&blk) end diff --git a/app/models/concerns/ci/bulk_insertable_tags.rb b/app/models/concerns/ci/bulk_insertable_tags.rb new file mode 100644 index 00000000000..453b3b3fbc9 --- /dev/null +++ b/app/models/concerns/ci/bulk_insertable_tags.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Ci + module BulkInsertableTags + extend ActiveSupport::Concern + + BULK_INSERT_TAG_THREAD_KEY = 'ci_bulk_insert_tags' + + class << self + def with_bulk_insert_tags + previous = Thread.current[BULK_INSERT_TAG_THREAD_KEY] + Thread.current[BULK_INSERT_TAG_THREAD_KEY] = true + yield + ensure + Thread.current[BULK_INSERT_TAG_THREAD_KEY] = previous + end + end + + # overrides save_tags from acts-as-taggable + def save_tags + super unless Thread.current[BULK_INSERT_TAG_THREAD_KEY] + end + end +end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index cca66c3ec94..721cb14201f 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -23,11 +23,9 @@ module Ci UnknownStatusError = Class.new(StandardError) class_methods do - # The parameter `project` is only used for the feature flag check, and will be removed with - # https://gitlab.com/gitlab-org/gitlab/-/issues/321972 - def composite_status(project: nil) + def composite_status Gitlab::Ci::Status::Composite - .new(all, with_allow_failure: columns_hash.key?('allow_failure'), project: project) + .new(all, with_allow_failure: columns_hash.key?('allow_failure')) .status end diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 443e1ab53b4..dbc0887dc97 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -2,6 +2,7 @@ module EachBatch extend ActiveSupport::Concern + include LooseIndexScan class_methods do # Iterates over the rows in a relation in batches, similar to Rails' @@ -100,5 +101,65 @@ module EachBatch break unless stop end end + + # Iterates over the rows in a relation in batches by skipping duplicated values in the column. + # Example: counting the number of distinct authors in `issues` + # + # - Table size: 100_000 + # - Column: author_id + # - Distinct author_ids in the table: 1000 + # + # The query will read maximum 1000 rows if we have index coverage on user_id. + # + # > count = 0 + # > Issue.distinct_each_batch(column: 'author_id', of: 1000) { |r| count += r.count(:author_id) } + def distinct_each_batch(column:, order: :asc, of: 1000) + start = except(:select) + .select(column) + .reorder(column => order) + + start = start.take + + return unless start + + start_id = start[column] + arel_table = self.arel_table + arel_column = arel_table[column.to_s] + + 1.step do |index| + stop = loose_index_scan(column: column, order: order) do |cte_query, inner_query| + if order == :asc + [cte_query.where(arel_column.gteq(start_id)), inner_query] + else + [cte_query.where(arel_column.lteq(start_id)), inner_query] + end + end.offset(of).take + + if stop + stop_id = stop[column] + + relation = loose_index_scan(column: column, order: order) do |cte_query, inner_query| + if order == :asc + [cte_query.where(arel_column.gteq(start_id)), inner_query.where(arel_column.lt(stop_id))] + else + [cte_query.where(arel_column.lteq(start_id)), inner_query.where(arel_column.gt(stop_id))] + end + end + start_id = stop_id + else + relation = loose_index_scan(column: column, order: order) do |cte_query, inner_query| + if order == :asc + [cte_query.where(arel_column.gteq(start_id)), inner_query] + else + [cte_query.where(arel_column.lteq(start_id)), inner_query] + end + end + end + + unscoped { yield relation, index } + + break unless stop + end + end end end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 445277a7a7c..ecb120d8013 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -29,9 +29,12 @@ module Enums builds_disabled: 20, environment_creation_failure: 21, deployment_rejected: 22, + protected_environment_failure: 1_000, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, + upstream_bridge_project_not_found: 1_004, + insufficient_upstream_permissions: 1_005, bridge_pipeline_is_child_pipeline: 1_006, # not used anymore, but cannot be deleted because of old data downstream_pipeline_creation_failed: 1_007, secrets_provider_not_found: 1_008, @@ -42,5 +45,3 @@ module Enums end end end - -Enums::Ci::CommitStatus.prepend_mod_with('Enums::Ci::CommitStatus') diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb index b1def38d019..57f8e21c5a6 100644 --- a/app/models/concerns/integrations/has_issue_tracker_fields.rb +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -5,26 +5,32 @@ module Integrations extend ActiveSupport::Concern included do + self.field_storage = :data_fields + field :project_url, required: true, - storage: :data_fields, title: -> { _('Project URL') }, - help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') } + help: -> do + s_('IssueTracker|The URL to the project in the external issue tracker.') + end field :issues_url, required: true, - storage: :data_fields, title: -> { s_('IssueTracker|Issue URL') }, help: -> do - format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'), + ERB::Util.html_escape( + s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') + ) % { colon_id: '<code>:id</code>'.html_safe + } end field :new_issue_url, required: true, - storage: :data_fields, title: -> { s_('IssueTracker|New issue URL') }, - help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') } + help: -> do + s_('IssueTracker|The URL to create an issue in the external issue tracker.') + end end end end diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb index 3bdaa852ddf..142e62bb501 100644 --- a/app/models/concerns/integrations/slack_mattermost_notifier.rb +++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb @@ -34,7 +34,7 @@ module Integrations class HTTPClient def self.post(uri, params = {}) params.delete(:http_options) # these are internal to the client and we do not want them - Gitlab::HTTP.post(uri, body: params, use_read_total_timeout: true) + Gitlab::HTTP.post(uri, body: params) end end end diff --git a/app/models/concerns/loose_index_scan.rb b/app/models/concerns/loose_index_scan.rb new file mode 100644 index 00000000000..5d37a30171a --- /dev/null +++ b/app/models/concerns/loose_index_scan.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module LooseIndexScan + extend ActiveSupport::Concern + + class_methods do + # Builds a recursive query to read distinct values from a column. + # + # Example 1: collect all distinct author ids for the `issues` table + # + # Bad: The DB reads all issues, sorts and dedups them in memory + # + # > Issue.select(:author_id).distinct.map(&:author_id) + # + # Good: Use loose index scan (skip index scan) + # + # > Issue.loose_index_scan(column: :author_id).map(&:author_id) + # + # Example 2: List of users for the DONE todos selector. Select all users who created a todo. + # + # Bad: Loads all DONE todos for the given user and extracts the author_ids + # + # > User.where(id: Todo.where(user_id: 4156052).done.select(:author_id)) + # + # Good: Loads distinct author_ids from todos and then loads users + # + # > distinct_authors = Todo.where(user_id: 4156052).done.loose_index_scan(column: :author_id).select(:author_id) + # > User.where(id: distinct_authors) + def loose_index_scan(column:, order: :asc) + arel_table = self.arel_table + arel_column = arel_table[column.to_s] + + cte = Gitlab::SQL::RecursiveCTE.new(:loose_index_scan_cte, union_args: { remove_order: false }) + + cte_query = except(:select) + .select(column) + .order(column => order) + .limit(1) + + inner_query = except(:select) + + cte_query, inner_query = yield([cte_query, inner_query]) if block_given? + cte << cte_query + + inner_query = if order == :asc + inner_query.where(arel_column.gt(cte.table[column.to_s])) + else + inner_query.where(arel_column.lt(cte.table[column.to_s])) + end + + inner_query = inner_query.order(column => order) + .select(column) + .limit(1) + + cte << cte.table + .project(Arel::Nodes::Grouping.new(Arel.sql(inner_query.to_sql)).as(column.to_s)) + + unscoped do + select(column) + .with + .recursive(cte.to_arel) + .from(cte.alias_to(arel_table)) + .where(arel_column.not_eq(nil)) # filtering out the last NULL value + end + end + end +end diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index 12041b103f6..14c54d99ef3 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -16,7 +16,7 @@ module Milestoneable scope :any_milestone, -> { where.not(milestone_id: nil) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } - scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) } + scope :without_particular_milestones, ->(titles) { left_outer_joins(:milestone).where("milestones.title NOT IN (?) OR milestone_id IS NULL", titles) } scope :any_release, -> { joins_milestone_releases } scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) } diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb index 18ec996c3df..f2df7579a65 100644 --- a/app/models/concerns/notification_branch_selection.rb +++ b/app/models/concerns/notification_branch_selection.rb @@ -6,13 +6,15 @@ module NotificationBranchSelection extend ActiveSupport::Concern - def branch_choices - [ - [_('All branches'), 'all'].freeze, - [_('Default branch'), 'default'].freeze, - [_('Protected branches'), 'protected'].freeze, - [_('Default branch and protected branches'), 'default_and_protected'].freeze - ].freeze + class_methods do + def branch_choices + [ + [_('All branches'), 'all'].freeze, + [_('Default branch'), 'default'].freeze, + [_('Protected branches'), 'protected'].freeze, + [_('Default branch and protected branches'), 'default_and_protected'].freeze + ].freeze + end end def notify_for_branch?(data) diff --git a/app/models/concerns/packages/fips.rb b/app/models/concerns/packages/fips.rb new file mode 100644 index 00000000000..b8589cdc991 --- /dev/null +++ b/app/models/concerns/packages/fips.rb @@ -0,0 +1,11 @@ +# rubocop:disable Naming/FileName +# frozen_string_literal: true + +module Packages + module FIPS + extend ActiveSupport::Concern + + DisabledError = Class.new(StandardError) + end +end +# rubocop:enable Naming/FileName diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 20743ebcb52..f59b5d1ecc8 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -92,7 +92,13 @@ module Participable end def raw_participants(current_user = nil, verify_access: false) - ext = Gitlab::ReferenceExtractor.new(project, current_user) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + + # Used to extract references from confidential notes. + # Referenced users that cannot read confidential notes are + # later removed from participants array. + internal_notes_extractor = Gitlab::ReferenceExtractor.new(project, current_user) + participants = Set.new process = [self] @@ -107,6 +113,8 @@ module Participable source.class.participant_attrs.each do |attr| if attr.respond_to?(:call) + ext = use_internal_notes_extractor_for?(source) ? internal_notes_extractor : extractor + source.instance_exec(current_user, ext, &attr) else process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend @@ -121,7 +129,18 @@ module Participable end end - participants.merge(ext.users) + participants.merge(users_that_can_read_internal_notes(internal_notes_extractor)) + participants.merge(extractor.users) + end + + def use_internal_notes_extractor_for?(source) + source.is_a?(Note) && source.confidential? + end + + def users_that_can_read_internal_notes(extractor) + return [] unless self.is_a?(Noteable) && self.try(:resource_parent) + + Ability.users_that_can_read_internal_notes(extractor.users, self.resource_parent) end def source_visible_to_user?(source, user) diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb new file mode 100644 index 00000000000..cf6a31e6ebd --- /dev/null +++ b/app/models/concerns/require_email_verification.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# == Require Email Verification module +# +# Contains functionality to handle email verification +module RequireEmailVerification + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + # This value is twice the amount we want it to be, because due to a bug in the devise-two-factor + # gem every failed login attempt increments the value of failed_attempts by 2 instead of 1. + # See: https://github.com/tinfoil/devise-two-factor/issues/127 + MAXIMUM_ATTEMPTS = 3 * 2 + UNLOCK_IN = 24.hours + + included do + # Virtual attribute for the email verification token form + attr_accessor :verification_token + end + + # When overridden, do not send Devise unlock instructions when locking access. + def lock_access!(opts = {}) + return super unless override_devise_lockable? + + super({ send_instructions: false }) + end + + protected + + # We cannot override the class methods `maximum_attempts` and `unlock_in`, because we want to + # check for 2FA being enabled on the instance. So instead override the Devise Lockable methods + # where those values are used. + def attempts_exceeded? + return super unless override_devise_lockable? + + failed_attempts >= MAXIMUM_ATTEMPTS + end + + def lock_expired? + return super unless override_devise_lockable? + + locked_at && locked_at < UNLOCK_IN.ago + end + + private + + def override_devise_lockable? + strong_memoize(:override_devise_lockable) do + Feature.enabled?(:require_email_verification, self) && !two_factor_enabled? + end + end +end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index 7f96b3901f1..4cf36f83857 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -42,4 +42,41 @@ module VulnerabilityFindingHelpers ) end end + + def build_vulnerability_finding(security_finding) + report_finding = report_finding_for(security_finding) + return Vulnerabilities::Finding.new unless report_finding + + finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures, + :flags, :evidence) + identifiers = report_finding.identifiers.map do |identifier| + Vulnerabilities::Identifier.new(identifier.to_hash) + end + signatures = report_finding.signatures.map do |signature| + Vulnerabilities::FindingSignature.new(signature.to_hash) + end + evidence = Vulnerabilities::Finding::Evidence.new(data: report_finding.evidence.data) if report_finding.evidence + + Vulnerabilities::Finding.new(finding_data).tap do |finding| + finding.location_fingerprint = report_finding.location.fingerprint + finding.vulnerability = vulnerability_for(security_finding.uuid) + finding.project = project + finding.sha = pipeline.sha + finding.scanner = security_finding.scanner + finding.finding_evidence = evidence + + if calculate_false_positive? + finding.vulnerability_flags = report_finding.flags.map do |flag| + Vulnerabilities::Flag.new(flag) + end + end + + finding.identifiers = identifiers + finding.signatures = signatures + end + end + + def calculate_false_positive? + project.licensed_feature_available?(:sast_fp_reduction) + end end diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb index 47d21d21afd..d4075e1ff1b 100644 --- a/app/models/container_registry/event.rb +++ b/app/models/container_registry/event.rb @@ -6,6 +6,7 @@ module ContainerRegistry ALLOWED_ACTIONS = %w(push delete).freeze PUSH_ACTION = 'push' + DELETE_ACTION = 'delete' EVENT_TRACKING_CATEGORY = 'container_registry:notification' attr_reader :event @@ -41,6 +42,10 @@ module ContainerRegistry event['target'].has_key?('tag') end + def target_digest? + event['target'].has_key?('digest') + end + def target_repository? !target_tag? && event['target'].has_key?('repository') end @@ -53,6 +58,10 @@ module ContainerRegistry PUSH_ACTION == action end + def action_delete? + DELETE_ACTION == action + end + def container_repository_exists? return unless container_registry_path @@ -74,7 +83,7 @@ module ContainerRegistry def update_project_statistics return unless supported? - return unless target_tag? + return unless target_tag? || (action_delete? && target_digest?) return unless project Rails.cache.delete(project.root_ancestor.container_repositories_size_cache_key) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index c965d7cffe1..cdfd24e00aa 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -468,7 +468,7 @@ class ContainerRepository < ApplicationRecord def size strong_memoize(:size) do next unless Gitlab.com? - next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) + next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) && self.migration_state != 'import_done' next unless gitlab_api_client.supports_gitlab_api? gitlab_api_client.repository_details(self.path, sizing: :self)['size_bytes'] diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index ded6ab8687a..0f13c45b84d 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -57,7 +57,22 @@ class CustomerRelations::Contact < ApplicationRecord end def self.sort_by_name - order("last_name ASC, first_name ASC") + order(Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'last_name', + order_expression: arel_table[:last_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'first_name', + order_expression: arel_table[:first_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].asc + ) + ])) end def self.find_ids_by_emails(group, emails) diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 3c0f7d91a03..20d19ec9541 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -5,9 +5,6 @@ class DeployToken < ApplicationRecord include TokenAuthenticatable include PolicyActor include Gitlab::Utils::StrongMemoize - include IgnorableColumns - - ignore_column :token, remove_with: '15.2', remove_after: '2022-07-22' add_authentication_token_field :token, encrypted: :required diff --git a/app/models/deployment.rb b/app/models/deployment.rb index fc0dd7e00c7..c25ba6f9268 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -108,13 +108,9 @@ class Deployment < ApplicationRecord end end - after_transition any => :running do |deployment| + after_transition any => :running do |deployment, transition| deployment.run_after_commit do - if Feature.enabled?(:deployment_hooks_skip_worker, deployment.project) - deployment.execute_hooks(Time.current) - else - Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) - end + Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current) end end @@ -126,13 +122,9 @@ class Deployment < ApplicationRecord end end - after_transition any => FINISHED_STATUSES do |deployment| + after_transition any => FINISHED_STATUSES do |deployment, transition| deployment.run_after_commit do - if Feature.enabled?(:deployment_hooks_skip_worker, deployment.project) - deployment.execute_hooks(Time.current) - else - Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) - end + Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current) end end @@ -193,7 +185,7 @@ class Deployment < ApplicationRecord def self.last_deployment_group_for_environment(env) return self.none unless env.last_deployment_pipeline&.latest_successful_builds&.present? - BatchLoader.for(env).batch do |environments, loader| + BatchLoader.for(env).batch(default_value: self.none) do |environments, loader| latest_successful_build_ids = [] environments_hash = {} @@ -269,8 +261,8 @@ class Deployment < ApplicationRecord Commit.truncate_sha(sha) end - def execute_hooks(status_changed_at) - deployment_data = Gitlab::DataBuilder::Deployment.build(self, status_changed_at) + def execute_hooks(status, status_changed_at) + deployment_data = Gitlab::DataBuilder::Deployment.build(self, status, status_changed_at) project.execute_hooks(deployment_data, :deployment_hooks) project.execute_integrations(deployment_data, :deployment_hooks) end diff --git a/app/models/environment.rb b/app/models/environment.rb index da6ab5ed077..68540ce0f5c 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -451,13 +451,11 @@ class Environment < ApplicationRecord def auto_stop_in=(value) return unless value - return unless parsed_result = ChronicDuration.parse(value) - self.auto_stop_at = parsed_result.seconds.from_now - end + parser = ::Gitlab::Ci::Build::DurationParser.new(value) + return if parser.seconds_from_now.nil? - def elastic_stack_available? - !!deployment_platform&.cluster&.elastic_stack_available? + self.auto_stop_at = parser.seconds_from_now end def rollout_status diff --git a/app/models/error_tracking/client_key.rb b/app/models/error_tracking/client_key.rb index bbc57573aa9..d58a183f223 100644 --- a/app/models/error_tracking/client_key.rb +++ b/app/models/error_tracking/client_key.rb @@ -16,7 +16,7 @@ class ErrorTracking::ClientKey < ApplicationRecord end def sentry_dsn - @sentry_dsn ||= ErrorTracking::Collector::Dsn.build_url(public_key, project_id) + @sentry_dsn ||= ::Gitlab::ErrorTracking::ErrorRepository.build(project).dsn_url(public_key) end private diff --git a/app/models/group.rb b/app/models/group.rb index f5aad6e74ff..6d8f8bd7613 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -112,6 +112,8 @@ class Group < Namespace has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob' has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest' + has_one :harbor_integration, class_name: 'Integrations::Harbor' + # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -361,8 +363,8 @@ class Group < Namespace owners.include?(user) end - def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) - Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass + def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) + Members::Groups::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass self, users, access_level, @@ -373,8 +375,8 @@ class Group < Namespace ) end - def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true) - Members::Groups::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass + def add_member(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true) + Members::Groups::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass self, user, access_level, @@ -386,23 +388,23 @@ class Group < Namespace end def add_guest(user, current_user = nil) - add_user(user, :guest, current_user: current_user) + add_member(user, :guest, current_user: current_user) end def add_reporter(user, current_user = nil) - add_user(user, :reporter, current_user: current_user) + add_member(user, :reporter, current_user: current_user) end def add_developer(user, current_user = nil) - add_user(user, :developer, current_user: current_user) + add_member(user, :developer, current_user: current_user) end def add_maintainer(user, current_user = nil) - add_user(user, :maintainer, current_user: current_user) + add_member(user, :maintainer, current_user: current_user) end def add_owner(user, current_user = nil) - add_user(user, :owner, current_user: current_user) + add_member(user, :owner, current_user: current_user) end def member?(user, min_access_level = Gitlab::Access::GUEST) diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b7ace34141e..bcbf43ee38b 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -27,6 +27,8 @@ class ProjectHook < WebHook belongs_to :project validates :project, presence: true + scope :for_projects, ->(project) { where(project: project) } + def pluralized_name _('Webhooks') end @@ -41,6 +43,19 @@ class ProjectHook < WebHook project end + override :update_last_failure + def update_last_failure + return if executable? + + key = "web_hooks:last_failure:project-#{project_id}" + time = Time.current.utc.iso8601 + + Gitlab::Redis::SharedState.with do |redis| + prev = redis.get(key) + redis.set(key, time) if !prev || prev < time + end + end + private override :web_hooks_disable_failed? diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index c8a0cc05912..c0073f9a9b8 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -26,6 +26,6 @@ class SystemHook < WebHook end def help_path - 'system_hooks/system_hooks' + 'administration/system_hooks' end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 37fd612e652..f428d07cd7f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -3,6 +3,8 @@ class WebHook < ApplicationRecord include Sortable + InterpolationError = Class.new(StandardError) + MAX_FAILURES = 100 FAILURE_THRESHOLD = 3 # three strikes INITIAL_BACKOFF = 10.minutes @@ -36,6 +38,7 @@ class WebHook < ApplicationRecord validates :token, format: { without: /\n/ } validates :push_events_branch_filter, branch_filter: true validates :url_variables, json_schema: { filename: 'web_hooks_url_variables' } + validate :no_missing_url_variables after_initialize :initialize_url_variables @@ -45,6 +48,11 @@ class WebHook < ApplicationRecord where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) end + # Inverse of executable + scope :disabled, -> do + where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current) + end + def executable? !temporarily_disabled? && !permanently_disabled? end @@ -164,6 +172,24 @@ class WebHook < ApplicationRecord super(options) end + # See app/validators/json_schemas/web_hooks_url_variables.json + VARIABLE_REFERENCE_RE = /\{([A-Za-z_][A-Za-z0-9_]+)\}/.freeze + + def interpolated_url + return url unless url.include?('{') + + vars = url_variables + url.gsub(VARIABLE_REFERENCE_RE) do + vars.fetch(_1.delete_prefix('{').delete_suffix('}')) + end + rescue KeyError => e + raise InterpolationError, "Invalid URL template. Missing key #{e.key}" + end + + def update_last_failure + # Overridden in child classes. + end + private def web_hooks_disable_failed? @@ -177,4 +203,17 @@ class WebHook < ApplicationRecord def rate_limiter @rate_limiter ||= Gitlab::WebHooks::RateLimiter.new(self) end + + def no_missing_url_variables + return if url.nil? + + variable_names = url_variables.keys + used_variables = url.scan(VARIABLE_REFERENCE_RE).map(&:first) + + missing = used_variables - variable_names + + return if missing.empty? + + errors.add(:url, "Invalid URL template. Missing keys: #{missing}") + end end diff --git a/app/models/incident_management/issuable_escalation_status.rb b/app/models/incident_management/issuable_escalation_status.rb index fc881e62efd..3c581f0489a 100644 --- a/app/models/incident_management/issuable_escalation_status.rb +++ b/app/models/incident_management/issuable_escalation_status.rb @@ -7,7 +7,7 @@ module IncidentManagement self.table_name = 'incident_management_issuable_escalation_statuses' belongs_to :issue - has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_status + has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_statuses validates :issue, presence: true, uniqueness: true diff --git a/app/models/integration.rb b/app/models/integration.rb index 726e95b7cbf..f5f701662e7 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -13,8 +13,6 @@ class Integration < ApplicationRecord include IgnorableColumns extend ::Gitlab::Utils::Override - ignore_column :properties, remove_with: '15.1', remove_after: '2022-05-22' - UnknownType = Class.new(StandardError) self.inheritance_column = :type_new @@ -154,6 +152,8 @@ class Integration < ApplicationRecord else raise ArgumentError, "Unknown field storage: #{storage}" end + + boolean_accessor(name) if attrs[:type] == 'checkbox' end # :nocov: @@ -200,14 +200,21 @@ class Integration < ApplicationRecord # Provide convenient boolean accessor methods for each serialized property. # Also keep track of updated properties in a similar way as ActiveModel::Dirty def self.boolean_accessor(*args) - prop_accessor(*args) - args.each do |arg| + # TODO: Allow legacy usage of `.boolean_accessor`, once all integrations + # are converted to the field DSL we can remove this and only call + # `.boolean_accessor` through `.field`. + # + # See https://gitlab.com/groups/gitlab-org/-/epics/7652 + prop_accessor(arg) unless method_defined?(arg) + class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def #{arg} - return if properties.blank? + # Make the original getter available as a private method. + alias_method :#{arg}_before_type_cast, :#{arg} + private(:#{arg}_before_type_cast) - Gitlab::Utils.to_boolean(properties['#{arg}']) + def #{arg} + Gitlab::Utils.to_boolean(#{arg}_before_type_cast) end def #{arg}? @@ -494,16 +501,12 @@ class Integration < ApplicationRecord self.class.event_names end - def event_field(event) - nil - end - def api_field_names fields.reject { _1[:type] == 'password' }.pluck(:name) end - def global_fields - fields + def form_fields + fields.reject { _1[:api_only] == true } end def configurable_events @@ -574,11 +577,7 @@ class Integration < ApplicationRecord def async_execute(data) return unless supported_events.include?(data[:object_kind]) - if Feature.enabled?(:rename_integrations_workers) - Integrations::ExecuteWorker.perform_async(id, data) - else - ProjectServiceWorker.perform_async(id, data) - end + Integrations::ExecuteWorker.perform_async(id, data) end # override if needed diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index d25bf8b1b1e..2cfd71c9eb2 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -4,9 +4,22 @@ require 'asana' module Integrations class Asana < Integration - prop_accessor :api_key, :restrict_to_branch validates :api_key, presence: true, if: :activated? + field :api_key, + type: 'password', + title: 'API key', + help: -> { s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, + # Example Personal Access Token from Asana docs + placeholder: '0/68a9e79b868c6789e79a124c30b0', + required: true + + field :restrict_to_branch, + 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 'Asana' end @@ -24,28 +37,6 @@ module Integrations 'asana' end - def fields - [ - { - type: 'password', - name: 'api_key', - title: 'API key', - help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'), - # Example Personal Access Token from Asana docs - placeholder: '0/68a9e79b868c6789e79a124c30b0', - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - title: 'Restrict to branch (optional)', - help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb index ccd24c1fb2c..88dbf2915ef 100644 --- a/app/models/integrations/assembla.rb +++ b/app/models/integrations/assembla.rb @@ -2,9 +2,18 @@ module Integrations class Assembla < Integration - prop_accessor :token, :subdomain validates :token, presence: true, if: :activated? + field :token, + type: 'password', + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :subdomain, + placeholder: '' + def title 'Assembla' end @@ -17,24 +26,6 @@ module Integrations 'assembla' end - def fields - [ - { - type: 'password', - name: 'token', - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'subdomain', - placeholder: '' - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 4e30c1ccc69..230dc6bb336 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -155,7 +155,6 @@ module Integrations query_params[:os_authType] = 'basic' params[:basic_auth] = basic_auth - params[:use_read_total_timeout] = true params end diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 33d4eecbf49..c7992e4083c 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Base class for Chat notifications services +# Base class for Chat notifications integrations # This class is not meant to be used directly, but only to inherit from. module Integrations @@ -46,7 +46,7 @@ module Integrations # `notify_only_default_branch`. Now we have a string property named # `branches_to_be_notified`. Instead of doing a background migration, we # opted to set a value for the new property based on the old one, if - # users haven't specified one already. When users edit the service and + # users haven't specified one already. When users edit the integration and # select a value for this new property, it will override everything. self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" @@ -78,7 +78,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices }.freeze, { type: 'text', @@ -118,7 +118,7 @@ module Integrations event_type = data[:event_type] || object_kind - channel_names = get_channel_field(event_type).presence || channel.presence + channel_names = event_channel_value(event_type).presence || channel.presence channels = channel_names&.split(',')&.map(&:strip) opts = {} @@ -134,15 +134,13 @@ module Integrations end def event_channel_names - supported_events.map { |event| event_channel_name(event) } - end + return [] unless configurable_channels? - def event_field(event) - fields.find { |field| field[:name] == event_channel_name(event) } + supported_events.map { |event| event_channel_name(event) } end - def global_fields - fields.reject { |field| field[:name].end_with?('channel') } + def form_fields + super.reject { |field| field[:name].end_with?('channel') } end def default_channel_placeholder @@ -153,6 +151,21 @@ module Integrations 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? + false + end + + def event_channel_name(event) + EVENT_CHANNEL[event] + end + + def event_channel_value(event) + field_name = event_channel_name(event) + self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend + end + private def log_usage(_, _) @@ -213,21 +226,12 @@ module Integrations end end - def get_channel_field(event) - field_name = event_channel_name(event) - self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend - end - def build_event_channels - supported_events.reduce([]) do |channels, event| - channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel_placeholder } + event_channel_names.map do |channel_field| + { type: 'text', name: channel_field, placeholder: default_channel_placeholder } end end - def event_channel_name(event) - EVENT_CHANNEL[event] - end - def project_name project.full_name end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index bffe87c21ee..fe4a2f43b13 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -94,7 +94,7 @@ module Integrations result = false begin - response = Gitlab::HTTP.head(self.project_url, verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.head(self.project_url, verify: true) if response message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 7889cd8f9a9..bf1358ac0f6 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -2,9 +2,34 @@ module Integrations class Campfire < Integration - prop_accessor :token, :subdomain, :room validates :token, presence: true, if: :activated? + field :token, + type: 'password', + title: -> { _('Campfire token') }, + help: -> { s_('CampfireService|API authentication token from Campfire.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :subdomain, + title: -> { _('Campfire subdomain (optional)') }, + placeholder: '', + help: -> do + ERB::Util.html_escape( + s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') + ) % { + code_open: '<code>'.html_safe, + code_close: '</code>'.html_safe + } + end + + field :room, + title: -> { _('Campfire room ID (optional)') }, + placeholder: '123456', + help: -> { s_('CampfireService|From the end of the room URL.') } + def title 'Campfire' end @@ -15,42 +40,18 @@ module Integrations def help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer' - s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + + ERB::Util.html_escape( + s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}') + ) % { + docs_link: docs_link.html_safe + } end def self.to_param 'campfire' end - def fields - [ - { - type: 'password', - name: 'token', - title: _('Campfire token'), - help: s_('CampfireService|API authentication token from Campfire.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'subdomain', - title: _('Campfire subdomain (optional)'), - placeholder: '', - help: s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - }, - { - type: 'text', - name: 'room', - title: _('Campfire room ID (optional)'), - placeholder: '123456', - help: s_('CampfireService|From the end of the room URL.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index 4e1d1993d02..c1c43af99bf 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -6,11 +6,14 @@ module Integrations VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze - prop_accessor :confluence_url - validates :confluence_url, presence: true, if: :activated? validate :validate_confluence_url_is_cloud, if: :activated? + field :confluence_url, + title: -> { s_('Confluence Cloud Workspace URL') }, + placeholder: 'https://example.atlassian.net/wiki', + required: true + def self.to_param 'confluence' end @@ -38,18 +41,6 @@ module Integrations end end - def fields - [ - { - type: 'text', - name: 'confluence_url', - title: s_('Confluence Cloud Workspace URL'), - placeholder: 'https://example.atlassian.net/wiki', - required: true - } - ] - end - def testable? false end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index bb0fb6b9079..97e586c0662 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -15,7 +15,75 @@ module Integrations TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags + field :datadog_site, + placeholder: DEFAULT_DOMAIN, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe + } + end + + field :api_url, + title: -> { s_('DatadogIntegration|API URL') }, + help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } + + field :api_key, + type: 'password', + title: -> { _('API key') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, + linkClose: '</a>'.html_safe + } + end, + required: true + + field :archive_trace_events, + type: 'checkbox', + title: -> { s_('Logs') }, + checkbox_label: -> { s_('Enable logs collection') }, + help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } + + field :datadog_service, + title: -> { s_('DatadogIntegration|Service') }, + placeholder: 'gitlab-ci', + help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') } + + field :datadog_env, + title: -> { s_('DatadogIntegration|Environment') }, + placeholder: 'ci', + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + end + + field :datadog_tags, + type: 'textarea', + title: -> { s_('DatadogIntegration|Tags') }, + placeholder: "tag:value\nanother_tag:value", + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + end before_validation :strip_properties @@ -77,92 +145,11 @@ module Integrations end def fields - f = [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_DOMAIN, - help: ERB::Util.html_escape( - s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe - }, - required: false - }, - { - type: 'text', - name: 'api_url', - title: s_('DatadogIntegration|API URL'), - help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), - required: false - }, - { - type: 'password', - name: 'api_key', - title: _('API key'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: ERB::Util.html_escape( - s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') - ) % { - linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, - linkClose: '</a>'.html_safe - }, - required: true - } - ] - if Feature.enabled?(:datadog_integration_logs_collection, parent) - f.append({ - type: 'checkbox', - name: 'archive_trace_events', - title: s_('Logs'), - checkbox_label: s_('Enable logs collection'), - help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), - required: false - }) + super + else + super.reject { _1.name == 'archive_trace_events' } end - - f += [ - { - type: 'text', - name: 'datadog_service', - title: s_('DatadogIntegration|Service'), - placeholder: 'gitlab-ci', - help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') - }, - { - type: 'text', - name: 'datadog_env', - title: s_('DatadogIntegration|Environment'), - placeholder: 'ci', - help: ERB::Util.html_escape( - s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - }, - { - type: 'textarea', - name: 'datadog_tags', - title: s_('DatadogIntegration|Tags'), - placeholder: "tag:value\nanother_tag:value", - help: ERB::Util.html_escape( - s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - } - ] - - f end override :hook_url diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 790e41e5a2a..ecabf23c90b 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -23,10 +23,6 @@ module Integrations s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def event_field(event) - # No-op. - end - def default_channel_placeholder # No-op. end @@ -43,7 +39,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index 35524503dea..b1f72b7144e 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -60,8 +60,7 @@ module Integrations response = Gitlab::HTTP.try_get( commit_status_path(sha, ref), verify: enable_ssl_verification, - extra_log_info: { project_id: project_id }, - use_read_total_timeout: true + extra_log_info: { project_id: project_id } ) status = diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index ab458bb2c27..ed12a3a8d63 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -6,12 +6,35 @@ module Integrations RECIPIENTS_LIMIT = 750 - boolean_accessor :send_from_committer_email - boolean_accessor :disable_diffs - prop_accessor :recipients, :branches_to_be_notified validates :recipients, presence: true, if: :validate_recipients? validate :number_of_recipients_within_limit, if: :validate_recipients? + field :send_from_committer_email, + type: 'checkbox', + title: -> { s_("EmailsOnPushService|Send from committer") }, + help: -> do + @help ||= begin + domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") + + s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } + end + end + + field :disable_diffs, + type: 'checkbox', + title: -> { s_("EmailsOnPushService|Disable code diffs") }, + help: -> { s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") } + + field :branches_to_be_notified, + type: 'select', + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: branch_choices + + field :recipients, + type: 'textarea', + placeholder: -> { s_('EmailsOnPushService|tanuki@example.com gitlab@example.com') }, + help: -> { s_('EmailsOnPushService|Emails separated by whitespace.') } + def self.valid_recipients(recipients) recipients.split.grep(Devise.email_regexp).uniq(&:downcase) end @@ -67,28 +90,6 @@ module Integrations Gitlab::Utils.to_boolean(self.disable_diffs) end - def fields - domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") - [ - { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), - help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, - { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), - help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { - type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices - }, - { - type: 'textarea', - name: 'recipients', - placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'), - help: s_('EmailsOnPushService|Emails separated by whitespace.') - } - ] - end - private def number_of_recipients_within_limit diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index 18c48411e30..bc2ea193a84 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -2,9 +2,14 @@ module Integrations class ExternalWiki < Integration - prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? + field :external_wiki_url, + title: -> { s_('ExternalWikiService|External wiki URL') }, + placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') }, + help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') }, + required: true + def title s_('ExternalWikiService|External wiki') end @@ -17,19 +22,6 @@ module Integrations 'external_wiki' end - def fields - [ - { - type: 'text', - name: 'external_wiki_url', - title: s_('ExternalWikiService|External wiki URL'), - placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'), - help: 'Enter the URL to the external wiki.', - required: true - } - ] - end - def 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' @@ -37,7 +29,7 @@ module Integrations end def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb index cbda418755b..53c8f5f623e 100644 --- a/app/models/integrations/field.rb +++ b/app/models/integrations/field.rb @@ -4,14 +4,16 @@ module Integrations class Field SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze + BOOLEAN_ATTRIBUTES = %i[required api_only exposes_secrets].freeze + ATTRIBUTES = %i[ - section type placeholder required choices value checkbox_label + section type placeholder choices value checkbox_label title help non_empty_password_help non_empty_password_title - api_only - exposes_secrets - ].freeze + ].concat(BOOLEAN_ATTRIBUTES).freeze + + TYPES = %w[text textarea password checkbox select].freeze attr_reader :name, :integration_class @@ -22,6 +24,13 @@ module Integrations attributes[:type] = SECRET_NAME.match?(@name) ? 'password' : type attributes[:api_only] = api_only @attributes = attributes.freeze + + invalid_attributes = attributes.keys - ATTRIBUTES + if invalid_attributes.present? + raise ArgumentError, "Invalid attributes #{invalid_attributes.inspect}" + elsif !TYPES.include?(self[:type]) + raise ArgumentError, "Invalid type #{self[:type].inspect}" + end end def [](key) @@ -34,11 +43,19 @@ module Integrations end def secret? - @attributes[:type] == 'password' + self[:type] == 'password' end ATTRIBUTES.each do |name| define_method(name) { self[name] } end + + BOOLEAN_ATTRIBUTES.each do |name| + define_method("#{name}?") { !!self[name] } + end + + TYPES.each do |type| + define_method("#{type}?") { self[:type] == type } + end end end diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb index 703d8013bab..52efb29f2c1 100644 --- a/app/models/integrations/flowdock.rb +++ b/app/models/integrations/flowdock.rb @@ -2,9 +2,16 @@ module Integrations class Flowdock < Integration - prop_accessor :token validates :token, presence: true, if: :activated? + field :token, + type: 'password', + help: -> { s_('FlowdockService|Enter your Flowdock token.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '1b609b52537...', + required: true + def title 'Flowdock' end @@ -22,20 +29,6 @@ module Integrations 'flowdock' end - def fields - [ - { - type: 'password', - name: 'token', - help: s_('FlowdockService|Enter your Flowdock token.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '1b609b52537...', - required: true - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 8c68c9ff95a..df112ad6ca8 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -19,9 +19,6 @@ module Integrations s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def event_field(event) - end - def default_channel_placeholder end @@ -42,7 +39,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 44813795fc0..82981493822 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -4,7 +4,7 @@ module Integrations class Harbor < Integration prop_accessor :url, :project_name, :username, :password - validates :url, public_url: true, presence: true, if: :activated? + validates :url, public_url: true, presence: true, addressable_url: { allow_localhost: false, allow_local_network: false }, if: :activated? validates :project_name, presence: true, if: :activated? validates :username, presence: true, if: :activated? validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated? diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index 780f4bef0c9..3f3e321f45e 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -4,13 +4,55 @@ require 'uri' module Integrations class Irker < Integration - prop_accessor :server_host, :server_port, :default_irc_uri - prop_accessor :recipients, :channels - boolean_accessor :colorize_messages validates :recipients, presence: true, if: :validate_recipients? - before_validation :get_channels + field :server_host, + placeholder: 'localhost', + title: -> { s_('IrkerService|Server host (optional)') }, + help: -> { s_('IrkerService|irker daemon hostname (defaults to localhost).') } + + field :server_port, + placeholder: 6659, + title: -> { s_('IrkerService|Server port (optional)') }, + help: -> { s_('IrkerService|irker daemon port (defaults to 6659).') } + + field :default_irc_uri, + title: -> { s_('IrkerService|Default IRC URI (optional)') }, + help: -> { s_('IrkerService|URI to add before each recipient.') }, + placeholder: 'irc://irc.network.net:6697/' + + field :recipients, + type: 'textarea', + title: -> { s_('IrkerService|Recipients') }, + placeholder: 'irc[s]://irc.network.net[:port]/#channel', + required: true, + help: -> do + recipients_docs_link = ActionController::Base.helpers.link_to( + s_('IrkerService|How to enter channels or users?'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/integrations/irker', + anchor: 'enter-irker-recipients' + ), + target: '_blank', rel: 'noopener noreferrer' + ) + + ERB::Util.html_escape( + s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}') + ) % { + recipients_docs_link: recipients_docs_link.html_safe + } + end + + field :colorize_messages, + type: 'checkbox', + title: -> { _('Colorize messages') } + + # NOTE: This field is only used internally to store the parsed + # channels from the `recipients` field, it should not be exposed + # in the UI or API. + prop_accessor :channels + def title s_('IrkerService|irker (IRC gateway)') end @@ -30,17 +72,10 @@ module Integrations def execute(data) return unless supported_events.include?(data[:object_kind]) - if Feature.enabled?(:rename_integrations_workers) - Integrations::IrkerWorker.perform_async( - project_id, channels, - colorize_messages, data, settings - ) - else - ::IrkerWorker.perform_async( - project_id, channels, - colorize_messages, data, settings - ) - end + Integrations::IrkerWorker.perform_async( + project_id, channels, + colorize_messages, data, settings + ) end def settings @@ -50,34 +85,6 @@ module Integrations } end - def fields - recipients_docs_link = ActionController::Base.helpers.link_to( - s_('IrkerService|How to enter channels or users?'), - Rails.application.routes.url_helpers.help_page_url( - 'user/project/integrations/irker', - anchor: 'enter-irker-recipients' - ), - target: '_blank', rel: 'noopener noreferrer' - ) - - [ - { type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'), - help: s_('IrkerService|irker daemon hostname (defaults to localhost).') }, - { type: 'text', name: 'server_port', placeholder: 6659, title: s_('IrkerService|Server port (optional)'), - help: s_('IrkerService|irker daemon port (defaults to 6659).') }, - { type: 'text', name: 'default_irc_uri', title: s_('IrkerService|Default IRC URI (optional)'), - help: s_('IrkerService|URI to add before each recipient.'), - placeholder: 'irc://irc.network.net:6697/' }, - { type: 'textarea', name: 'recipients', title: s_('IrkerService|Recipients'), - placeholder: 'irc[s]://irc.network.net[:port]/#channel', required: true, - help: format( - s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}').html_safe, - recipients_docs_link: recipients_docs_link.html_safe - ) }, - { type: 'checkbox', name: 'colorize_messages', title: _('Colorize messages') } - ] - end - def help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 125f52104d4..c9c9b9d59d6 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -71,11 +71,12 @@ module Integrations non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') } + field :jira_issue_transition_id, api_only: true + # TODO: we can probably just delegate as part of # https://gitlab.com/gitlab-org/gitlab/issues/29404 # These fields are API only, so no field definition is required. data_field :jira_issue_transition_automatic - data_field :jira_issue_transition_id data_field :project_key data_field :issues_enabled data_field :vulnerabilities_enabled diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index d9ccbb7ea34..dae11b99bc5 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -3,6 +3,7 @@ module Integrations class Mattermost < BaseChatNotification include SlackMattermostNotifier + extend ::Gitlab::Utils::Override def title s_('Mattermost notifications') @@ -28,5 +29,10 @@ module Integrations def webhook_placeholder 'http://mattermost.example.com/hooks/' end + + override :configurable_channels? + def configurable_channels? + true + end end end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index 625ee0bc522..69863f164cd 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -22,9 +22,6 @@ module Integrations 'https://outlook.office.com/webhook/…' end - def event_field(event) - end - def default_channel_placeholder end @@ -47,7 +44,7 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb index 0b3a9bc5405..2d8e26d409f 100644 --- a/app/models/integrations/mock_ci.rb +++ b/app/models/integrations/mock_ci.rb @@ -49,7 +49,7 @@ module Integrations # # => 'running' # def commit_status(sha, ref) - response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification, use_read_total_timeout: true) + response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification) read_commit_status(response) rescue Errno::ECONNREFUSED :error diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index 758c9e4761b..05ee919892d 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -5,7 +5,25 @@ module Integrations include HasWebHook extend Gitlab::Utils::Override - prop_accessor :username, :token, :server + field :username, + title: -> { _('Username') }, + help: -> { s_('Enter your Packagist username.') }, + placeholder: '', + required: true + + field :token, + type: 'password', + title: -> { _('Token') }, + help: -> { s_('Enter your Packagist token.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :server, + title: -> { _('Server (optional)') }, + help: -> { s_('Enter your Packagist server. Defaults to https://packagist.org.') }, + placeholder: 'https://packagist.org' validates :username, presence: true, if: :activated? validates :token, presence: true, if: :activated? @@ -22,37 +40,6 @@ module Integrations 'packagist' end - def fields - [ - { - type: 'text', - name: 'username', - title: _('Username'), - help: s_('Enter your Packagist username.'), - placeholder: '', - required: true - }, - { - type: 'password', - name: 'token', - title: _('Token'), - help: s_('Enter your Packagist token.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'server', - title: _('Server (optional)'), - help: s_('Enter your Packagist server. Defaults to https://packagist.org.'), - placeholder: 'https://packagist.org', - required: false - } - ] - end - def self.supported_events %w(push merge_request tag_push) end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index f15482dc2e1..77cbba25f2c 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -6,11 +6,26 @@ module Integrations RECIPIENTS_LIMIT = 30 - prop_accessor :recipients, :branches_to_be_notified - boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :recipients, presence: true, if: :validate_recipients? validate :number_of_recipients_within_limit, if: :validate_recipients? + field :recipients, + type: 'textarea', + help: -> { _('Comma-separated list of email addresses.') }, + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox' + + field :notify_only_default_branch, + type: 'checkbox', + api_only: true + + field :branches_to_be_notified, + type: 'select', + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: branch_choices + def initialize_properties super @@ -65,21 +80,6 @@ module Integrations project&.ci_pipelines&.any? end - def fields - [ - { type: 'textarea', - name: 'recipients', - help: _('Comma-separated list of email addresses.'), - required: true }, - { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, - { type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices } - ] - end - def test(data) result = execute(data, force: true) diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index 931ccf46655..d32fb974339 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -4,9 +4,22 @@ module Integrations class Pivotaltracker < Integration API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' - prop_accessor :token, :restrict_to_branch validates :token, presence: true, if: :activated? + field :token, + type: 'password', + help: -> { s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true + + field :restrict_to_branch, + title: -> { s_('Integrations|Restrict to branch (optional)') }, + help: -> do + s_('PivotalTrackerService|Comma-separated list of branches to ' \ + 'automatically inspect. Leave blank to include all branches.') + end + def title 'Pivotal Tracker' end @@ -24,26 +37,6 @@ module Integrations 'pivotaltracker' end - def fields - [ - { - type: 'password', - name: 'token', - help: s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - title: 'Restrict to branch (optional)', - help: s_('PivotalTrackerService|Comma-separated list of branches to ' \ - 'automatically inspect. Leave blank to include all branches.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 36060565317..e672a985810 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -4,11 +4,30 @@ module Integrations class Prometheus < BaseMonitoring include PrometheusAdapter - # Access to prometheus is directly through the API - prop_accessor :api_url - prop_accessor :google_iap_service_account_json - prop_accessor :google_iap_audience_client_id - boolean_accessor :manual_configuration + field :manual_configuration, + type: 'checkbox', + title: -> { s_('PrometheusService|Active') }, + help: -> { s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.') }, + required: true + + field :api_url, + title: 'API URL', + placeholder: -> { s_('PrometheusService|https://prometheus.example.com/') }, + help: -> { s_('PrometheusService|The Prometheus API base URL.') }, + required: true + + field :google_iap_audience_client_id, + title: 'Google IAP Audience Client ID', + placeholder: -> { s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com') }, + help: -> { s_('PrometheusService|The ID of the IAP-secured resource.') }, + required: false + + field :google_iap_service_account_json, + type: 'textarea', + title: 'Google IAP Service Account JSON', + placeholder: -> { s_('PrometheusService|{ "type": "service_account", "project_id": ... }') }, + help: -> { s_('PrometheusService|The contents of the credentials.json file of your service account.') }, + required: false # We need to allow the self-monitoring project to connect to the internal # Prometheus instance. @@ -45,43 +64,6 @@ module Integrations 'prometheus' end - def fields - [ - { - type: 'checkbox', - name: 'manual_configuration', - title: s_('PrometheusService|Active'), - help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: 'API URL', - placeholder: s_('PrometheusService|https://prometheus.example.com/'), - help: s_('PrometheusService|The Prometheus API base URL.'), - required: true - }, - { - type: 'text', - name: 'google_iap_audience_client_id', - title: 'Google IAP Audience Client ID', - placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'), - help: s_('PrometheusService|The ID of the IAP-secured resource.'), - autocomplete: 'off', - required: false - }, - { - type: 'textarea', - name: 'google_iap_service_account_json', - title: 'Google IAP Service Account JSON', - placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'), - help: s_('PrometheusService|The contents of the credentials.json file of your service account.'), - required: false - } - ] - end - # Check we can connect to the Prometheus API def test(*args) return { success: false, result: 'Prometheus configuration error' } unless prometheus_client diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 7fd5efa8765..791e27c5db7 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -4,9 +4,73 @@ module Integrations class Pushover < Integration BASE_URI = 'https://api.pushover.net/1' - prop_accessor :api_key, :user_key, :device, :priority, :sound validates :api_key, :user_key, :priority, presence: true, if: :activated? + field :api_key, + type: 'password', + title: -> { _('API key') }, + help: -> { s_('PushoverService|Enter your application key.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, + placeholder: '', + required: true + + field :user_key, + type: 'password', + title: -> { _('User key') }, + help: -> { s_('PushoverService|Enter your user key.') }, + non_empty_password_title: -> { s_('PushoverService|Enter new user key') }, + non_empty_password_help: -> { s_('PushoverService|Leave blank to use your current user key.') }, + placeholder: '', + required: true + + field :device, + title: -> { _('Devices (optional)') }, + help: -> { s_('PushoverService|Leave blank for all active devices.') }, + placeholder: '' + + field :priority, + type: 'select', + required: true, + choices: -> do + [ + [s_('PushoverService|Lowest priority'), -2], + [s_('PushoverService|Low priority'), -1], + [s_('PushoverService|Normal priority'), 0], + [s_('PushoverService|High priority'), 1] + ] + end + + field :sound, + type: 'select', + choices: -> do + [ + ['Device default sound', nil], + ['Pushover (default)', 'pushover'], + %w(Bike bike), + %w(Bugle bugle), + ['Cash Register', 'cashregister'], + %w(Classical classical), + %w(Cosmic cosmic), + %w(Falling falling), + %w(Gamelan gamelan), + %w(Incoming incoming), + %w(Intermission intermission), + %w(Magic magic), + %w(Mechanical mechanical), + ['Piano Bar', 'pianobar'], + %w(Siren siren), + ['Space Alarm', 'spacealarm'], + ['Tug Boat', 'tugboat'], + ['Alien Alarm (long)', 'alien'], + ['Climb (long)', 'climb'], + ['Persistent (long)', 'persistent'], + ['Pushover Echo (long)', 'echo'], + ['Up Down (long)', 'updown'], + ['None (silent)', 'none'] + ] + end + def title 'Pushover' end @@ -19,81 +83,6 @@ module Integrations 'pushover' end - def fields - [ - { - type: 'password', - name: 'api_key', - title: _('API key'), - help: s_('PushoverService|Enter your application key.'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'), - placeholder: '', - required: true - }, - { - type: 'password', - name: 'user_key', - title: _('User key'), - help: s_('PushoverService|Enter your user key.'), - non_empty_password_title: s_('PushoverService|Enter new user key'), - non_empty_password_help: s_('PushoverService|Leave blank to use your current user key.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'device', - title: _('Devices (optional)'), - help: s_('PushoverService|Leave blank for all active devices.'), - placeholder: '' - }, - { - type: 'select', - name: 'priority', - required: true, - choices: - [ - [s_('PushoverService|Lowest priority'), -2], - [s_('PushoverService|Low priority'), -1], - [s_('PushoverService|Normal priority'), 0], - [s_('PushoverService|High priority'), 1] - ], - default_choice: 0 - }, - { - type: 'select', - name: 'sound', - choices: - [ - ['Device default sound', nil], - ['Pushover (default)', 'pushover'], - %w(Bike bike), - %w(Bugle bugle), - ['Cash Register', 'cashregister'], - %w(Classical classical), - %w(Cosmic cosmic), - %w(Falling falling), - %w(Gamelan gamelan), - %w(Incoming incoming), - %w(Intermission intermission), - %w(Magic magic), - %w(Mechanical mechanical), - ['Piano Bar', 'pianobar'], - %w(Siren siren), - ['Space Alarm', 'spacealarm'], - ['Tug Boat', 'tugboat'], - ['Alien Alarm (long)', 'alien'], - ['Climb (long)', 'climb'], - ['Persistent (long)', 'persistent'], - ['Pushover Echo (long)', 'echo'], - ['Up Down (long)', 'updown'], - ['None (silent)', 'none'] - ] - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index dd25a0bc558..8bc296e0320 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -2,9 +2,12 @@ module Integrations class Shimo < BaseThirdPartyWiki - prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? + field :external_wiki_url, + title: -> { s_('Shimo|Shimo Workspace URL') }, + required: true + def render? return false unless Feature.enabled?(:shimo_integration, project) @@ -25,21 +28,10 @@ module Integrations # support for `test` method def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil end - - def fields - [ - { - type: 'text', - name: 'external_wiki_url', - title: s_('Shimo|Shimo Workspace URL'), - required: true - } - ] - end end end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 0381db3a67e..93263229109 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -55,5 +55,10 @@ module Integrations Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) end + + override :configurable_channels? + def configurable_channels? + true + end end end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index a23aa5f783d..e0299c9ac5f 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -156,7 +156,7 @@ module Integrations end def get_path(path) - Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }, use_read_total_timeout: true) + Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) end def post_to_build_queue(data, branch) @@ -167,8 +167,7 @@ module Integrations '</build>', headers: { 'Content-type' => 'application/xml' }, verify: enable_ssl_verification, - basic_auth: basic_auth, - use_read_total_timeout: true + basic_auth: basic_auth ) end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index f085423d229..f10a75fac5d 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -19,9 +19,6 @@ module Integrations s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def event_field(event) - end - def default_channel_placeholder end @@ -38,7 +35,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end @@ -49,8 +46,7 @@ module Integrations response = Gitlab::HTTP.post(webhook, body: { subject: message.project_name, text: message.summary, - markdown: true, - use_read_total_timeout: true + markdown: true }.to_json) response if response.success? diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 345dd98cbc1..75be457dcf5 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -19,9 +19,6 @@ module Integrations s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } end - def event_field(event) - end - def default_channel_placeholder end @@ -38,7 +35,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end @@ -47,7 +44,7 @@ module Integrations def notify(message, opts) header = { 'Content-Type' => 'application/json' } - response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json, use_read_total_timeout: true) + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) response if response.success? end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index ab6e1da27f8..fa719f925ed 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -33,10 +33,7 @@ module Integrations end def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } - ] + super.select { _1.name.in?(%w[project_url issues_url]) } end end end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index c33df465fde..11db469f7ee 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -4,7 +4,28 @@ module Integrations class Zentao < Integration include Gitlab::Routing - data_field :url, :api_url, :api_token, :zentao_product_xid + self.field_storage = :data_fields + + field :url, + title: -> { s_('ZentaoIntegration|ZenTao Web URL') }, + placeholder: 'https://www.zentao.net', + help: -> { s_('ZentaoIntegration|Base URL of the ZenTao instance.') }, + required: true + + field :api_url, + title: -> { s_('ZentaoIntegration|ZenTao API URL (optional)') }, + help: -> { s_('ZentaoIntegration|If different from Web URL.') } + + field :api_token, + type: 'password', + title: -> { s_('ZentaoIntegration|ZenTao API token') }, + non_empty_password_title: -> { s_('ZentaoIntegration|Enter new ZenTao API token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true + + field :zentao_product_xid, + title: -> { s_('ZentaoIntegration|ZenTao Product ID') }, + required: true validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true @@ -47,39 +68,6 @@ module Integrations %w() end - def fields - [ - { - type: 'text', - name: 'url', - title: s_('ZentaoIntegration|ZenTao Web URL'), - placeholder: 'https://www.zentao.net', - help: s_('ZentaoIntegration|Base URL of the ZenTao instance.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: s_('ZentaoIntegration|ZenTao API URL (optional)'), - help: s_('ZentaoIntegration|If different from Web URL.') - }, - { - type: 'password', - name: 'api_token', - title: s_('ZentaoIntegration|ZenTao API token'), - non_empty_password_title: s_('ZentaoIntegration|Enter new ZenTao API token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - required: true - }, - { - type: 'text', - name: 'zentao_product_xid', - title: s_('ZentaoIntegration|ZenTao Product ID'), - required: true - } - ] - end - private def client diff --git a/app/models/issue.rb b/app/models/issue.rb index 47aa2b24feb..cae42115bef 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -46,7 +46,7 @@ class Issue < ApplicationRecord TYPES_FOR_LIST = %w(issue incident).freeze belongs_to :project - has_one :namespace, through: :project + belongs_to :namespace, inverse_of: :issues belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' @@ -98,6 +98,7 @@ class Issue < ApplicationRecord validates :project, presence: true validates :issue_type, presence: true + validates :namespace, presence: true, if: -> { project.present? } enum issue_type: WorkItems::Type.base_types @@ -123,8 +124,24 @@ class Issue < ApplicationRecord scope :order_due_date_desc, -> { reorder(arel_table[:due_date].desc.nulls_last) } scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } - scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } - scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') } + scope :order_severity_asc, -> do + build_keyset_order_on_joined_column( + scope: includes(:issuable_severity), + attribute_name: 'issuable_severities_severity', + column: IssuableSeverity.arel_table[:severity], + direction: :asc, + nullable: :nulls_first + ) + end + scope :order_severity_desc, -> do + build_keyset_order_on_joined_column( + scope: includes(:issuable_severity), + attribute_name: 'issuable_severities_severity', + column: IssuableSeverity.arel_table[:severity], + direction: :desc, + nullable: :nulls_last + ) + end scope :order_escalation_status_asc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].asc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_closed_at_asc, -> { reorder(arel_table[:closed_at].asc.nulls_last) } @@ -184,6 +201,8 @@ class Issue < ApplicationRecord scope :with_null_relative_position, -> { where(relative_position: nil) } scope :with_non_null_relative_position, -> { where.not(relative_position: nil) } + before_validation :ensure_namespace_id + after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? after_create_commit :record_create_action, unless: :importing? @@ -231,6 +250,31 @@ class Issue < ApplicationRecord alias_method :with_state, :with_state_id alias_method :with_states, :with_state_ids + def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:) + reversed_direction = direction == :asc ? :desc : :asc + + # rubocop: disable GitlabSecurity/PublicSend + order = ::Gitlab::Pagination::Keyset::Order.build([ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: attribute_name, + column_expression: column, + order_expression: column.send(direction).send(nullable), + reversed_order_expression: column.send(reversed_direction).send(nullable), + order_direction: direction, + distinct: false, + add_to_projections: true, + nullable: nullable + ), + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table['id'].desc + ) + ]) + # rubocop: enable GitlabSecurity/PublicSend + + order.apply_cursor_conditions(scope).order(order) + end + override :order_upvotes_desc def order_upvotes_desc reorder(upvotes_count: :desc) @@ -328,11 +372,11 @@ class Issue < ApplicationRecord when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc when 'due_date_desc' then order_due_date_desc.with_order_id_desc when 'relative_position', 'relative_position_asc' then order_by_relative_position - when 'severity_asc' then order_severity_asc.with_order_id_desc - when 'severity_desc' then order_severity_desc.with_order_id_desc - when 'escalation_status_asc' then order_escalation_status_asc.with_order_id_desc - when 'escalation_status_desc' then order_escalation_status_desc.with_order_id_desc - when 'closed_at_asc' then order_closed_at_asc + when 'severity_asc' then order_severity_asc + when 'severity_desc' then order_severity_desc + when 'escalation_status_asc' then order_escalation_status_asc + when 'escalation_status_desc' then order_escalation_status_desc + when 'closed_at', 'closed_at_asc' then order_closed_at_asc when 'closed_at_desc' then order_closed_at_desc else super @@ -405,14 +449,6 @@ class Issue < ApplicationRecord end end - # Returns boolean if a related branch exists for the current issue - # ignores merge requests branchs - def has_related_branch? - project.repository.branch_names.any? do |branch| - /\A#{iid}-(?!\d+-stable)/i =~ branch - end - end - # To allow polymorphism with MergeRequest. def source_project project @@ -656,6 +692,10 @@ class Issue < ApplicationRecord # Symptom of running out of space - schedule rebalancing Issues::RebalancingWorker.perform_async(nil, *project.self_or_root_group_ids) end + + def ensure_namespace_id + self.namespace = project.project_namespace if project + end end Issue.prepend_mod_with('Issue') diff --git a/app/models/key.rb b/app/models/key.rb index 5268ce2e040..9f6029cc5d4 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -28,7 +28,7 @@ class Key < ApplicationRecord validate :key_meets_restrictions validate :expiration, on: :create - validate :banned_key, if: :should_check_for_banned_key? + validate :banned_key, if: :key_changed? delegate :name, :email, to: :user, prefix: true @@ -121,6 +121,12 @@ class Key < ApplicationRecord @public_key ||= Gitlab::SSHPublicKey.new(key) end + def ensure_sha256_fingerprint! + return if self.fingerprint_sha256 + + save if generate_fingerprint + end + private def generate_fingerprint @@ -143,12 +149,6 @@ class Key < ApplicationRecord end end - def should_check_for_banned_key? - return false unless user - - key_changed? && Feature.enabled?(:ssh_banned_key, user) - end - def banned_key return unless public_key.banned? diff --git a/app/models/member.rb b/app/models/member.rb index bb5d2b10f8e..dcca63b5691 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -219,7 +219,23 @@ class Member < ApplicationRecord class << self def search(query) - joins(:user).merge(User.search(query, use_minimum_char_limit: false)) + scope = joins(:user).merge(User.search(query, use_minimum_char_limit: false)) + + return scope unless Gitlab::Pagination::Keyset::Order.keyset_aware?(scope) + + # If the User.search method returns keyset pagination aware AR scope then we + # need call apply_cursor_conditions which adds the ORDER BY columns from the scope + # to the SELECT clause. + # + # Why is this needed: + # When using keyset pagination, the next page is loaded using the ORDER BY + # values of the last record (cursor). This query selects `members.*` and + # orders by a custom SQL expression on `users` and `users.name`. The values + # will not be part of `members.*`. + # + # Result: `SELECT members.*, users.column1, users.column2 FROM members ...` + order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope) + order.apply_cursor_conditions(scope).reorder(order) end def search_invite_email(query) diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 791cb6f0dff..c97f00364fd 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -21,30 +21,30 @@ class ProjectMember < Member end class << self - # Add users to projects with passed access option + # Add members to projects with passed access option # # access can be an integer representing a access code # or symbol like :maintainer representing role # # Ex. - # add_users_to_projects( + # add_members_to_projects( # project_ids, # user_ids, # ProjectMember::MAINTAINER # ) # - # add_users_to_projects( + # add_members_to_projects( # project_ids, # user_ids, # :maintainer # ) # - def add_users_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil) + def add_members_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil) self.transaction do project_ids.each do |project_id| project = Project.find(project_id) - Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass + Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, @@ -111,7 +111,7 @@ class ProjectMember < Member # rubocop:disable CodeReuse/ServiceClass if blocking - AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute + AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]]) else AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 1a3464d05a2..ec97ab0ea42 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -174,6 +174,10 @@ class MergeRequest < ApplicationRecord merge_request.merge_jid = nil end + before_transition any => :closed do |merge_request| + merge_request.merge_error = nil + end + after_transition any => :opened do |merge_request| merge_request.run_after_commit do UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) @@ -1567,6 +1571,7 @@ class MergeRequest < ApplicationRecord variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path) variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url) variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s) variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present? variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 87afb7a489a..e08b2cc2a7d 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -21,6 +21,10 @@ class MergeRequestDiff < ApplicationRecord # from the database if this sentinel is seen FILES_COUNT_SENTINEL = 2**15 - 1 + # External diff cache key used by diffs export + EXTERNAL_DIFFS_CACHE_TMPDIR = 'project-%{project_id}-external-mr-%{mr_id}-diff-%{id}-cache' + EXTERNAL_DIFF_CACHE_CHUNK_SIZE = 8.megabytes + belongs_to :merge_request manual_inverse_association :merge_request, :merge_request_diff @@ -545,6 +549,28 @@ class MergeRequestDiff < ApplicationRecord merge_request_diff_files.reset end + # Yields locally cached external diff if it's externally stored. + # Used during Project Export to speed up externally + # stored merge request diffs export + def cached_external_diff + return yield(nil) unless stored_externally? + + cache_external_diff unless File.exist?(external_diff_cache_filepath) + + File.open(external_diff_cache_filepath) do |file| + yield(file) + end + end + + def remove_cached_external_diff + Gitlab::Utils.check_path_traversal!(external_diff_cache_dir) + Gitlab::Utils.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir]) + + return unless Dir.exist?(external_diff_cache_dir) + + FileUtils.rm_rf(external_diff_cache_dir) + end + private def convert_external_diffs_to_database @@ -791,6 +817,31 @@ class MergeRequestDiff < ApplicationRecord def sort_diffs(diffs) Gitlab::Diff::FileCollectionSorter.new(diffs).sort end + + # Downloads external diff to a temp storage location. + def cache_external_diff + return unless stored_externally? + return if File.exist?(external_diff_cache_filepath) + + Dir.mkdir(external_diff_cache_dir) unless Dir.exist?(external_diff_cache_dir) + + opening_external_diff do |external_diff| + File.open(external_diff_cache_filepath, 'wb') do |file| + file.write(external_diff.read(EXTERNAL_DIFF_CACHE_CHUNK_SIZE)) until external_diff.eof? + end + end + end + + def external_diff_cache_filepath + File.join(external_diff_cache_dir, "diff-#{id}") + end + + def external_diff_cache_dir + File.join( + Dir.tmpdir, + EXTERNAL_DIFFS_CACHE_TMPDIR % { project_id: project.id, mr_id: merge_request_id, id: id } + ) + end end MergeRequestDiff.prepend_mod_with('MergeRequestDiff') diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index f7648937c1d..36902e43a77 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -15,7 +15,12 @@ class MergeRequestDiffFile < ApplicationRecord end def utf8_diff - fetched_diff = diff + fetched_diff = if Feature.enabled?(:externally_stored_diffs_caching_export) && + merge_request_diff&.stored_externally? + diff_export + else + diff + end return '' if fetched_diff.blank? @@ -45,4 +50,40 @@ class MergeRequestDiffFile < ApplicationRecord content end end + + private + + # This method is meant to be used during Project Export. + # It is identical to the behaviour in #diff with the only + # difference of caching externally stored diffs on local disk in + # temp storage location in order to improve diff export performance. + def diff_export + content = merge_request_diff.cached_external_diff do |file| + file.seek(external_diff_offset) + + force_encode_utf8(file.read(external_diff_size)) + end + + # See #diff + if binary? + content = begin + content.unpack1('m0') + rescue ArgumentError + content + end + end + + content + rescue StandardError => e + log_payload = { + message: 'Cached external diff export failed', + merge_request_diff_file_id: id, + merge_request_diff_id: merge_request_diff&.id + } + + Gitlab::ExceptionLogFormatter.format!(e, log_payload) + Gitlab::AppLogger.warn(log_payload) + + diff + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5bb06cdbb4a..f23a859b119 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -74,6 +74,8 @@ class Namespace < ApplicationRecord has_many :sync_events, class_name: 'Namespaces::SyncEvent' has_one :cluster_enabled_grant, inverse_of: :namespace, class_name: 'Clusters::ClusterEnabledGrant' + has_many :work_items, inverse_of: :namespace + has_many :issues, inverse_of: :namespace validates :owner, presence: true, if: ->(n) { n.owner_required? } validates :name, @@ -341,6 +343,10 @@ class Namespace < ApplicationRecord end end + def emails_enabled? + !emails_disabled? + end + def lfs_enabled? # User namespace will always default to the global setting Gitlab.config.lfs.enabled @@ -450,9 +456,14 @@ class Namespace < ApplicationRecord end def pages_virtual_domain + cache = if Feature.enabled?(:cache_pages_domain_api, root_ancestor) + ::Gitlab::Pages::CacheControl.for_namespace(root_ancestor.id) + end + Pages::VirtualDomain.new( - all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment), - trim_prefix: full_path + projects: all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment), + trim_prefix: full_path, + cache: cache ) end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 504daf2662e..595e34821af 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -24,14 +24,27 @@ class NamespaceSetting < ApplicationRecord chronic_duration_attr :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval - NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, - :lock_delayed_project_removal, :resource_access_token_creation_allowed, - :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, - :setup_for_company, :jobs_to_be_done, :runner_token_expiration_interval, :enabled_git_access_protocol, - :subgroup_runner_token_expiration_interval, :project_runner_token_expiration_interval].freeze + NAMESPACE_SETTINGS_PARAMS = %i[ + default_branch_name + delayed_project_removal + lock_delayed_project_removal + resource_access_token_creation_allowed + prevent_sharing_groups_outside_hierarchy + new_user_signups_cap + setup_for_company + jobs_to_be_done + runner_token_expiration_interval + enabled_git_access_protocol + subgroup_runner_token_expiration_interval + project_runner_token_expiration_interval + ].freeze self.primary_key = :namespace_id + def self.allowed_namespace_settings_params + NAMESPACE_SETTINGS_PARAMS + end + sanitizes! :default_branch_name def prevent_sharing_groups_outside_hierarchy diff --git a/app/models/note.rb b/app/models/note.rb index 41e45a8759f..986a85acac6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -111,6 +111,7 @@ class Note < ApplicationRecord end validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?] + validate :validate_created_after # @deprecated attachments are handled by the Upload model. # @@ -665,6 +666,25 @@ class Note < ApplicationRecord ) end + def mentioned_users(current_user = nil) + users = super + + return users unless confidential? + + Ability.users_that_can_read_internal_notes(users, resource_parent) + end + + def mentioned_filtered_user_ids_for(references) + return super unless confidential? + + user_ids = references.mentioned_user_ids.presence + + return [] if user_ids.blank? + + users = User.where(id: user_ids) + Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id) + end + private def system_note_viewable_by?(user) @@ -729,6 +749,13 @@ class Note < ApplicationRecord errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT end + def validate_created_after + return unless created_at + return if created_at >= '1970-01-01' + + errors.add(:created_at, s_('Note|The created date provided is too far in the past.')) + end + def noteable_label_url_method for_merge_request? ? :project_merge_requests_url : :project_issues_url end diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 79a84231083..b3eaed154e2 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -125,6 +125,10 @@ class NotificationRecipient @project ? @project.emails_disabled? : @group&.emails_disabled? end + def emails_enabled? + !emails_disabled? + end + def read_ability return if @skip_read_ability return @read_ability if instance_variable_defined?(:@read_ability) diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 9789d8ed62b..20130f01d44 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -7,6 +7,8 @@ class OauthAccessToken < Doorkeeper::AccessToken alias_attribute :user, :resource_owner scope :distinct_resource_owner_counts, ->(applications) { where(application: applications).distinct.group(:application_id).count(:resource_owner_id) } + scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) } + scope :preload_application, -> { preload(:application) } def scopes=(value) if value.is_a?(Array) diff --git a/app/models/operations/feature_flags_client.rb b/app/models/operations/feature_flags_client.rb index 1c65c3f096e..e8c237abbc5 100644 --- a/app/models/operations/feature_flags_client.rb +++ b/app/models/operations/feature_flags_client.rb @@ -4,6 +4,8 @@ module Operations class FeatureFlagsClient < ApplicationRecord include TokenAuthenticatable + DEFAULT_UNLEASH_API_VERSION = 1 + self.table_name = 'operations_feature_flags_clients' belongs_to :project @@ -13,6 +15,8 @@ module Operations add_authentication_token_field :token, encrypted: :required + attr_accessor :unleash_app_name + before_validation :ensure_token! def self.find_for_project_and_token(project, token) @@ -21,5 +25,25 @@ module Operations where(project_id: project).find_by_token(token) end + + def self.update_last_feature_flag_updated_at!(project) + where(project: project).update_all(last_feature_flag_updated_at: Time.current) + end + + def unleash_api_version + DEFAULT_UNLEASH_API_VERSION + end + + def unleash_api_features + return [] unless unleash_app_name.present? + + Operations::FeatureFlag.for_unleash_client(project, unleash_app_name) + end + + def unleash_api_cache_key + "api_version:#{unleash_api_version}:" \ + "app_name:#{unleash_app_name}:" \ + "updated_at:#{last_feature_flag_updated_at.to_i}" + end end end diff --git a/app/models/packages/cleanup/policy.rb b/app/models/packages/cleanup/policy.rb index d7df90a4ce0..35f58f3680d 100644 --- a/app/models/packages/cleanup/policy.rb +++ b/app/models/packages/cleanup/policy.rb @@ -23,10 +23,25 @@ module Packages where.not(keep_n_duplicated_package_files: 'all') end + def self.with_packages + exists_select = ::Packages::Package.installable + .where('packages_packages.project_id = packages_cleanup_policies.project_id') + .select(1) + where('EXISTS (?)', exists_select) + end + + def self.runnable + runnable_schedules.with_packages.order(next_run_at: :asc) + end + def set_next_run_at # fixed cadence of 12 hours self.next_run_at = Time.zone.now + 12.hours end + + def keep_n_duplicated_package_files_disabled? + keep_n_duplicated_package_files == 'all' + end end end end diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb index eb66f4acfa9..b70b6c460d2 100644 --- a/app/models/packages/debian/file_entry.rb +++ b/app/models/packages/debian/file_entry.rb @@ -4,6 +4,7 @@ module Packages module Debian class FileEntry include ActiveModel::Model + include ::Packages::FIPS DIGESTS = %i[md5 sha1 sha256].freeze FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze @@ -31,6 +32,8 @@ module Packages private def valid_package_file_digests + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + DIGESTS.each do |digest| package_file_digest = package_file["file_#{digest}"] sum = public_send("#{digest}sum") # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 497f67993ae..119cc7fc166 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -2,8 +2,9 @@ module Pages class VirtualDomain - def initialize(projects, trim_prefix: nil, domain: nil) + def initialize(projects:, cache: nil, trim_prefix: nil, domain: nil) @projects = projects + @cache = cache @trim_prefix = trim_prefix @domain = domain end @@ -27,8 +28,12 @@ module Pages paths.sort_by(&:prefix).reverse end + def cache_key + @cache_key ||= cache&.cache_key + end + private - attr_reader :projects, :trim_prefix, :domain + attr_reader :projects, :trim_prefix, :domain, :cache end end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 93119bbff1f..9e93bff4acf 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -209,7 +209,15 @@ class PagesDomain < ApplicationRecord def pages_virtual_domain return unless pages_deployed? - Pages::VirtualDomain.new([project], domain: self) + cache = if Feature.enabled?(:cache_pages_domain_api, project.root_namespace) + ::Gitlab::Pages::CacheControl.for_project(project.id) + end + + Pages::VirtualDomain.new( + projects: [project], + domain: self, + cache: cache + ) end def clear_auto_ssl_failure diff --git a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb index b4ce61a869c..99a31a620c5 100644 --- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb +++ b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb @@ -12,6 +12,8 @@ module Preloaders def execute return unless @projects.present? && @users.present? + preload_users_namespace_bans(@users) + access_levels.each do |(project_id, user_id), access_level| project = projects_by_id[project_id] @@ -42,5 +44,11 @@ module Preloaders def projects_by_id @projects_by_id ||= @projects.index_by(&:id) end + + def preload_users_namespace_bans(_users) + # overridden in EE + end end end + +# Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod diff --git a/app/models/project.rb b/app/models/project.rb index dca47911d20..46e25564eab 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -247,7 +247,6 @@ class Project < ApplicationRecord has_many :export_jobs, class_name: 'ProjectExportJob' has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :project has_one :project_repository, inverse_of: :project - has_one :tracing_setting, class_name: 'ProjectTracingSetting' has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' @@ -261,6 +260,7 @@ class Project < ApplicationRecord has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues + has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus' has_many :labels, class_name: 'ProjectLabel' has_many :integrations has_many :events @@ -434,7 +434,6 @@ class Project < ApplicationRecord allow_destroy: true, reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } - accepts_nested_attributes_for :tracing_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :incident_management_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true @@ -442,33 +441,29 @@ class Project < ApplicationRecord accepts_nested_attributes_for :prometheus_integration, update_only: true accepts_nested_attributes_for :alerting_setting, update_only: true - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, - :merge_requests_enabled?, :forking_enabled?, :issues_enabled?, - :pages_enabled?, :analytics_enabled?, :snippets_enabled?, :public_pages?, :private_pages?, - :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_enabled?, :operations_access_level, :security_and_compliance_access_level, - :container_registry_access_level, :container_registry_enabled?, - to: :project_feature, allow_nil: true - alias_method :container_registry_enabled, :container_registry_enabled? - delegate :show_default_award_emojis, :show_default_award_emojis=, :show_default_award_emojis?, - :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=, :enforce_auth_checks_on_uploads?, - :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, :warn_about_potentially_unwanted_characters?, - to: :project_setting, allow_nil: true - delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, - prefix: :import, to: :import_state, allow_nil: true + 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, + to: :project_feature, allow_nil: true + + delegate :show_default_award_emojis, :show_default_award_emojis=, + :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=, + :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, + to: :project_setting, allow_nil: true + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting delegate :squash_option, :squash_option=, to: :project_setting delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting delegate :previous_default_branch, :previous_default_branch=, to: :project_setting - delegate :no_import?, to: :import_state, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true - delegate :add_user, :add_users, to: :team + delegate :add_member, :add_members, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role, to: :team delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true - delegate :root_ancestor, :certificate_based_clusters_enabled?, to: :namespace, allow_nil: true + delegate :root_ancestor, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true @@ -476,6 +471,7 @@ class Project < ApplicationRecord delegate :forward_deployment_enabled, :forward_deployment_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true + delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true @@ -483,7 +479,6 @@ class Project < ApplicationRecord delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?, to: :project_setting - delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true @@ -667,7 +662,6 @@ class Project < ApplicationRecord scope :created_by, -> (user) { where(creator: user) } scope :imported_from, -> (type) { where(import_type: type) } scope :imported, -> { where.not(import_type: nil) } - scope :with_tracing_enabled, -> { joins(:tracing_setting) } scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) } scope :with_service_desk_key, -> (key) do @@ -676,10 +670,12 @@ class Project < ApplicationRecord joins(:service_desk_setting).where('service_desk_settings.project_key' => key) end - scope :with_topic, ->(topic_name) do + scope :with_topic, ->(topic) { where(id: topic.project_topics.select(:project_id)) } + + scope :with_topic_by_name, ->(topic_name) do topic = Projects::Topic.find_by_name(topic_name) - topic ? where(id: topic.project_topics.select(:project_id)) : none + topic ? with_topic(topic) : none end enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -917,6 +913,14 @@ class Project < ApplicationRecord association(:namespace).loaded? end + def certificate_based_clusters_enabled? + !!namespace&.certificate_based_clusters_enabled? + end + + def prometheus_integration_active? + !!prometheus_integration&.active? + end + def personal_namespace_holder?(user) return false unless personal? return false unless user @@ -933,6 +937,42 @@ class Project < ApplicationRecord super.presence || build_project_setting end + def show_default_award_emojis? + !!project_setting&.show_default_award_emojis? + end + + def enforce_auth_checks_on_uploads? + !!project_setting&.enforce_auth_checks_on_uploads? + end + + def warn_about_potentially_unwanted_characters? + !!project_setting&.warn_about_potentially_unwanted_characters? + end + + def no_import? + !!import_state&.no_import? + end + + def import_scheduled? + !!import_state&.scheduled? + end + + def import_started? + !!import_state&.started? + end + + def import_in_progress? + !!import_state&.in_progress? + end + + def import_failed? + !!import_state&.failed? + end + + def import_finished? + !!import_state&.finished? + end + def all_pipelines if builds_enabled? super @@ -998,6 +1038,9 @@ class Project < ApplicationRecord end end + def emails_enabled? + !emails_disabled? + end override :lfs_enabled? def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -1840,6 +1883,59 @@ class Project < ApplicationRecord end end + def feature_available?(feature, user = nil) + !!project_feature&.feature_available?(feature, user) + end + + def builds_enabled? + !!project_feature&.builds_enabled? + end + + def wiki_enabled? + !!project_feature&.wiki_enabled? + end + + def merge_requests_enabled? + !!project_feature&.merge_requests_enabled? + end + + def forking_enabled? + !!project_feature&.forking_enabled? + end + + def issues_enabled? + !!project_feature&.issues_enabled? + end + + def pages_enabled? + !!project_feature&.pages_enabled? + end + + def analytics_enabled? + !!project_feature&.analytics_enabled? + end + + def snippets_enabled? + !!project_feature&.snippets_enabled? + end + + def public_pages? + !!project_feature&.public_pages? + end + + def private_pages? + !!project_feature&.private_pages? + end + + def operations_enabled? + !!project_feature&.operations_enabled? + end + + def container_registry_enabled? + !!project_feature&.container_registry_enabled? + end + alias_method :container_registry_enabled, :container_registry_enabled? + def enable_ci project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) end @@ -2762,10 +2858,6 @@ class Project < ApplicationRecord instance.token end - def tracing_external_url - tracing_setting&.external_url - end - override :git_garbage_collect_worker_klass def git_garbage_collect_worker_klass Projects::GitGarbageCollectWorker @@ -2907,6 +2999,10 @@ class Project < ApplicationRecord build_artifacts_size_refresh&.started? end + def group_group_links + group&.shared_with_group_links&.of_ancestors_and_self || GroupGroupLink.none + end + def security_training_available? licensed_feature_available?(:security_training) end diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb index c7fe3d7bc10..decc71ee193 100644 --- a/app/models/project_export_job.rb +++ b/app/models/project_export_job.rb @@ -2,6 +2,7 @@ class ProjectExportJob < ApplicationRecord belongs_to :project + has_many :relation_exports, class_name: 'Projects::ImportExport::RelationExport' validates :project, :jid, :status, presence: true diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index f478af32788..0a30e125c83 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -69,6 +69,11 @@ class ProjectFeature < ApplicationRecord default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false default_value_for :operations_access_level, value: ENABLED, allows_nil: false default_value_for :security_and_compliance_access_level, value: PRIVATE, allows_nil: false + default_value_for :monitor_access_level, value: ENABLED, allows_nil: false + default_value_for :infrastructure_access_level, value: ENABLED, allows_nil: false + default_value_for :feature_flags_access_level, value: ENABLED, allows_nil: false + default_value_for :environments_access_level, value: ENABLED, allows_nil: false + default_value_for :releases_access_level, value: ENABLED, allows_nil: false default_value_for(:pages_access_level, allows_nil: false) do |feature| if ::Gitlab::Pages.access_control_is_forced? diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index b1c1a5b6697..7711c6d604a 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -31,6 +31,10 @@ class ProjectImportState < ApplicationRecord transition started: :finished end + event :cancel do + transition [:none, :scheduled, :started] => :canceled + end + event :fail_op do transition [:scheduled, :started] => :failed end @@ -39,6 +43,7 @@ class ProjectImportState < ApplicationRecord state :started state :finished state :failed + state :canceled after_transition [:none, :finished, :failed] => :scheduled do |state, _| state.run_after_commit do @@ -51,7 +56,7 @@ class ProjectImportState < ApplicationRecord end end - after_transition any => :finished do |state, _| + after_transition any => [:canceled, :finished] do |state, _| if state.jid.present? Gitlab::SidekiqStatus.unset(state.jid) @@ -59,7 +64,7 @@ class ProjectImportState < ApplicationRecord end end - after_transition any => :failed do |state, _| + after_transition any => [:canceled, :failed] do |state, _| state.project.remove_import_data end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index e9fd7e4446c..59d2e3deb4f 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -5,6 +5,8 @@ class ProjectSetting < ApplicationRecord belongs_to :project, inverse_of: :project_setting + scope :for_projects, ->(projects) { where(project_id: projects) } + enum squash_option: { never: 0, always: 1, diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 97ab5aa2619..5641fbfb867 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -8,23 +8,23 @@ class ProjectTeam end def add_guest(user, current_user: nil) - add_user(user, :guest, current_user: current_user) + add_member(user, :guest, current_user: current_user) end def add_reporter(user, current_user: nil) - add_user(user, :reporter, current_user: current_user) + add_member(user, :reporter, current_user: current_user) end def add_developer(user, current_user: nil) - add_user(user, :developer, current_user: current_user) + add_member(user, :developer, current_user: current_user) end def add_maintainer(user, current_user: nil) - add_user(user, :maintainer, current_user: current_user) + add_member(user, :maintainer, current_user: current_user) end def add_owner(user, current_user: nil) - add_user(user, :owner, current_user: current_user) + add_member(user, :owner, current_user: current_user) end def add_role(user, role, current_user: nil) @@ -43,8 +43,8 @@ class ProjectTeam member end - def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) - Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass + def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) + Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, @@ -55,8 +55,8 @@ class ProjectTeam ) end - def add_user(user, access_level, current_user: nil, expires_at: nil) - Members::Projects::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass + def add_member(user, access_level, current_user: nil, expires_at: nil) + Members::Projects::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass project, user, access_level, diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb deleted file mode 100644 index 93fa80aed67..00000000000 --- a/app/models/project_tracing_setting.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class ProjectTracingSetting < ApplicationRecord - belongs_to :project - - validates :external_url, length: { maximum: 255 }, public_url: true - - before_validation :sanitize_external_url - - private - - def sanitize_external_url - self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url) - end -end diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb new file mode 100644 index 00000000000..0a31e525ac2 --- /dev/null +++ b/app/models/projects/import_export/relation_export.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class RelationExport < ApplicationRecord + self.table_name = 'project_relation_exports' + + belongs_to :project_export_job + + has_one :upload, + class_name: 'Projects::ImportExport::RelationExportUpload', + foreign_key: :project_relation_export_id, + inverse_of: :relation_export + + validates :export_error, length: { maximum: 300 } + validates :jid, length: { maximum: 255 } + validates :project_export_job, presence: true + validates :relation, presence: true, length: { maximum: 255 }, uniqueness: { scope: :project_export_job_id } + validates :status, numericality: { only_integer: true }, presence: true + end + end +end diff --git a/app/models/projects/import_export/relation_export_upload.rb b/app/models/projects/import_export/relation_export_upload.rb new file mode 100644 index 00000000000..965dc39d19f --- /dev/null +++ b/app/models/projects/import_export/relation_export_upload.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class RelationExportUpload < ApplicationRecord + include WithUploads + include ObjectStorage::BackgroundMove + + self.table_name = 'project_relation_export_uploads' + + belongs_to :relation_export, + class_name: 'Projects::ImportExport::RelationExport', + foreign_key: :project_relation_export_id, + inverse_of: :upload + + mount_uploader :export_file, ImportExportUploader + end + end +end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 77038d52efe..7cf15439b47 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -4,6 +4,8 @@ class ProtectedBranch < ApplicationRecord include ProtectedRef include Gitlab::SQL::Pattern + CACHE_EXPIRE_IN = 1.hour + scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } @@ -29,7 +31,7 @@ class ProtectedBranch < ApplicationRecord return true if project.empty_repo? && project.default_branch_protected? return false if ref_name.blank? - Rails.cache.fetch(protected_ref_cache_key(project, ref_name)) do + Rails.cache.fetch(protected_ref_cache_key(project, ref_name), expires_in: CACHE_EXPIRE_IN) do self.matching(ref_name, protected_refs: protected_refs(project)).present? end end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 7f41f0907d5..f8d500e106b 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -128,7 +128,7 @@ class RemoteMirror < ApplicationRecord def sync return unless sync? - if recently_scheduled? + if schedule_with_delay? RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.current) else RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.current) @@ -261,7 +261,8 @@ class RemoteMirror < ApplicationRecord super end - def recently_scheduled? + def schedule_with_delay? + return false if Feature.enabled?(:remote_mirror_no_delay, project, type: :ops) return false unless self.last_update_started_at self.last_update_started_at >= Time.current - backoff_delay diff --git a/app/models/repository.rb b/app/models/repository.rb index 0135020e586..0da71d87457 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1049,8 +1049,8 @@ class Repository blob_data_at(sha, '.lfsconfig') end - def changelog_config(ref = 'HEAD') - blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH) + def changelog_config(ref, path) + blob_data_at(ref, path) end def fetch_ref(source_repository, source_ref:, target_ref:) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index cf4b83d44c2..c813c5cb5b8 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -18,6 +18,7 @@ class Snippet < ApplicationRecord include CanMoveRepositoryStorage include AfterCommitQueue extend ::Gitlab::Utils::Override + include CreatedAtFilterable MAX_FILE_COUNT = 10 diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index ac7ba9530dd..daa64f4e087 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -12,7 +12,15 @@ class SshHostKey end def as_json(*) - { bits: bits, fingerprint: fingerprint, type: type, index: index } + { bits: bits, type: type, index: index }.merge(fingerprint_data) + end + + private + + def fingerprint_data + data = { fingerprint_sha256: fingerprint_sha256 } + data[:fingerprint] = fingerprint unless Gitlab::FIPS.enabled? + data end end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 4d17a4d332c..59f7d852ce6 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -3,6 +3,7 @@ module Terraform class State < ApplicationRecord include UsageStatistics + include AfterCommitQueue HEX_REGEXP = %r{\A\h+\z}.freeze UUID_LENGTH = 32 diff --git a/app/models/todo.rb b/app/models/todo.rb index 45ab770a0f6..cff7a93f72f 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -230,6 +230,10 @@ class Todo < ApplicationRecord target_type == AlertManagement::Alert.name end + def for_issue_or_work_item? + [Issue.name, WorkItem.name].any? { |klass_name| target_type == klass_name } + end + # override to return commits, which are not active record def target if for_commit? diff --git a/app/models/user.rb b/app/models/user.rb index 40096dfa411..12f434db631 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,7 @@ class User < ApplicationRecord include Gitlab::SQL::Pattern include AfterCommitQueue include Avatarable + include Awareness include Referable include Sortable include CaseSensitivity @@ -80,7 +81,7 @@ class User < ApplicationRecord serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize devise :lockable, :recoverable, :rememberable, :trackable, - :validatable, :omniauthable, :confirmable, :registerable + :validatable, :omniauthable, :confirmable, :registerable, :pbkdf2_encryptable include AdminChangedPasswordNotifier @@ -88,6 +89,7 @@ class User < ApplicationRecord # and should be added after Devise modules are initialized. include AsyncDeviseEmail include ForcedEmailConfirmation + include RequireEmailVerification MINIMUM_INACTIVE_DAYS = 90 MINIMUM_DAYS_CREATED = 7 @@ -220,6 +222,7 @@ class User < ApplicationRecord has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' + has_many :namespace_callouts, class_name: 'Users::NamespaceCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -476,8 +479,8 @@ class User < ApplicationRecord end scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) } scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } - scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last) } - scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first) } + scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } + scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) } scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } @@ -687,7 +690,33 @@ class User < ApplicationRecord scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query) scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit])) - scope.reorder(sanitized_order_sql, :name) + if Feature.enabled?(:use_keyset_aware_user_search_query) + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_match_priority', + order_expression: sanitized_order_sql.asc, + add_to_projections: true, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_name', + order_expression: arel_table[:name].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_id', + order_expression: arel_table[:id].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: true + ) + ]) + scope.reorder(order) + else + scope.reorder(sanitized_order_sql, :name) + end end # Limits the result set to users _not_ in the given query/list of IDs. @@ -894,21 +923,59 @@ class User < ApplicationRecord reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago end - # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638 - DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze + def authenticatable_salt + return encrypted_password[0, 29] unless Feature.enabled?(:pbkdf2_password_encryption) + return super if password_strategy == :pbkdf2_sha512 + + encrypted_password[0, 29] + end # Overwrites valid_password? from Devise::Models::DatabaseAuthenticatable # In constant-time, check both that the password isn't on a denylist AND # that the password is the user's password def valid_password?(password) + return false unless password_allowed?(password) + return super if Feature.enabled?(:pbkdf2_password_encryption) + + Devise::Encryptor.compare(self.class, encrypted_password, password) + rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash + validate_and_migrate_bcrypt_password(password) + rescue ::BCrypt::Errors::InvalidHash + false + end + + # This method should be removed once the :pbkdf2_password_encryption feature flag is removed. + def password=(new_password) + if Feature.enabled?(:pbkdf2_password_encryption) && Feature.enabled?(:pbkdf2_password_encryption_write, self) + super + else + # Copied from Devise DatabaseAuthenticatable. + @password = new_password + self.encrypted_password = Devise::Encryptor.digest(self.class, new_password) if new_password.present? + end + end + + def password_strategy + super + rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash + begin + return :bcrypt if BCrypt::Password.new(encrypted_password) + rescue BCrypt::Errors::InvalidHash + :unknown + end + end + + # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638 + DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze + + def password_allowed?(password) password_allowed = true + DISALLOWED_PASSWORDS.each do |disallowed_password| password_allowed = false if Devise.secure_compare(password, disallowed_password) end - original_result = super - - password_allowed && original_result + password_allowed end def remember_me! @@ -1570,6 +1637,10 @@ class User < ApplicationRecord self.followees.exists?(user.id) end + def followed_by?(user) + self.followers.include?(user) + end + def follow(user) return false if self.id == user.id @@ -1625,7 +1696,7 @@ class User < ApplicationRecord end def oauth_authorized_tokens - Doorkeeper::AccessToken.where(resource_owner_id: id, revoked_at: nil) + OauthAccessToken.where(resource_owner_id: id, revoked_at: nil) end # Returns the projects a user contributed to in the last year. @@ -1899,7 +1970,7 @@ class User < ApplicationRecord end # override, from Devise - def lock_access! + def lock_access!(opts = {}) Gitlab::AppLogger.info("Account Locked: username=#{username}") super end @@ -2015,6 +2086,13 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end + def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil) + source_feature_name = "#{feature_name}_#{namespace.id}" + callout = namespace_callouts_by_feature_name[source_feature_name] + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + # Load the current highest access by looking directly at the user's memberships def current_highest_access_level members.non_request.maximum(:access_level) @@ -2041,6 +2119,11 @@ class User < ApplicationRecord .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) end + def find_or_initialize_namespace_callout(feature_name, namespace_id) + namespace_callouts + .find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id) + end + def can_trigger_notifications? confirmed? && !blocked? && !ghost? end @@ -2158,6 +2241,10 @@ class User < ApplicationRecord @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) end + def namespace_callouts_by_feature_name + @namespace_callouts_by_feature_name ||= namespace_callouts.index_by(&:source_feature_name) + end + def authorized_groups_without_shared_membership Group.from_union([ groups.select(*Namespace.cached_column_list), @@ -2318,6 +2405,15 @@ class User < ApplicationRecord Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) end + + def validate_and_migrate_bcrypt_password(password) + return false unless Devise::Encryptor.compare(self.class, encrypted_password, password) + return true unless Feature.enabled?(:pbkdf2_password_encryption_write, self) + + update_attribute(:password, password) + rescue ::BCrypt::Errors::InvalidHash + false + end end User.prepend_mod_with('User') diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 0ecae4d148a..570e3ae9b3c 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -49,11 +49,14 @@ module Users storage_enforcement_banner_fourth_enforcement_threshold: 46, attention_requests_top_nav: 47, attention_requests_side_nav: 48, - minute_limit_banner: 49, + # 49 was removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91533 + # because the banner was no longer relevant. + # Records will be migrated with https://gitlab.com/gitlab-org/gitlab/-/issues/367293 preview_user_over_limit_free_plan_alert: 50, # EE-only user_reached_limit_free_plan_alert: 51, # EE-only submit_license_usage_data_banner: 52, # EE-only - personal_project_limitations_banner: 53 # EE-only + personal_project_limitations_banner: 53, # EE-only + mr_experience_survey: 54 } validates :feature_name, diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 373bc30889f..0ea7b8199aa 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -16,7 +16,8 @@ module Users storage_enforcement_banner_third_enforcement_threshold: 5, storage_enforcement_banner_fourth_enforcement_threshold: 6, preview_user_over_limit_free_plan_alert: 7, # EE-only - user_reached_limit_free_plan_alert: 8 # EE-only + user_reached_limit_free_plan_alert: 8, # EE-only + free_group_limited_alert: 9 # EE-only } validates :group, presence: true diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index 82c2e336a09..f220cfd17c5 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -41,7 +41,7 @@ module Users # Tracks we don't send emails for (e.g. unsuccessful experiment). These # are kept since we already have DB records that use the enum value. - INACTIVE_TRACK_NAMES = %w(invite_team).freeze + INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES) scope :for_user_with_track_and_series, -> (user, track, series) do diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb new file mode 100644 index 00000000000..a20a196a4ef --- /dev/null +++ b/app/models/users/namespace_callout.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + class NamespaceCallout < ApplicationRecord + include Users::Calloutable + + self.table_name = 'user_namespace_callouts' + + belongs_to :namespace + + enum feature_name: { + invite_members_banner: 1, + approaching_seat_count_threshold: 2, # EE-only + storage_enforcement_banner_first_enforcement_threshold: 3, + storage_enforcement_banner_second_enforcement_threshold: 4, + storage_enforcement_banner_third_enforcement_threshold: 5, + storage_enforcement_banner_fourth_enforcement_threshold: 6, + preview_user_over_limit_free_plan_alert: 7, # EE-only + user_reached_limit_free_plan_alert: 8, # EE-only + web_hook_disabled: 9 + } + + validates :namespace, presence: true + validates :feature_name, + presence: true, + uniqueness: { scope: [:user_id, :namespace_id] }, + inclusion: { in: NamespaceCallout.feature_names.keys } + + def source_feature_name + "#{feature_name}_#{namespace_id}" + end + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 647b4e787c6..63c60f5a89e 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -316,6 +316,7 @@ class WikiPage end def update_front_matter(attrs) + return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container) return unless attrs.has_key?(:front_matter) fm_yaml = serialize_front_matter(attrs[:front_matter]) @@ -326,7 +327,7 @@ class WikiPage def parsed_content strong_memoize(:parsed_content) do - Gitlab::WikiPages::FrontMatterParser.new(raw_content).parse + Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index bdd9aae90a4..d29df0c31fc 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true class WorkItem < Issue + include Gitlab::Utils::StrongMemoize + self.table_name = 'issues' self.inheritance_column = :_type_disabled + belongs_to :namespace, inverse_of: :work_items has_one :parent_link, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_id has_one :work_item_parent, through: :parent_link, class_name: 'WorkItem' @@ -22,8 +25,10 @@ class WorkItem < Issue end def widgets - work_item_type.widgets.map do |widget_class| - widget_class.new(self) + strong_memoize(:widgets) do + work_item_type.widgets.map do |widget_class| + widget_class.new(self) + end end end diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index 3c405dbce3b..f5ebbfa59b8 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -5,11 +5,13 @@ module WorkItems self.table_name = 'work_item_parent_links' MAX_CHILDREN = 100 + PARENT_TYPES = [:issue, :incident].freeze belongs_to :work_item belongs_to :work_item_parent, class_name: 'WorkItem' - validates :work_item, :work_item_parent, presence: true + validates :work_item_parent, presence: true + validates :work_item, presence: true, uniqueness: true validate :validate_child_type validate :validate_parent_type validate :validate_same_project @@ -21,15 +23,20 @@ module WorkItems return unless work_item unless work_item.task? - errors.add :work_item, _('Only Task can be assigned as a child in hierarchy.') + errors.add :work_item, _('only Task can be assigned as a child in hierarchy.') end end def validate_parent_type return unless work_item_parent - unless work_item_parent.issue? - errors.add :work_item_parent, _('Only Issue can be parent of Task.') + base_type = work_item_parent.work_item_type.base_type.to_sym + unless PARENT_TYPES.include?(base_type) + parent_names = WorkItems::Type::BASE_TYPES.slice(*WorkItems::ParentLink::PARENT_TYPES) + .values.map { |type| type[:name] } + + errors.add :work_item_parent, _('only %{parent_types} can be parent of Task.') % + { parent_types: parent_names.to_sentence } end end @@ -37,7 +44,7 @@ module WorkItems return if work_item.nil? || work_item_parent.nil? if work_item.resource_parent != work_item_parent.resource_parent - errors.add :work_item_parent, _('Parent must be in the same project as child.') + errors.add :work_item_parent, _('parent must be in the same project as child.') end end @@ -46,7 +53,7 @@ module WorkItems max = persisted? ? MAX_CHILDREN : MAX_CHILDREN - 1 if work_item_parent.child_links.count > max - errors.add :work_item_parent, _('Parent already has maximum number of children.') + errors.add :work_item_parent, _('parent already has maximum number of children.') end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index bf251a3ade5..e38d0ae153a 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -21,11 +21,11 @@ module WorkItems }.freeze WIDGETS_FOR_TYPE = { - issue: [Widgets::Description, Widgets::Hierarchy], - incident: [Widgets::Description], + issue: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight], + incident: [Widgets::Description, Widgets::Hierarchy], test_case: [Widgets::Description], requirement: [Widgets::Description], - task: [Widgets::Description, Widgets::Hierarchy] + task: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight] }.freeze cache_markdown_field :description, pipeline: :single_line diff --git a/app/models/work_items/widgets/assignees.rb b/app/models/work_items/widgets/assignees.rb new file mode 100644 index 00000000000..ecbbee1bcfb --- /dev/null +++ b/app/models/work_items/widgets/assignees.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Assignees < Base + delegate :assignees, to: :work_item + delegate :allows_multiple_assignees?, to: :work_item + end + end +end diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb index 35b6d295321..1e84d172bef 100644 --- a/app/models/work_items/widgets/description.rb +++ b/app/models/work_items/widgets/description.rb @@ -4,10 +4,6 @@ module WorkItems module Widgets class Description < Base delegate :description, to: :work_item - - def update(params:) - work_item.description = params[:description] if params&.key?(:description) - end end end end diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb index dadd341de83..930aced8ace 100644 --- a/app/models/work_items/widgets/hierarchy.rb +++ b/app/models/work_items/widgets/hierarchy.rb @@ -4,13 +4,13 @@ module WorkItems module Widgets class Hierarchy < Base def parent - return unless Feature.enabled?(:work_items_hierarchy, work_item.project) + return unless work_item.project.work_items_feature_flag_enabled? work_item.work_item_parent end def children - return WorkItem.none unless Feature.enabled?(:work_items_hierarchy, work_item.project) + return WorkItem.none unless work_item.project.work_items_feature_flag_enabled? work_item.work_item_children end diff --git a/app/models/work_items/widgets/weight.rb b/app/models/work_items/widgets/weight.rb new file mode 100644 index 00000000000..f589378f307 --- /dev/null +++ b/app/models/work_items/widgets/weight.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Weight < Base + delegate :weight, to: :work_item + end + end +end diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb index 2c1d0110b7c..7c2581b8bb2 100644 --- a/app/models/x509_certificate.rb +++ b/app/models/x509_certificate.rb @@ -16,7 +16,7 @@ class X509Certificate < ApplicationRecord has_many :x509_commit_signatures, class_name: 'CommitSignatures::X509CommitSignature', inverse_of: 'x509_certificate' # rfc 5280 - 4.2.1.2 Subject Key Identifier - validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } + validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex } # rfc 5280 - 4.1.2.6 Subject validates :subject, presence: true # rfc 5280 - 4.1.2.6 Subject (subjectAltName contains the email address) diff --git a/app/models/x509_issuer.rb b/app/models/x509_issuer.rb index 4b75e38bbde..81491d8e507 100644 --- a/app/models/x509_issuer.rb +++ b/app/models/x509_issuer.rb @@ -4,7 +4,7 @@ class X509Issuer < ApplicationRecord has_many :x509_certificates, inverse_of: 'x509_issuer' # rfc 5280 - 4.2.1.1 Authority Key Identifier - validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } + validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex } # rfc 5280 - 4.1.2.4 Issuer validates :subject, presence: true # rfc 5280 - 4.2.1.13 CRL Distribution Points diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index fa7b117f3cd..406144b7a5c 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -120,6 +120,8 @@ class GlobalPolicy < BasePolicy # We can't use `read_statistics` because the user may have different permissions for different projects rule { admin }.enable :use_project_statistics_filters + rule { admin }.enable :delete_runners + rule { external_user }.prevent :create_snippet end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 6ca30ba5dab..50b6f4bbe15 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -154,6 +154,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy rule { reporter }.policy do enable :reporter_access enable :read_container_image + enable :read_harbor_registry enable :admin_issue_board enable :admin_label enable :admin_milestone @@ -179,6 +180,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_deploy_token enable :create_jira_connect_subscription enable :maintainer_access + enable :maintain_namespace end rule { owner }.policy do diff --git a/app/policies/incident_management/timeline_event_policy.rb b/app/policies/incident_management/timeline_event_policy.rb index 514a2bf0a56..d8c3b283cd0 100644 --- a/app/policies/incident_management/timeline_event_policy.rb +++ b/app/policies/incident_management/timeline_event_policy.rb @@ -3,5 +3,15 @@ module IncidentManagement class TimelineEventPolicy < ::BasePolicy delegate { @subject.incident } + + condition(:is_editable, scope: :subject, score: 0) { @subject.editable? } + + rule { ~can?(:admin_incident_management_timeline_event) }.policy do + prevent :edit_incident_management_timeline_event + end + + rule { is_editable }.policy do + enable :edit_incident_management_timeline_event + end end end diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 2b6dcc56fa0..0a0a35d41cc 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -57,13 +57,7 @@ class IssuePolicy < IssuablePolicy enable :update_subscription end - # admin can set metadata on new issues - rule { ~persisted & admin }.policy do - enable :set_issue_metadata - end - - # support bot needs to be able to set metadata on new issues when service desk is enabled - rule { ~persisted & support_bot & can?(:guest_access) }.policy do + rule { can?(:admin_issue) }.policy do enable :set_issue_metadata end @@ -72,10 +66,6 @@ class IssuePolicy < IssuablePolicy enable :set_issue_metadata end - rule { persisted & can?(:admin_issue) }.policy do - enable :set_issue_metadata - end - rule { can?(:set_issue_metadata) }.policy do enable :set_confidentiality end diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index 96002d98afe..bda327cb661 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -14,7 +14,7 @@ class MergeRequestPolicy < IssuablePolicy prevent :accept_merge_request end - rule { can?(:update_merge_request) }.policy do + rule { can?(:update_merge_request) & is_project_member }.policy do enable :approve_merge_request end diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb index 028247497e5..26112332003 100644 --- a/app/policies/namespaces/user_namespace_policy.rb +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -11,6 +11,7 @@ module Namespaces enable :owner_access enable :create_projects enable :admin_namespace + enable :maintain_namespace enable :read_namespace enable :read_statistics enable :create_jira_connect_subscription diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 2594310c498..54270dc186e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -195,6 +195,8 @@ class ProjectPolicy < BasePolicy with_scope :subject condition(:packages_disabled) { !@subject.packages_enabled } + condition(:work_items_enabled, scope: :subject) { project&.work_items_feature_flag_enabled? } + features = %w[ merge_requests issues @@ -223,6 +225,10 @@ class ProjectPolicy < BasePolicy Gitlab.config.registry.enabled end + condition :packages_enabled do + Gitlab.config.packages.enabled + end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -290,10 +296,9 @@ class ProjectPolicy < BasePolicy rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident - rule { can?(:create_issue) }.policy do - enable :create_task - enable :create_work_item - end + rule { can?(:create_issue) }.enable :create_work_item + + rule { can?(:create_issue) & work_items_enabled }.enable :create_task # These abilities are not allowed to admins that are not members of the project, # that's why they are defined separately. @@ -317,6 +322,7 @@ class ProjectPolicy < BasePolicy enable :read_commit_status enable :read_build enable :read_container_image + enable :read_harbor_registry enable :read_deploy_board enable :read_pipeline enable :read_pipeline_schedule @@ -490,6 +496,7 @@ class ProjectPolicy < BasePolicy enable :update_runners_registration_token enable :admin_project_google_cloud enable :admin_secure_files + enable :read_web_hooks end rule { public_project & metrics_dashboard_allowed }.policy do @@ -792,6 +799,10 @@ class ProjectPolicy < BasePolicy enable :view_package_registry_project_settings end + rule { packages_enabled & can?(:admin_package) }.policy do + enable :view_package_registry_project_settings + end + private def user_is_user? diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb index ea7559592e1..2f3561f1135 100644 --- a/app/policies/work_item_policy.rb +++ b/app/policies/work_item_policy.rb @@ -13,4 +13,8 @@ class WorkItemPolicy < IssuePolicy # need to make sure we also prevent this rule if read_issue # is prevented rule { ~can?(:read_issue) }.prevent :read_work_item + + rule { can?(:reporter_access) }.policy do + enable :admin_parent_link + end end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 2dcc6cd5df3..74ac47fa439 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -69,7 +69,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def find_file_path - url_helpers.project_find_file_path(project, ref_qualified_path) + url_helpers.project_find_file_path(project, blob.commit_id) end def blame_path diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 0be684901d5..513fcd90cf8 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -4,16 +4,6 @@ module Ci class BuildPresenter < ProcessablePresenter presents ::Ci::Build, as: :build - def erased_by_user? - # Build can be erased through API, therefore it does not have - # `erased_by` user assigned in that case. - erased? && erased_by - end - - def erased_by_name - erased_by.name if erased_by_user? - end - def status_title(status = detailed_status) if auto_canceled? "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" @@ -33,10 +23,6 @@ module Ci end end - def tooltip_message - "#{build.name} - #{detailed_status.status_tooltip}" - end - def execute_in scheduled? && scheduled_at && [0, scheduled_at - Time.now].max end diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb deleted file mode 100644 index c803abfab6a..00000000000 --- a/app/presenters/ci/legacy_stage_presenter.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Ci - class LegacyStagePresenter < Gitlab::View::Presenter::Delegated - presents ::Ci::LegacyStage, as: :legacy_stage - - def latest_ordered_statuses - preload_statuses(legacy_stage.statuses.latest_ordered) - end - - def retried_ordered_statuses - preload_statuses(legacy_stage.statuses.retried_ordered) - end - - private - - def preload_statuses(statuses) - Preloaders::CommitStatusPreloader.new(statuses).execute(Ci::StagePresenter::PRELOADED_RELATIONS) - - statuses - end - end -end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 454e5c0e44a..ec1dc96c2e3 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -52,16 +52,6 @@ module Clusters end end - def gitlab_managed_apps_logs_path - return unless logs_project && can_read_cluster? - - if cluster.elastic_stack_adapter&.available? - elasticsearch_project_logs_path(logs_project, cluster_id: cluster.id, format: :json) - else - k8s_project_logs_path(logs_project, cluster_id: cluster.id, format: :json) - end - end - def read_only_kubernetes_platform_fields? !cluster.provided_by_user? end diff --git a/app/presenters/clusters/integration_presenter.rb b/app/presenters/clusters/integration_presenter.rb index f7be59f00f3..af735e1c18b 100644 --- a/app/presenters/clusters/integration_presenter.rb +++ b/app/presenters/clusters/integration_presenter.rb @@ -2,7 +2,7 @@ module Clusters class IntegrationPresenter < Gitlab::View::Presenter::Delegated - presents ::Clusters::Integrations::Prometheus, ::Clusters::Integrations::ElasticStack, as: :integration + presents ::Clusters::Integrations::Prometheus, as: :integration def application_type integration.class.name.demodulize.underscore diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 675288da35b..815a4da25ab 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -16,8 +16,11 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator', forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run', pipeline_loop_detected: 'This job could not be executed because it would create infinitely looping pipelines', + insufficient_upstream_permissions: 'This job could not be executed because of insufficient permissions to track the upstream project.', + upstream_bridge_project_not_found: 'This job could not be executed because upstream bridge project could not be found.', invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid', downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found', + protected_environment_failure: 'The environment this job is deploying to is protected. Only users with permission may successfully run this job.', insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline', bridge_pipeline_is_child_pipeline: 'This job belongs to a child pipeline and cannot create further child pipelines', downstream_pipeline_creation_failed: 'The downstream pipeline could not be created', @@ -62,5 +65,3 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated ActionController::Base.helpers.link_to('How do I fix it?', help_page_path(path, anchor: anchor)) end end - -CommitStatusPresenter.prepend_mod_with('CommitStatusPresenter') diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb index 81a954761ea..6230e61d2be 100644 --- a/app/presenters/gitlab/blame_presenter.rb +++ b/app/presenters/gitlab/blame_presenter.rb @@ -66,7 +66,7 @@ module Gitlab previous_commit_id = commit.parent_id return unless previous_commit_id && !previous_path.nil? - link_to project_blame_path(project, tree_join(previous_commit_id, previous_path)), + link_to project_blame_path(project, tree_join(previous_commit_id, previous_path), page: page), title: _('View blame prior to this change'), aria: { label: _('View blame prior to this change') }, class: 'version-link', diff --git a/app/presenters/invitation_presenter.rb b/app/presenters/invitation_presenter.rb deleted file mode 100644 index ada8227a477..00000000000 --- a/app/presenters/invitation_presenter.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class InvitationPresenter < Gitlab::View::Presenter::Delegated - presents nil, as: :invitation -end diff --git a/app/presenters/terraform/module_version_presenter.rb b/app/presenters/terraform/module_version_presenter.rb new file mode 100644 index 00000000000..776a4d8ab82 --- /dev/null +++ b/app/presenters/terraform/module_version_presenter.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Terraform + class ModuleVersionPresenter < Gitlab::View::Presenter::Simple + attr_accessor :package, :system + + def initialize(package, system) + @package = package + @system = system + end + + def name + package.name + end + + def provider + system + end + + def providers + [ + provider + ] + end + + def root + { + 'dependencies' => [] + } + end + + def source + package&.project&.web_url + end + + def submodules + [] + end + + def version + package.version + end + + def versions + [ + version + ] + end + end +end diff --git a/app/serializers/README.md b/app/serializers/README.md index d83c2061e0b..6bab3c83e1b 100644 --- a/app/serializers/README.md +++ b/app/serializers/README.md @@ -22,6 +22,10 @@ Using serializers, instead of `to_json` method, has several benefits: * it makes it easier to reduce merge conflicts between CE -> EE * it makes it easier to benefit from domain driven development techniques +## Security considerations + +Consult the `Serialization` section of our [Secure Coding Guidelines](../../doc/development/secure_coding_guidelines.md#serialization) to help avoiding leaking sensitive attributes when using serializers. + ## What is a serializer? A serializer is a class that encapsulates all business rules for building a diff --git a/app/serializers/ci/job_entity.rb b/app/serializers/ci/job_entity.rb index b6b11e54a16..813938c2a18 100644 --- a/app/serializers/ci/job_entity.rb +++ b/app/serializers/ci/job_entity.rb @@ -41,6 +41,8 @@ module Ci expose :scheduled?, as: :scheduled expose :scheduled_at, if: -> (*) { scheduled? } expose :created_at + expose :queued_at + expose :queued_duration expose :updated_at expose :detailed_status, as: :status, with: DetailedStatusEntity expose :callout_message, if: -> (*) { failed? && !job.script_failure? } diff --git a/app/serializers/ci/job_serializer.rb b/app/serializers/ci/job_serializer.rb index 01f9e223943..37213ee284a 100644 --- a/app/serializers/ci/job_serializer.rb +++ b/app/serializers/ci/job_serializer.rb @@ -3,10 +3,5 @@ module Ci class JobSerializer < BaseSerializer entity Ci::JobEntity - - def represent_status(resource) - data = represent(resource, { only: [:status] }) - data.fetch(:status, {}) - end end end diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index df72a994143..8e256863bcd 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -19,21 +19,7 @@ class ClusterEntity < Grape::Entity Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter end - expose :gitlab_managed_apps_logs_path, if: -> (*) { logging_enabled? } do |cluster| - Clusters::ClusterPresenter.new(cluster, current_user: request.current_user).gitlab_managed_apps_logs_path # rubocop: disable CodeReuse/Presenter - end - expose :kubernetes_errors do |cluster| Clusters::KubernetesErrorEntity.new(cluster) end - - expose :enable_advanced_logs_querying, if: -> (*) { logging_enabled? } do |cluster| - cluster.elastic_stack_available? - end - - private - - def logging_enabled? - Feature.enabled?(:monitor_logging, object.project) - end end diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb index f71591612a6..30b8863efa2 100644 --- a/app/serializers/cluster_serializer.rb +++ b/app/serializers/cluster_serializer.rb @@ -10,8 +10,6 @@ class ClusterSerializer < BaseSerializer :cluster_type, :enabled, :environment_scope, - :gitlab_managed_apps_logs_path, - :enable_advanced_logs_querying, :id, :kubernetes_errors, :name, diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index ac99463bd64..3473b4aebc8 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -66,22 +66,6 @@ class EnvironmentEntity < Grape::Entity environment.available? && can?(current_user, :stop_environment, environment) end - expose :logs_path, if: -> (*) { can_read_pod_logs? } do |environment| - project_logs_path(environment.project, environment_name: environment.name) - end - - expose :logs_api_path, if: -> (*) { can_read_pod_logs? } do |environment| - if environment.elastic_stack_available? - elasticsearch_project_logs_path(environment.project, environment_name: environment.name, format: :json) - else - k8s_project_logs_path(environment.project, environment_name: environment.name, format: :json) - end - end - - expose :enable_advanced_logs_querying, if: -> (*) { can_read_pod_logs? } do |environment| - environment.elastic_stack_available? - end - expose :can_delete do |environment| can?(current_user, :destroy_environment, environment) end @@ -102,11 +86,6 @@ class EnvironmentEntity < Grape::Entity can?(current_user, :update_environment, environment) end - def can_read_pod_logs? - Feature.enabled?(:monitor_logging, environment.project) && - can?(current_user, :read_pod_logs, environment.project) - end - def can_read_deploy_board? can?(current_user, :read_deploy_board, environment.project) end diff --git a/app/serializers/error_tracking/error_entity.rb b/app/serializers/error_tracking/error_entity.rb index 91388e7c3ad..49644ed2fe7 100644 --- a/app/serializers/error_tracking/error_entity.rb +++ b/app/serializers/error_tracking/error_entity.rb @@ -2,7 +2,11 @@ module ErrorTracking class ErrorEntity < Grape::Entity - expose :id, :title, :type, :user_count, :count, + expose :id do |error| + error.id.to_s + end + + expose :title, :type, :user_count, :count, :first_seen, :last_seen, :message, :culprit, :external_url, :project_id, :project_name, :project_slug, :short_id, :status, :frequency diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb index 170f660f334..91bd91dd941 100644 --- a/app/serializers/integrations/event_entity.rb +++ b/app/serializers/integrations/event_entity.rb @@ -18,12 +18,12 @@ module Integrations IntegrationsHelper.integration_event_description(integration, event) end - expose :field, if: ->(_, _) { event_field } do + expose :field, if: ->(_, _) { integration.try(:configurable_channels?) } do expose :name do |event| - event_field[:name] + integration.event_channel_name(event) end expose :value do |event| - integration.public_send(event_field[:name]) # rubocop:disable GitlabSecurity/PublicSend + integration.event_channel_value(event) end end @@ -35,10 +35,6 @@ module Integrations IntegrationsHelper.integration_event_field_name(event) end - def event_field - @event_field ||= integration.event_field(event) - end - def integration request.integration end diff --git a/app/serializers/integrations/harbor_serializers/artifact_entity.rb b/app/serializers/integrations/harbor_serializers/artifact_entity.rb new file mode 100644 index 00000000000..010380561eb --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/artifact_entity.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class ArtifactEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + + expose :harbor_id do |item| + item['id'] + end + + expose :digest do |item| + strip_tags(item['digest']) + end + + expose :size do |item| + item['size'] + end + + expose :push_time do |item| + item['push_time']&.to_datetime&.utc + end + + expose :tags do |item| + item['tags'].map { |tag| strip_tags(tag['name']) } + end + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/artifact_serializer.rb b/app/serializers/integrations/harbor_serializers/artifact_serializer.rb new file mode 100644 index 00000000000..aaf78a72330 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/artifact_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class ArtifactSerializer < BaseSerializer + include WithPagination + + entity ::Integrations::HarborSerializers::ArtifactEntity + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/repository_entity.rb b/app/serializers/integrations/harbor_serializers/repository_entity.rb new file mode 100644 index 00000000000..f03465fe8e2 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/repository_entity.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class RepositoryEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + + expose :harbor_id do |item| + item['id'] + end + + expose :name do |item| + strip_tags(item['name']) + end + + expose :artifact_count do |item| + item['artifact_count'] + end + + expose :creation_time do |item| + item['creation_time']&.to_datetime&.utc + end + + expose :update_time do |item| + item['update_time']&.to_datetime&.utc + end + + expose :harbor_project_id do |item| + item['project_id'] + end + + expose :pull_count do |item| + item['pull_count'] + end + + expose :location do |item| + path = [ + 'harbor/projects', + item['project_id'].to_s, + 'repositories', + item['name'].remove("#{options[:project_name]}/") + ].join('/') + path = validate_path(path) + strip_tags(Gitlab::Utils.append_path(options[:url], path)) + end + + private + + def validate_path(path) + Gitlab::Utils.check_path_traversal!(path) + rescue ::Gitlab::Utils::PathTraversalAttackError + Gitlab::AppLogger.error("Path traversal attack detected #{path}") + '' + end + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/repository_serializer.rb b/app/serializers/integrations/harbor_serializers/repository_serializer.rb new file mode 100644 index 00000000000..9b9e089eab8 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/repository_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class RepositorySerializer < BaseSerializer + include WithPagination + + entity ::Integrations::HarborSerializers::RepositoryEntity + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/tag_entity.rb b/app/serializers/integrations/harbor_serializers/tag_entity.rb new file mode 100644 index 00000000000..8c26bc1ecbd --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/tag_entity.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class TagEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + + expose :harbor_repository_id do |item| + item['repository_id'] + end + + expose :harbor_artifact_id do |item| + item['artifact_id'] + end + + expose :harbor_id do |item| + item['id'] + end + + expose :name do |item| + strip_tags(item['name']) + end + + expose :pull_time do |item| + item['pull_time']&.to_datetime&.utc + end + + expose :push_time do |item| + item['push_time']&.to_datetime&.utc + end + + expose :signed do |item| + item['signed'] + end + + expose :immutable do |item| + item['immutable'] + end + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/tag_serializer.rb b/app/serializers/integrations/harbor_serializers/tag_serializer.rb new file mode 100644 index 00000000000..7111e65e3e6 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/tag_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class TagSerializer < BaseSerializer + include WithPagination + + entity ::Integrations::HarborSerializers::TagEntity + end + end +end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index eba2c49bc2e..ea43ed87d22 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -73,7 +73,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: 'archiving-a-project') + help_page_path('user/project/settings/index.md', anchor: 'archive-a-project') end expose :issue_email_participants do |issue| diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 7d45484fc2f..2820c88b293 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -7,6 +7,7 @@ class MergeRequestBasicEntity < Grape::Entity expose :state expose :source_branch_exists?, as: :source_branch_exists expose :rebase_in_progress?, as: :rebase_in_progress + expose :should_be_rebased?, as: :should_be_rebased expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity expose :assignees, using: API::Entities::UserBasic diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 548ff577863..f278ccfce73 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -50,14 +50,6 @@ class StageEntity < Grape::Entity stage.detailed_status(request.current_user) end - def grouped_statuses - @grouped_statuses ||= stage.statuses.latest_ordered.group_by(&:status) - end - - def grouped_retried_statuses - @grouped_retried_statuses ||= stage.statuses.retried_ordered.group_by(&:status) - end - def latest_statuses Ci::HasStatus::ORDERED_STATUSES.flat_map do |ordered_status| grouped_statuses.fetch(ordered_status, []) @@ -69,4 +61,18 @@ class StageEntity < Grape::Entity grouped_retried_statuses.fetch(ordered_status, []) end end + + def grouped_statuses + @grouped_statuses ||= preload_metadata(stage.statuses.latest_ordered).group_by(&:status) + end + + def grouped_retried_statuses + @grouped_retried_statuses ||= preload_metadata(stage.statuses.retried_ordered).group_by(&:status) + end + + def preload_metadata(statuses) + Preloaders::CommitStatusPreloader.new(statuses).execute([:metadata]) + + statuses + end end diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb index 6bdceb0f27b..f273e15b159 100644 --- a/app/services/alert_management/alerts/update_service.rb +++ b/app/services/alert_management/alerts/update_service.rb @@ -12,7 +12,6 @@ module AlertManagement @alert = alert @param_errors = [] @status = params.delete(:status) - @status_change_reason = params.delete(:status_change_reason) super(project: alert.project, current_user: current_user, params: params) end @@ -37,7 +36,7 @@ module AlertManagement private - attr_reader :alert, :param_errors, :status, :status_change_reason + attr_reader :alert, :param_errors, :status def allowed? current_user&.can?(:update_alert_management_alert, alert) @@ -130,37 +129,16 @@ module AlertManagement def handle_status_change add_status_change_system_note resolve_todos if alert.resolved? - sync_to_incident if should_sync_to_incident? end def add_status_change_system_note - SystemNoteService.change_alert_status(alert, current_user, status_change_reason) + SystemNoteService.change_alert_status(alert, current_user) end def resolve_todos todo_service.resolve_todos_for_target(alert, current_user) end - def sync_to_incident - ::Issues::UpdateService.new( - project: project, - current_user: current_user, - params: { - escalation_status: { - status: status, - status_change_reason: " by changing the status of #{alert.to_reference(project)}" - } - } - ).execute(alert.issue) - end - - def should_sync_to_incident? - alert.issue && - alert.issue.supports_escalation? && - alert.issue.escalation_status && - alert.issue.escalation_status.status != alert.status - end - def filter_duplicate # Only need to check if changing to a not-resolved status return if params[:status_event].blank? || params[:status_event] == :resolve diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 97debccfb18..26244a8bcc5 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -121,12 +121,15 @@ class AuditEventService def log_security_event_to_database return if Gitlab::Database.read_only? - event = AuditEvent.new(base_payload.merge(details: @details)) + event = build_event save_or_track event - event end + def build_event + AuditEvent.new(base_payload.merge(details: @details)) + end + def stream_event_to_external_destinations(_event) # Defined in EE end diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 2a32f0c74ac..9e49bd86ec0 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -63,21 +63,6 @@ module AutoMerge end end - ## - # NOTE: This method is to be removed when `disallow_to_create_merge_request_pipelines_in_target_project` - # feature flag is removed. - def self.can_add_to_merge_train?(merge_request) - if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project) - merge_request.for_same_project? - else - true - end - end - - def can_add_to_merge_train?(merge_request) - self.class.can_add_to_merge_train?(merge_request) - end - private # Overridden in child classes diff --git a/app/services/bulk_imports/create_pipeline_trackers_service.rb b/app/services/bulk_imports/create_pipeline_trackers_service.rb index 5c9c68e62b5..af97aec09b5 100644 --- a/app/services/bulk_imports/create_pipeline_trackers_service.rb +++ b/app/services/bulk_imports/create_pipeline_trackers_service.rb @@ -47,7 +47,7 @@ module BulkImports end def non_patch_source_version - Gitlab::VersionInfo.new(source_version.major, source_version.minor, 0) + source_version.without_patch end def log_skipped_pipeline(pipeline, minimum_version, maximum_version) diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb index 8bdb51320f9..f9146b3677a 100644 --- a/app/services/ci/build_report_result_service.rb +++ b/app/services/ci/build_report_result_service.rb @@ -22,7 +22,7 @@ module Ci private def generate_test_suite_report(build) - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end def tests_params(test_suite) diff --git a/app/services/ci/external_pull_requests/create_pipeline_service.rb b/app/services/ci/external_pull_requests/create_pipeline_service.rb index 66127c94d35..ffc129eccda 100644 --- a/app/services/ci/external_pull_requests/create_pipeline_service.rb +++ b/app/services/ci/external_pull_requests/create_pipeline_service.rb @@ -10,24 +10,12 @@ module Ci return pull_request_not_open_error unless pull_request.open? return pull_request_branch_error unless pull_request.actual_branch_head? - create_pipeline_for(pull_request) - end - - private - - def create_pipeline_for(pull_request) Ci::ExternalPullRequests::CreatePipelineWorker.perform_async( project.id, current_user.id, pull_request.id ) end - def create_params(pull_request) - { - ref: pull_request.source_ref, - source_sha: pull_request.source_sha, - target_sha: pull_request.target_sha - } - end + private def pull_request_not_open_error ServiceResponse.error(message: 'The pull request is not opened', payload: nil) diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb index 12b1f19f4b5..81f26e84ef8 100644 --- a/app/services/ci/generate_coverage_reports_service.rb +++ b/app/services/ci/generate_coverage_reports_service.rb @@ -32,5 +32,18 @@ module Ci def latest?(base_pipeline, head_pipeline, data) data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) end + + private + + def key(base_pipeline, head_pipeline) + [ + base_pipeline&.id, last_update_timestamp(base_pipeline), + head_pipeline&.id, last_update_timestamp(head_pipeline) + ] + end + + def last_update_timestamp(pipeline_hierarchy) + pipeline_hierarchy&.self_and_descendants&.maximum(:updated_at) + end end end diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 635111130d6..05f8e804c67 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -5,10 +5,7 @@ module Ci class CreateService < ::BaseService include Gitlab::Utils::UsageData - ArtifactsExistError = Class.new(StandardError) - LSIF_ARTIFACT_TYPE = 'lsif' - METRICS_REPORT_UPLOAD_EVENT_NAME = 'i_testing_metrics_report_artifact_uploaders' OBJECT_STORAGE_ERRORS = [ Errno::EIO, @@ -74,10 +71,6 @@ module Ci Ci::JobArtifact.max_artifact_size(type: type, project: project) end - def forbidden_type_error(type) - error("#{type} artifacts are forbidden", :forbidden) - end - def too_large_error error('file size has reached maximum size limit', :payload_too_large) end @@ -160,10 +153,8 @@ module Ci ) end - def track_artifact_uploader(artifact) - return unless artifact.file_type == 'metrics' - - track_usage_event(METRICS_REPORT_UPLOAD_EVENT_NAME, @job.user_id) + def track_artifact_uploader(_artifact) + # Overridden in EE end def parse_dotenv_artifact(artifact) @@ -172,3 +163,5 @@ module Ci end end end + +Ci::JobArtifacts::CreateService.prepend_mod diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb index 49b65f13804..9d6b413ce59 100644 --- a/app/services/ci/job_artifacts/destroy_batch_service.rb +++ b/app/services/ci/job_artifacts/destroy_batch_service.rb @@ -184,10 +184,12 @@ module Ci project_ids << artifact.project_id end - Gitlab::ProjectStatsRefreshConflictsLogger.warn_skipped_artifact_deletion_during_stats_refresh( - method: 'Ci::JobArtifacts::DestroyBatchService#execute', - project_ids: project_ids - ) + if project_ids.any? + Gitlab::ProjectStatsRefreshConflictsLogger.warn_skipped_artifact_deletion_during_stats_refresh( + method: 'Ci::JobArtifacts::DestroyBatchService#execute', + project_ids: project_ids + ) + end end end end diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb index b0acb1d5a0b..c11a8f7a0fd 100644 --- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb +++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb @@ -9,17 +9,11 @@ module Ci end def execute - return if pipeline.has_coverage_reports? return if report.empty? - pipeline.pipeline_artifacts.create!( - project_id: pipeline.project_id, - file_type: :code_coverage, - file_format: Ci::PipelineArtifact::REPORT_TYPES.fetch(:code_coverage), - size: carrierwave_file["tempfile"].size, - file: carrierwave_file, - expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now - ) + Ci::PipelineArtifact.create_or_replace_for_pipeline!(**pipeline_artifact_params).tap do |pipeline_artifact| + Gitlab::AppLogger.info(log_params(pipeline_artifact)) + end end private @@ -32,6 +26,15 @@ module Ci end end + def pipeline_artifact_params + { + pipeline: pipeline, + file_type: :code_coverage, + file: carrierwave_file, + size: carrierwave_file['tempfile'].size + } + end + def carrierwave_file strong_memoize(:carrier_wave_file) do CarrierWaveStringFile.new_file( @@ -41,6 +44,15 @@ module Ci ) end end + + def log_params(pipeline_artifact) + { + project_id: pipeline.project_id, + pipeline_id: pipeline.id, + pipeline_artifact_id: pipeline_artifact.id, + message: "Created code coverage for pipeline." + } + end end end end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb index 4d1b2e07d7f..676c2ecb257 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb @@ -78,7 +78,7 @@ module Ci def status_for_array(statuses, dag:) result = Gitlab::Ci::Status::Composite - .new(statuses, dag: dag, project: pipeline.project) + .new(statuses, dag: dag) .status result || 'success' end diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb index fefbdb151ec..2deebc1d725 100644 --- a/app/services/ci/queue/build_queue_service.rb +++ b/app/services/ci/queue/build_queue_service.rb @@ -24,25 +24,7 @@ module Ci # rubocop:disable CodeReuse/ActiveRecord def builds_for_group_runner - if strategy.use_denormalized_data_strategy? - strategy.builds_for_group_runner - else - # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL` - groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces) - - hierarchy_groups = Gitlab::ObjectHierarchy - .new(groups) - .base_and_descendants - - projects = Project.where(namespace_id: hierarchy_groups) - .with_group_runners_enabled - .with_builds_enabled - .without_deleted - - relation = new_builds.where(project: projects) - - order(relation) - end + strategy.builds_for_group_runner end def builds_for_project_runner @@ -80,11 +62,7 @@ module Ci def strategy strong_memoize(:strategy) do - if ::Feature.enabled?(:ci_pending_builds_queue_source, runner) - Queue::PendingBuildsStrategy.new(runner) - else - Queue::BuildsTableStrategy.new(runner) - end + Queue::PendingBuildsStrategy.new(runner) end end diff --git a/app/services/ci/queue/builds_table_strategy.rb b/app/services/ci/queue/builds_table_strategy.rb deleted file mode 100644 index c27c10bd18d..00000000000 --- a/app/services/ci/queue/builds_table_strategy.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -module Ci - module Queue - class BuildsTableStrategy - attr_reader :runner - - def initialize(runner) - @runner = runner - end - - # rubocop:disable CodeReuse/ActiveRecord - def builds_for_shared_runner - relation = new_builds - # don't run projects which have not enabled shared runners and builds - .joins('INNER JOIN projects ON ci_builds.project_id = projects.id') - .where(projects: { shared_runners_enabled: true, pending_delete: false }) - .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id') - .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0') - - if Feature.enabled?(:ci_queueing_disaster_recovery_disable_fair_scheduling, runner, type: :ops) - # if disaster recovery is enabled, we fallback to FIFO scheduling - relation.order('ci_builds.id ASC') - else - # Implement fair scheduling - # this returns builds that are ordered by number of running builds - # we prefer projects that don't use shared runners at all - relation - .joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id = project_builds.project_id") - .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') - end - end - - def builds_for_group_runner - raise NotImplementedError - end - - def builds_matching_tag_ids(relation, ids) - # pick builds that does not have other tags than runner's one - relation.matches_tag_ids(ids) - end - - def builds_with_any_tags(relation) - # pick builds that have at least one tag - relation.with_any_tags - end - - def order(relation) - relation.order('id ASC') - end - - def new_builds - ::Ci::Build.pending.unstarted - end - - def build_ids(relation) - relation.pluck(:id) - end - - def use_denormalized_data_strategy? - false - end - - private - - def running_builds_for_shared_runners - ::Ci::Build.running - .where(runner: ::Ci::Runner.instance_type) - .group(:project_id) - .select(:project_id, 'COUNT(*) AS running_builds') - end - # rubocop:enable CodeReuse/ActiveRecord - end - end -end diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb index f2eba0681db..c8bdbba5e65 100644 --- a/app/services/ci/queue/pending_builds_strategy.rb +++ b/app/services/ci/queue/pending_builds_strategy.rb @@ -23,19 +23,11 @@ module Ci end def builds_matching_tag_ids(relation, ids) - if use_denormalized_data_strategy? - relation.for_tags(runner.tags_ids) - else - relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id')) - end + relation.for_tags(runner.tags_ids) end def builds_with_any_tags(relation) - if use_denormalized_data_strategy? - relation.where('cardinality(tag_ids) > 0') - else - relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id')) - end + relation.where('cardinality(tag_ids) > 0') end def order(relation) @@ -50,23 +42,10 @@ module Ci relation.pluck(:build_id) end - def use_denormalized_data_strategy? - ::Feature.enabled?(:ci_queuing_use_denormalized_data_strategy) - end - private def builds_available_for_shared_runners - if use_denormalized_data_strategy? - new_builds.with_instance_runners - else - new_builds - # don't run projects which have not enabled shared runners and builds - .joins('INNER JOIN projects ON ci_pending_builds.project_id = projects.id') - .where(projects: { shared_runners_enabled: true, pending_delete: false }) - .joins('LEFT JOIN project_features ON ci_pending_builds.project_id = project_features.project_id') - .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0') - end + new_builds.with_instance_runners end def builds_ordered_for_shared_runners(relation) diff --git a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb new file mode 100644 index 00000000000..e04079bfe27 --- /dev/null +++ b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Ci + module Runners + class ReconcileExistingRunnerVersionsService + include BaseServiceUtility + + VERSION_BATCH_SIZE = 100 + + def execute + insert_result = insert_runner_versions + total_deleted = cleanup_runner_versions(insert_result[:versions_from_runners]) + total_updated = update_status_on_outdated_runner_versions(insert_result[:versions_from_runners]) + + success({ + total_inserted: insert_result[:new_record_count], + total_updated: total_updated, + total_deleted: total_deleted + }) + end + + private + + def upgrade_check + Gitlab::Ci::RunnerUpgradeCheck.instance + end + + # rubocop: disable CodeReuse/ActiveRecord + def insert_runner_versions + versions_from_runners = Set[] + new_record_count = 0 + Ci::Runner.distinct_each_batch(column: :version, of: VERSION_BATCH_SIZE) do |version_batch| + batch_versions = version_batch.pluck(:version).to_set + versions_from_runners += batch_versions + + # Avoid hitting primary DB + already_existing_versions = Ci::RunnerVersion.where(version: batch_versions).pluck(:version) + new_versions = batch_versions - already_existing_versions + + if new_versions.any? + new_record_count += Ci::RunnerVersion.insert_all( + new_versions.map { |v| { version: v } }, + returning: :version, + unique_by: :version).count + end + end + + { versions_from_runners: versions_from_runners, new_record_count: new_record_count } + end + + def cleanup_runner_versions(versions_from_runners) + Ci::RunnerVersion.where.not(version: versions_from_runners).delete_all + end + # rubocop: enable CodeReuse/ActiveRecord + + def outdated_runner_versions + Ci::RunnerVersion.potentially_outdated + end + + def update_status_on_outdated_runner_versions(versions_from_runners) + total_updated = 0 + + outdated_runner_versions.each_batch(of: VERSION_BATCH_SIZE) do |version_batch| + updated = version_batch + .select { |runner_version| versions_from_runners.include?(runner_version['version']) } + .filter_map { |runner_version| runner_version_with_updated_status(runner_version) } + + if updated.any? + total_updated += Ci::RunnerVersion.upsert_all(updated, unique_by: :version).count + end + end + + total_updated + end + + def runner_version_with_updated_status(runner_version) + version = runner_version['version'] + suggestion = upgrade_check.check_runner_upgrade_status(version) + new_status = suggestion.each_key.first + + if new_status != :error && new_status != runner_version['status'].to_sym + { + version: version, + status: Ci::RunnerVersion.statuses[new_status] + } + end + end + end + end +end diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb index 196d2de1a65..6588cd7e248 100644 --- a/app/services/ci/runners/register_runner_service.rb +++ b/app/services/ci/runners/register_runner_service.rb @@ -8,7 +8,19 @@ module Ci return unless runner_type_attrs - ::Ci::Runner.create(attributes.merge(runner_type_attrs)) + runner = ::Ci::Runner.new(attributes.merge(runner_type_attrs)) + + Ci::BulkInsertableTags.with_bulk_insert_tags do + Ci::Runner.transaction do + if runner.save + Gitlab::Ci::Tags::BulkInsert.bulk_insert_tags!([runner]) + else + raise ActiveRecord::Rollback + end + end + end + + runner end private diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index 7323ad417ea..2214a6a2729 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -81,7 +81,7 @@ module Ci def generate_test_suite!(build) # Returns an instance of Gitlab::Ci::Reports::TestSuite - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end def ci_unit_test_attrs(batch) diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index a525ea179e0..58927a90b6e 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -14,8 +14,6 @@ module Ci # Add a build to the pending builds queue # def push(build, transition) - return unless maintain_pending_builds_queue? - raise InvalidQueueTransition unless transition.to == 'pending' transition.within_transaction do @@ -33,8 +31,6 @@ module Ci # Remove a build from the pending builds queue # def pop(build, transition) - return unless maintain_pending_builds_queue? - raise InvalidQueueTransition unless transition.from == 'pending' transition.within_transaction { remove!(build) } @@ -57,7 +53,6 @@ module Ci # Add shared runner build tracking entry (used for queuing). # def track(build, transition) - return unless maintain_pending_builds_queue? return unless build.shared_runner_build? raise InvalidQueueTransition unless transition.to == 'running' @@ -78,7 +73,6 @@ module Ci # queuing). # def untrack(build, transition) - return unless maintain_pending_builds_queue? return unless build.shared_runner_build? raise InvalidQueueTransition unless transition.from == 'running' @@ -115,9 +109,5 @@ module Ci runner.pick_build!(build) end end - - def maintain_pending_builds_queue? - ::Ci::PendingBuild.maintain_denormalized_data? - end end end diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb index 733b684bcc6..2118dbcc19e 100644 --- a/app/services/ci/update_pending_build_service.rb +++ b/app/services/ci/update_pending_build_service.rb @@ -15,8 +15,6 @@ module Ci end def execute - return unless ::Ci::PendingBuild.maintain_denormalized_data? - @model.pending_builds.each_batch do |relation| relation.update_all(@update_params) end diff --git a/app/services/clusters/integrations/create_service.rb b/app/services/clusters/integrations/create_service.rb index 142f731a7d3..555df52d177 100644 --- a/app/services/clusters/integrations/create_service.rb +++ b/app/services/clusters/integrations/create_service.rb @@ -31,8 +31,6 @@ module Clusters case params[:application_type] when 'prometheus' cluster.find_or_build_integration_prometheus - when 'elastic_stack' - cluster.find_or_build_integration_elastic_stack else raise ArgumentError, "invalid application_type: #{params[:application_type]}" end diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb index abbfd4d66d4..f10ff4e6f19 100644 --- a/app/services/concerns/alert_management/alert_processing.rb +++ b/app/services/concerns/alert_management/alert_processing.rb @@ -38,7 +38,7 @@ module AlertManagement if alert.resolve(incoming_payload.ends_at) SystemNoteService.change_alert_status(alert, User.alert_bot) - close_issue(alert.issue) if auto_close_incident? + close_issue(alert.issue_id) if auto_close_incident? else logger.warn( message: 'Unable to update AlertManagement::Alert status to resolved', @@ -52,22 +52,18 @@ module AlertManagement alert.register_new_event! end - def close_issue(issue) - return if issue.blank? || issue.closed? + def close_issue(issue_id) + return unless issue_id - ::Issues::CloseService - .new(project: project, current_user: User.alert_bot) - .execute(issue, system_note: false) - - SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed? + ::IncidentManagement::CloseIncidentWorker.perform_async(issue_id) end def process_new_alert + return if resolving_alert? + if alert.save alert.execute_integrations SystemNoteService.create_new_alert(alert, alert_source) - - process_resolved_alert if resolving_alert? else logger.warn( message: "Unable to create AlertManagement::Alert from #{alert_source}", @@ -78,7 +74,7 @@ module AlertManagement end def process_incident_issues - return if alert.issue || alert.resolved? + return if alert.issue_id || alert.resolved? ::IncidentManagement::ProcessAlertWorkerV2.perform_async(alert.id) end diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb index acaa773fd49..ae1e1d1e66c 100644 --- a/app/services/concerns/integrations/project_test_data.rb +++ b/app/services/concerns/integrations/project_test_data.rb @@ -63,7 +63,7 @@ module Integrations return { error: s_('TestHooks|Ensure the project has deployments.') } unless deployment.present? - Gitlab::DataBuilder::Deployment.build(deployment, Time.current) + Gitlab::DataBuilder::Deployment.build(deployment, deployment.status, Time.current) end def releases_events_data diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb new file mode 100644 index 00000000000..5665b07dce1 --- /dev/null +++ b/app/services/concerns/work_items/widgetable_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module WorkItems + module WidgetableService + def execute_widgets(work_item:, callback:, widget_params: {}) + work_item.widgets.each do |widget| + widget_service(widget).try(callback, params: widget_params[widget.class.api_symbol]) + end + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def widget_service(widget) + @widget_services ||= {} + return @widget_services[widget] if @widget_services.has_key?(widget) + + @widget_services[widget] = widget_service_class(widget)&.new(widget: widget, current_user: current_user) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def widget_service_class(widget) + "WorkItems::Widgets::#{widget.type.capitalize}Service::#{self.class.name.demodulize}".constantize + rescue NameError + nil + end + end +end diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_build_service.rb index b3e2d2edb59..76d871161e3 100644 --- a/app/services/deployments/create_for_build_service.rb +++ b/app/services/deployments/create_for_build_service.rb @@ -11,8 +11,18 @@ module Deployments # TODO: Move all buisness logic in `Seed::Deployment` to this class after # `create_deployment_in_separate_transaction` feature flag has been removed. # See https://gitlab.com/gitlab-org/gitlab/-/issues/348778 + + # If build.persisted_environment is a BatchLoader, we need to remove + # the method proxy in order to clone into new item here + # https://github.com/exAspArk/batch-loader/issues/31 + environment = if build.persisted_environment.respond_to?(:__sync) + build.persisted_environment.__sync + else + build.persisted_environment + end + deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment - .new(build, build.persisted_environment).to_resource + .new(build, environment).to_resource return unless deployment diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index ca7208dba96..eae736ae10d 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -75,6 +75,7 @@ module ErrorTracking # For now we implement the bare minimum for rendering the list in UI. list_opts = { filters: { status: opts[:issue_status] }, + query: opts[:search_term], sort: opts[:sort], limit: opts[:limit], cursor: opts[:cursor] diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 2ab4bb47462..019246dfc9f 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -25,14 +25,18 @@ class EventCreateService def open_mr(merge_request, current_user) create_record_event(merge_request, current_user, :created).tap do track_event(event_action: :created, event_target: MergeRequest, author_id: current_user.id) - track_mr_snowplow_event(merge_request, current_user, :create) + track_snowplow_event(merge_request, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION, + :create, 'merge_requests_users') end end def close_mr(merge_request, current_user) create_record_event(merge_request, current_user, :closed).tap do track_event(event_action: :closed, event_target: MergeRequest, author_id: current_user.id) - track_mr_snowplow_event(merge_request, current_user, :close) + track_snowplow_event(merge_request, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION, + :close, 'merge_requests_users') end end @@ -43,7 +47,9 @@ class EventCreateService def merge_mr(merge_request, current_user) create_record_event(merge_request, current_user, :merged).tap do track_event(event_action: :merged, event_target: MergeRequest, author_id: current_user.id) - track_mr_snowplow_event(merge_request, current_user, :merge) + track_snowplow_event(merge_request, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION, + :merge, 'merge_requests_users') end end @@ -67,7 +73,9 @@ class EventCreateService create_record_event(note, current_user, :commented).tap do if note.is_a?(DiffNote) && note.for_merge_request? track_event(event_action: :commented, event_target: MergeRequest, author_id: current_user.id) - track_mr_snowplow_event(note, current_user, :comment) + track_snowplow_event(note, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION, + :comment, 'merge_requests_users') end end end @@ -100,12 +108,27 @@ class EventCreateService records = create.zip([:created].cycle) + update.zip([:updated].cycle) return [] if records.empty? + if create.any? + track_snowplow_event(create.first, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION, + :create, 'design_users') + end + + if update.any? + track_snowplow_event(update.first, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION, + :update, 'design_users') + end + create_record_events(records, current_user) end def destroy_designs(designs, current_user) return [] unless designs.present? + track_snowplow_event(designs.first, current_user, + Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION, + :destroy, 'design_users') create_record_events(designs.zip([:destroyed].cycle), current_user) end @@ -230,14 +253,14 @@ class EventCreateService Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(**params) end - def track_mr_snowplow_event(record, current_user, action) + def track_snowplow_event(record, current_user, category, action, label) return unless Feature.enabled?(:route_hll_to_snowplow_phase2) project = record.project Gitlab::Tracking.event( - Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION.to_s, + category.to_s, action.to_s, - label: 'merge_requests_users', + label: label, project: project, namespace: project.namespace, user: current_user diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb index 86dc6188f0a..59db1a5f12f 100644 --- a/app/services/feature_flags/base_service.rb +++ b/app/services/feature_flags/base_service.rb @@ -15,6 +15,10 @@ module FeatureFlags protected + def update_last_feature_flag_updated_at! + Operations::FeatureFlagsClient.update_last_feature_flag_updated_at!(project) + end + def audit_event(feature_flag) message = audit_message(feature_flag) diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb index ebbe71f39c7..6ea40345191 100644 --- a/app/services/feature_flags/create_service.rb +++ b/app/services/feature_flags/create_service.rb @@ -10,6 +10,8 @@ module FeatureFlags feature_flag = project.operations_feature_flags.new(params) if feature_flag.save + update_last_feature_flag_updated_at! + success(feature_flag: feature_flag) else error(feature_flag.errors.full_messages, 400) diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb index 817a80940c0..0fdc890b8a3 100644 --- a/app/services/feature_flags/destroy_service.rb +++ b/app/services/feature_flags/destroy_service.rb @@ -13,6 +13,8 @@ module FeatureFlags ApplicationRecord.transaction do if feature_flag.destroy + update_last_feature_flag_updated_at! + success(feature_flag: feature_flag) else error(feature_flag.errors.full_messages) diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb index bcfd2c15189..a465ca1dd5f 100644 --- a/app/services/feature_flags/update_service.rb +++ b/app/services/feature_flags/update_service.rb @@ -29,6 +29,8 @@ module FeatureFlags audit_event = audit_event(feature_flag) if feature_flag.save + update_last_feature_flag_updated_at! + success(feature_flag: feature_flag, audit_event: audit_event) else error(feature_flag.errors.full_messages, :bad_request) diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index aa471d3a69f..7de56c037ed 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -81,6 +81,8 @@ module Git branch_update_hooks if updating_branch? branch_change_hooks if creating_branch? || updating_branch? branch_remove_hooks if removing_branch? + + track_process_commit_limit_overflow end def branch_create_hooks @@ -123,6 +125,12 @@ module Git end end + def track_process_commit_limit_overflow + return if threshold_commits.count <= PROCESS_COMMIT_LIMIT + + Gitlab::Metrics.add_event(:process_commit_limit_overflow) + end + # Schedules processing of commit messages def enqueue_process_commit_messages referencing_commits = limited_commits.select(&:matches_cross_reference_regex?) diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index 91f14251608..464d79d9991 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -39,13 +39,13 @@ module Git def enqueue_update_mrs return if params[:merge_request_branches]&.exclude?(branch_name) - # TODO: pass params[:push_options] to worker UpdateMergeRequestsWorker.perform_async( project.id, current_user.id, oldrev, newrev, - ref + ref, + params.slice(:push_options).deep_stringify_keys ) end diff --git a/app/services/google_cloud/base_service.rb b/app/services/google_cloud/base_service.rb new file mode 100644 index 00000000000..016ab15408f --- /dev/null +++ b/app/services/google_cloud/base_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module GoogleCloud + class BaseService < ::BaseService + protected + + def google_oauth2_token + @params[:google_oauth2_token] + end + + def gcp_project_id + @params[:gcp_project_id] + end + + def environment_name + @params[:environment_name] + end + + def google_api_client + @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil) + end + + def unique_gcp_project_ids + filter_params = { key: 'GCP_PROJECT_ID' } + ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq + end + + def group_vars_by_environment(keys) + filtered_vars = project.variables.filter { |variable| keys.include? variable.key } + filtered_vars.each_with_object({}) do |variable, grouped| + grouped[variable.environment_scope] ||= {} + grouped[variable.environment_scope][variable.key] = variable.value + end + end + + def create_or_replace_project_vars(environment_scope, key, value, is_protected, is_masked = false) + change_params = { + variable_params: { + key: key, + value: value, + environment_scope: environment_scope, + protected: is_protected, + masked: is_masked + } + } + existing_variable = find_existing_variable(environment_scope, key) + + if existing_variable + change_params[:action] = :update + change_params[:variable] = existing_variable + else + change_params[:action] = :create + end + + ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute + end + + private + + def find_existing_variable(environment_scope, key) + filter_params = { key: key, filter: { environment_scope: environment_scope } } + ::Ci::VariablesFinder.new(project, filter_params).execute.first + end + end +end diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb index 51d08cc5b55..9617161b8e9 100644 --- a/app/services/google_cloud/create_service_accounts_service.rb +++ b/app/services/google_cloud/create_service_accounts_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module GoogleCloud - class CreateServiceAccountsService < :: BaseService + class CreateServiceAccountsService < ::GoogleCloud::BaseService def execute service_account = google_api_client.create_service_account(gcp_project_id, service_account_name, service_account_desc) service_account_key = google_api_client.create_service_account_key(gcp_project_id, service_account.unique_id) @@ -23,22 +23,6 @@ module GoogleCloud private - def google_oauth2_token - @params[:google_oauth2_token] - end - - def gcp_project_id - @params[:gcp_project_id] - end - - def environment_name - @params[:environment_name] - end - - def google_api_client - @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil) - end - def service_accounts_service GoogleCloud::ServiceAccountsService.new(project) end diff --git a/app/services/google_cloud/enable_cloud_run_service.rb b/app/services/google_cloud/enable_cloud_run_service.rb index 643f2b2b6d2..4fd92f423c5 100644 --- a/app/services/google_cloud/enable_cloud_run_service.rb +++ b/app/services/google_cloud/enable_cloud_run_service.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true module GoogleCloud - class EnableCloudRunService < :: BaseService + class EnableCloudRunService < ::GoogleCloud::BaseService def execute gcp_project_ids = unique_gcp_project_ids if gcp_project_ids.empty? error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.") else - google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) - gcp_project_ids.each do |gcp_project_id| google_api_client.enable_cloud_run(gcp_project_id) google_api_client.enable_artifacts_registry(gcp_project_id) @@ -19,16 +17,5 @@ module GoogleCloud success({ gcp_project_ids: gcp_project_ids }) end end - - private - - def unique_gcp_project_ids - all_gcp_project_ids = project.variables.filter { |var| var.key == 'GCP_PROJECT_ID' }.map { |var| var.value } - all_gcp_project_ids.uniq - end - - def token_in_session - @params[:token_in_session] - end end end diff --git a/app/services/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/google_cloud/gcp_region_add_or_replace_service.rb index 467f818bcc7..f79df707a08 100644 --- a/app/services/google_cloud/gcp_region_add_or_replace_service.rb +++ b/app/services/google_cloud/gcp_region_add_or_replace_service.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module GoogleCloud - class GcpRegionAddOrReplaceService < ::BaseService + class GcpRegionAddOrReplaceService < ::GoogleCloud::BaseService def execute(environment, region) - gcp_region_key = Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY + gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } } filter_params = { key: gcp_region_key, filter: { environment_scope: environment } } diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb index 077f815e60c..be0c7a783c9 100644 --- a/app/services/google_cloud/generate_pipeline_service.rb +++ b/app/services/google_cloud/generate_pipeline_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module GoogleCloud - class GeneratePipelineService < :: BaseService + class GeneratePipelineService < ::GoogleCloud::BaseService ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN' ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE' diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb index b791f07cd65..e90fd112e2e 100644 --- a/app/services/google_cloud/service_accounts_service.rb +++ b/app/services/google_cloud/service_accounts_service.rb @@ -8,7 +8,7 @@ module GoogleCloud ## # This service deals with GCP Service Accounts in GitLab - class ServiceAccountsService < ::BaseService + class ServiceAccountsService < ::GoogleCloud::BaseService ## # Find GCP Service Accounts in a GitLab project # @@ -17,7 +17,7 @@ module GoogleCloud # aligning GitLab project and ref to GCP projects def find_for_project - group_vars_by_ref.map do |environment_scope, value| + group_vars_by_environment(GCP_KEYS).map do |environment_scope, value| { ref: environment_scope, gcp_project: value['GCP_PROJECT_ID'], @@ -28,50 +28,24 @@ module GoogleCloud end def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected) - project_var_create_or_replace( + create_or_replace_project_vars( ref, 'GCP_PROJECT_ID', gcp_project_id, is_protected ) - project_var_create_or_replace( + create_or_replace_project_vars( ref, 'GCP_SERVICE_ACCOUNT', service_account, is_protected ) - project_var_create_or_replace( + create_or_replace_project_vars( ref, 'GCP_SERVICE_ACCOUNT_KEY', service_account_key, is_protected ) end - - private - - def group_vars_by_ref - filtered_vars = project.variables.filter { |variable| GCP_KEYS.include? variable.key } - filtered_vars.each_with_object({}) do |variable, grouped| - grouped[variable.environment_scope] ||= {} - grouped[variable.environment_scope][variable.key] = variable.value - end - end - - def project_var_create_or_replace(environment_scope, key, value, is_protected) - change_params = { variable_params: { key: key, value: value, environment_scope: environment_scope, protected: is_protected } } - filter_params = { key: key, filter: { environment_scope: environment_scope } } - - existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first - - if existing_variable - change_params[:action] = :update - change_params[:variable] = existing_variable - else - change_params[:action] = :create - end - - ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute - end end end diff --git a/app/services/google_cloud/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb new file mode 100644 index 00000000000..73650ee752f --- /dev/null +++ b/app/services/google_cloud/setup_cloudsql_instance_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module GoogleCloud + class SetupCloudsqlInstanceService < ::GoogleCloud::BaseService + INSTANCE_STATE_RUNNABLE = 'RUNNABLE' + OPERATION_STATE_DONE = 'DONE' + DEFAULT_DATABASE_NAME = 'main_db' + DEFAULT_DATABASE_USER = 'main_user' + + def execute + return error('Unauthorized user') unless Ability.allowed?(current_user, :admin_project_google_cloud, project) + + get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name) + + if get_instance_response.state != INSTANCE_STATE_RUNNABLE + return error("CloudSQL instance not RUNNABLE: #{get_instance_response.to_json}") + end + + database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name) + + if database_response.status != OPERATION_STATE_DONE + return error("Database creation failed: #{database_response.to_json}") + end + + user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password) + + if user_response.status != OPERATION_STATE_DONE + return error("User creation failed: #{user_response.to_json}") + end + + primary_ip_address = get_instance_response.ip_addresses.first.ip_address + connection_name = get_instance_response.connection_name + + save_ci_var('GCP_PROJECT_ID', gcp_project_id) + save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name) + save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name) + save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address) + save_ci_var('GCP_CLOUDSQL_VERSION', database_version) + save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name) + save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username) + save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', password, true) + + success + rescue Google::Apis::Error => err + error(message: err.to_json) + end + + private + + def instance_name + @params[:instance_name] + end + + def database_version + @params[:database_version] + end + + def database_name + @params.fetch(:database_name, DEFAULT_DATABASE_NAME) + end + + def username + @params.fetch(:username, DEFAULT_DATABASE_USER) + end + + def password + SecureRandom.hex(16) + end + + def save_ci_var(key, value, is_masked = false) + create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked) + end + end +end diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb index a689b088854..9d5990f2c8a 100644 --- a/app/services/gravatar_service.rb +++ b/app/services/gravatar_service.rb @@ -2,6 +2,7 @@ class GravatarService def execute(email, size = nil, scale = 2, username: nil) + return if Gitlab::FIPS.enabled? return unless Gitlab::CurrentSettings.gravatar_enabled? identifier = email.presence || username.presence diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb index 06136aff50e..9705f3a560d 100644 --- a/app/services/groups/base_service.rb +++ b/app/services/groups/base_service.rb @@ -13,11 +13,11 @@ module Groups private def handle_namespace_settings - settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS) + settings_params = params.slice(*::NamespaceSetting.allowed_namespace_settings_params) return if settings_params.empty? - ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp| + ::NamespaceSetting.allowed_namespace_settings_params.each do |nsp| params.delete(nsp) end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 639f7c68c40..35716f7742a 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -13,7 +13,7 @@ module Groups remove_unallowed_params set_visibility_level - @group = Group.new(params.except(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS)) + @group = Group.new(params.except(*::NamespaceSetting.allowed_namespace_settings_params)) @group.build_namespace_settings handle_namespace_settings diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb index 0e7fd7e0817..4d74b5f32e2 100644 --- a/app/services/groups/group_links/destroy_service.rb +++ b/app/services/groups/group_links/destroy_service.rb @@ -16,6 +16,8 @@ module Groups groups_to_refresh = links.map(&:shared_with_group) groups_to_refresh.uniq.each do |group| + next if Feature.enabled?(:skip_group_share_unlink_auth_refresh, group.root_ancestor) + group.refresh_members_authorized_projects(blocking: false, direct_members_only: true) end else diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index a0021ae2ccb..29e3a9473ab 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -162,6 +162,12 @@ module Groups projects_to_update .update_all(visibility_level: @new_parent_group.visibility_level) + + update_project_settings(@updated_project_ids) + end + + # Overridden in EE + def update_project_settings(updated_project_ids) end def update_two_factor_authentication diff --git a/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb b/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb index b7f8b268f18..d11492e062a 100644 --- a/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb +++ b/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb @@ -6,7 +6,6 @@ module IncidentManagement def initialize(issuable, current_user, **params) @issuable = issuable @escalation_status = issuable.escalation_status - @alert = issuable.alert_management_alert super(project: issuable.project, current_user: current_user, params: params) end @@ -19,29 +18,24 @@ module IncidentManagement private - attr_reader :issuable, :escalation_status, :alert + attr_reader :issuable, :escalation_status def after_update - sync_status_to_alert add_status_system_note + add_timeline_event end - def sync_status_to_alert - return unless alert - return if alert.status == escalation_status.status + def add_status_system_note + return unless escalation_status.status_previously_changed? - ::AlertManagement::Alerts::UpdateService.new( - alert, - current_user, - status: escalation_status.status_name, - status_change_reason: " by changing the incident status of #{issuable.to_reference(project)}" - ).execute + SystemNoteService.change_incident_status(issuable, current_user, params[:status_change_reason]) end - def add_status_system_note + def add_timeline_event return unless escalation_status.status_previously_changed? - SystemNoteService.change_incident_status(issuable, current_user, params[:status_change_reason]) + IncidentManagement::TimelineEvents::CreateService + .change_incident_status(issuable, current_user, escalation_status) end end end diff --git a/app/services/incident_management/issuable_escalation_statuses/build_service.rb b/app/services/incident_management/issuable_escalation_statuses/build_service.rb index 9ebcf72a0c9..b3c57da03cb 100644 --- a/app/services/incident_management/issuable_escalation_statuses/build_service.rb +++ b/app/services/incident_management/issuable_escalation_statuses/build_service.rb @@ -5,30 +5,17 @@ module IncidentManagement class BuildService < ::BaseProjectService def initialize(issue) @issue = issue - @alert = issue.alert_management_alert super(project: issue.project) end def execute - return issue.escalation_status if issue.escalation_status - - issue.build_incident_management_issuable_escalation_status(alert_params) + issue.escalation_status || issue.build_incident_management_issuable_escalation_status end private - attr_reader :issue, :alert - - def alert_params - return {} unless alert - - { - status_event: alert.status_event_for(alert.status_name) - } - end + attr_reader :issue end end end - -IncidentManagement::IssuableEscalationStatuses::BuildService.prepend_mod diff --git a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb index 1d0504a6e80..58777848151 100644 --- a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb +++ b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb @@ -5,7 +5,7 @@ module IncidentManagement class PrepareUpdateService < ::BaseProjectService include Gitlab::Utils::StrongMemoize - SUPPORTED_PARAMS = %i[status status_change_reason].freeze + SUPPORTED_PARAMS = %i[status].freeze def initialize(issuable, current_user, params) @issuable = issuable diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb index 5e5feed65c2..3cb67ccf2b1 100644 --- a/app/services/incident_management/timeline_events/create_service.rb +++ b/app/services/incident_management/timeline_events/create_service.rb @@ -4,6 +4,7 @@ module IncidentManagement module TimelineEvents DEFAULT_ACTION = 'comment' DEFAULT_EDITABLE = false + DEFAULT_AUTO_CREATED = false class CreateService < TimelineEvents::BaseService def initialize(incident, user, params) @@ -11,6 +12,42 @@ module IncidentManagement @incident = incident @user = user @params = params + @auto_created = !!params.fetch(:auto_created, DEFAULT_AUTO_CREATED) + end + + class << self + def create_incident(incident, user) + note = "@#{user.username} created the incident" + occurred_at = incident.created_at + action = 'issues' + + new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute + end + + def reopen_incident(incident, user) + note = "@#{user.username} reopened the incident" + occurred_at = incident.updated_at + action = 'issues' + + new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute + end + + def resolve_incident(incident, user) + note = "@#{user.username} resolved the incident" + occurred_at = incident.updated_at + action = 'status' + + new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute + end + + def change_incident_status(incident, user, escalation_status) + status = escalation_status.status_name.to_s.titleize + note = "@#{user.username} changed the incident status to **#{status}**" + occurred_at = incident.updated_at + action = 'status' + + new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute + end end def execute @@ -32,8 +69,8 @@ module IncidentManagement if timeline_event.save add_system_note(timeline_event) - track_usage_event(:incident_management_timeline_event_created, user.id) + success(timeline_event) else error_in_save(timeline_event) @@ -42,9 +79,16 @@ module IncidentManagement private - attr_reader :project, :user, :incident, :params + attr_reader :project, :user, :incident, :params, :auto_created + + def allowed? + return true if auto_created + + super + end def add_system_note(timeline_event) + return if auto_created return unless Feature.enabled?(:incident_timeline, project) SystemNoteService.add_timeline_event(timeline_event) diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb index 83497b123dd..8217c8125c2 100644 --- a/app/services/incident_management/timeline_events/update_service.rb +++ b/app/services/incident_management/timeline_events/update_service.rb @@ -17,7 +17,6 @@ module IncidentManagement end def execute - return error_non_editable unless timeline_event.editable? return error_no_permissions unless allowed? if timeline_event.update(update_params) @@ -59,8 +58,8 @@ module IncidentManagement :none end - def error_non_editable - error(_('You cannot edit this timeline event.')) + def allowed? + user&.can?(:edit_incident_management_timeline_event, timeline_event) end end end diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb deleted file mode 100644 index 279d3051848..00000000000 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -module Issuable - module Clone - class AttributesRewriter < ::Issuable::Clone::BaseService - def initialize(current_user, original_entity, new_entity) - @current_user = current_user - @original_entity = original_entity - @new_entity = new_entity - end - - def execute - update_attributes = { labels: cloneable_labels } - - milestone = matching_milestone(original_entity.milestone&.title) - update_attributes[:milestone] = milestone if milestone.present? - - new_entity.update(update_attributes) - - copy_resource_label_events - copy_resource_milestone_events - copy_resource_state_events - end - - private - - def matching_milestone(title) - return if title.blank? || !new_entity.supports_milestone? - - params = { title: title, project_ids: new_entity.project&.id, group_ids: group&.id } - - milestones = MilestonesFinder.new(params).execute - milestones.first - end - - def cloneable_labels - params = { - project_id: new_entity.project&.id, - group_id: group&.id, - title: original_entity.labels.select(:title), - include_ancestor_groups: true - } - - params[:only_group_labels] = true if new_parent.is_a?(Group) - - LabelsFinder.new(current_user, params).execute - end - - def copy_resource_label_events - copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event| - event.attributes - .except('id', 'reference', 'reference_html') - .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action]) - end - end - - def copy_resource_milestone_events - return unless milestone_events_supported? - - copy_events(ResourceMilestoneEvent.table_name, original_entity.resource_milestone_events) do |event| - if event.remove? - event_attributes_with_milestone(event, nil) - else - matching_destination_milestone = matching_milestone(event.milestone_title) - - event_attributes_with_milestone(event, matching_destination_milestone) if matching_destination_milestone.present? - end - end - end - - def copy_resource_state_events - return unless state_events_supported? - - copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event| - event.attributes - .except(*blocked_state_event_attributes) - .merge(entity_key => new_entity.id, - 'state' => ResourceStateEvent.states[event.state]) - end - end - - # Overriden on EE::Issuable::Clone::AttributesRewriter - def blocked_state_event_attributes - ['id'] - end - - def event_attributes_with_milestone(event, milestone) - event.attributes - .except('id') - .merge(entity_key => new_entity.id, - 'milestone_id' => milestone&.id, - 'action' => ResourceMilestoneEvent.actions[event.action], - 'state' => ResourceMilestoneEvent.states[event.state]) - end - - def copy_events(table_name, events_to_copy) - events_to_copy.find_in_batches do |batch| - events = batch.map do |event| - yield(event) - end.compact - - ApplicationRecord.legacy_bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert - end - end - - def entity_key - new_entity.class.name.underscore.foreign_key - end - - def milestone_events_supported? - both_respond_to?(:resource_milestone_events) - end - - def state_events_supported? - both_respond_to?(:resource_state_events) - end - - def both_respond_to?(method) - original_entity.respond_to?(method) && - new_entity.respond_to?(method) - end - end - end -end - -Issuable::Clone::AttributesRewriter.prepend_mod_with('Issuable::Clone::AttributesRewriter') diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index ce9918a4b56..98c50347719 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -3,13 +3,13 @@ module Issuable module Clone class BaseService < IssuableBaseService - attr_reader :original_entity, :new_entity, :target_project + attr_reader :original_entity, :new_entity alias_method :old_project, :project - def execute(original_entity, target_project = nil) + def execute(original_entity, target_parent) @original_entity = original_entity - @target_project = target_project + @target_parent = target_parent # Using transaction because of a high resources footprint # on rewriting notes (unfolding references) @@ -25,19 +25,21 @@ module Issuable private - def copy_award_emoji - AwardEmojis::CopyService.new(original_entity, new_entity).execute - end + attr_reader :target_parent - def copy_notes - Notes::CopyService.new(current_user, original_entity, new_entity).execute + def rewritten_old_entity_attributes(include_milestone: true) + Gitlab::Issuable::Clone::AttributesRewriter.new( + current_user, + original_entity, + target_parent + ).execute(include_milestone: include_milestone) end def update_new_entity update_new_entity_description - update_new_entity_attributes copy_award_emoji copy_notes + copy_resource_events end def update_new_entity_description @@ -52,8 +54,16 @@ module Issuable new_entity.update!(update_description_params) end - def update_new_entity_attributes - AttributesRewriter.new(current_user, original_entity, new_entity).execute + def copy_award_emoji + AwardEmojis::CopyService.new(original_entity, new_entity).execute + end + + def copy_notes + Notes::CopyService.new(current_user, original_entity, new_entity).execute + end + + def copy_resource_events + Gitlab::Issuable::Clone::CopyResourceEventsService.new(current_user, original_entity, new_entity).execute end def update_old_entity @@ -74,14 +84,8 @@ module Issuable new_entity.resource_parent end - def group - if new_entity.project&.group && current_user.can?(:read_group, new_entity.project.group) - new_entity.project.group - end - end - def relative_position - return if original_entity.project.root_ancestor.id != target_project.root_ancestor.id + return if original_entity.project.root_ancestor.id != target_parent.root_ancestor.id original_entity.relative_position end diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 4a2078a4e60..9b41c88159f 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -23,7 +23,8 @@ module Issuable with_csv_lines.each do |row, line_no| issuable_attributes = { title: row[:title], - description: row[:description] + description: row[:description], + due_date: row[:due_date] } if create_issuable(issuable_attributes).persisted? diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 03115416607..acd6d45af7a 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -162,8 +162,6 @@ class IssuableBaseService < ::BaseProjectService return unless result.success? && result[:escalation_status].present? - @escalation_status_change_reason = result[:escalation_status].delete(:status_change_reason) - params[:incident_management_issuable_escalation_status_attributes] = result[:escalation_status] end @@ -231,7 +229,7 @@ class IssuableBaseService < ::BaseProjectService before_create(issuable) issuable_saved = issuable.with_transaction_returning_status do - issuable.save + transaction_create(issuable) end if issuable_saved @@ -282,8 +280,9 @@ class IssuableBaseService < ::BaseProjectService assign_requested_labels(issuable) assign_requested_assignees(issuable) assign_requested_crm_contacts(issuable) + widget_params = filter_widget_params - if issuable.changed? || params.present? + if issuable.changed? || params.present? || widget_params.present? issuable.assign_attributes(allowed_update_params(params)) if has_title_or_description_changed?(issuable) @@ -303,7 +302,7 @@ class IssuableBaseService < ::BaseProjectService ensure_milestone_available(issuable) issuable_saved = issuable.with_transaction_returning_status do - issuable.save(touch: should_touch) + transaction_update(issuable, { save_with_touch: should_touch }) end if issuable_saved @@ -332,6 +331,16 @@ class IssuableBaseService < ::BaseProjectService issuable end + def transaction_update(issuable, opts = {}) + touch = opts[:save_with_touch] || false + + issuable.save(touch: touch) + end + + def transaction_create(issuable) + issuable.save + end + def update_task(issuable) filter_params(issuable) @@ -590,6 +599,10 @@ class IssuableBaseService < ::BaseProjectService issuable_sla.update(issuable_closed: issuable.closed?) end + + def filter_widget_params + params.delete(:widget_params) + end end IssuableBaseService.prepend_mod_with('IssuableBaseService') diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index 0887f04760c..aca98596a02 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -8,6 +8,7 @@ module IssuableLinks @issuable = issuable @current_user = user @params = params.dup + @errors = [] end def execute @@ -22,7 +23,6 @@ module IssuableLinks return error(issuables_not_found_message, 404) end - @errors = [] references = create_links if @errors.present? diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 1ebf9bb6ba2..75bd2b88e86 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -81,8 +81,9 @@ module Issues ::Issue end - def allowed_issue_params - allowed_params = [ + def public_params + # Additional params may be assigned later (in a CreateService for example) + public_issue_params = [ :title, :description, :confidential @@ -90,17 +91,17 @@ module Issues params[:work_item_type] = WorkItems::Type.find_by(id: params[:work_item_type_id]) if params[:work_item_type_id].present? # rubocop: disable CodeReuse/ActiveRecord - allowed_params << :milestone_id if can?(current_user, :admin_issue, project) - allowed_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type]) - allowed_params << :work_item_type if create_issue_type_allowed?(project, params[:work_item_type]&.base_type) + public_issue_params << :milestone_id if can?(current_user, :admin_issue, project) + public_issue_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type]) + public_issue_params << :work_item_type if create_issue_type_allowed?(project, params[:work_item_type]&.base_type) - params.slice(*allowed_params) + params.slice(*public_issue_params) end def build_issue_params { author: current_user } .merge(issue_params_with_info_from_discussions) - .merge(allowed_issue_params) + .merge(public_params) .with_indifferent_access end diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index c675f957cd7..896b15a14b8 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -41,9 +41,12 @@ module Issues def update_new_entity # we don't call `super` because we want to be able to decide whether or not to copy all comments over. update_new_entity_description - update_new_entity_attributes copy_award_emoji - copy_notes if with_notes + + if with_notes + copy_notes + copy_resource_events + end end def update_old_entity @@ -62,14 +65,18 @@ module Issues } new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params) + new_params = new_params.merge(rewritten_old_entity_attributes) + new_params.delete(:created_at) + new_params.delete(:updated_at) # spam checking is not necessary, as no new content is being created. Passing nil for # spam_params will cause SpamActionService to skip checking and return a success response. spam_params = nil - # Skip creation of system notes for existing attributes of the issue. The system notes of the old - # issue are copied over so we don't want to end up with duplicate notes. - CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: true) + # Skip creation of system notes for existing attributes of the issue when cloning with notes. + # The system notes of the old issue are copied over so we don't want to end up with duplicate notes. + # When cloning without notes, we want to generate system notes for the attributes that were copied. + CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: with_notes) end def queue_copy_designs diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index ff45091c7e6..d08e4d12a92 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -97,7 +97,10 @@ module Issues status = issue.incident_management_issuable_escalation_status || issue.build_incident_management_issuable_escalation_status - SystemNoteService.change_incident_status(issue, current_user, ' by closing the incident') if status.resolve + return unless status.resolve + + SystemNoteService.change_incident_status(issue, current_user, ' by closing the incident') + IncidentManagement::TimelineEvents::CreateService.resolve_incident(issue, current_user) end def store_first_mentioned_in_commit_at(issue, merge_request, max_commit_lookup: 100) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index edf6d75b632..30d4cb68840 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -13,6 +13,7 @@ module Issues # in the caller (for example, an issue created via email) and the required arguments to the # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil. def initialize(project:, current_user: nil, params: {}, spam_params:, build_service: nil) + @extra_params = params.delete(:extra_params) || {} super(project: project, current_user: current_user, params: params) @spam_params = spam_params @build_service = build_service || BuildService.new(project: project, current_user: current_user, params: params) @@ -46,7 +47,7 @@ module Issues issue.run_after_commit do NewIssueWorker.perform_async(issue.id, user.id) Issues::PlacementWorker.perform_async(nil, issue.project_id) - Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.namespace.id) + Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.project.namespace_id) end end @@ -56,7 +57,8 @@ module Issues handle_add_related_issue(issue) resolve_discussions_with_issue(issue) create_escalation_status(issue) - try_to_associate_contact(issue) + create_timeline_event(issue) + try_to_associate_contacts(issue) super end @@ -85,12 +87,18 @@ module Issues private - attr_reader :spam_params + attr_reader :spam_params, :extra_params def create_escalation_status(issue) ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation? end + def create_timeline_event(issue) + return unless issue.incident? + + IncidentManagement::TimelineEvents::CreateService.create_incident(issue, current_user) + end + def user_agent_detail_service UserAgentDetailService.new(spammable: @issue, spam_params: spam_params) end @@ -101,11 +109,14 @@ module Issues IssueLinks::CreateService.new(issue, issue.author, { target_issuable: @add_related_issue }).execute end - def try_to_associate_contact(issue) + def try_to_associate_contacts(issue) return unless issue.external_author return unless current_user.can?(:set_issue_crm_contacts, issue) - set_crm_contacts(issue, [issue.external_author]) + contacts = [issue.external_author] + contacts.concat extra_params[:cc] unless extra_params[:cc].nil? + + set_crm_contacts(issue, contacts) end end end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index d210ba2a76c..edab62b1fdf 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -76,6 +76,7 @@ module Issues } new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params) + new_params = new_params.merge(rewritten_old_entity_attributes) # spam checking is not necessary, as no new content is being created. Passing nil for # spam_params will cause SpamActionService to skip checking and return a success response. spam_params = nil diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb index 8b08c1f8ddb..2ecd3e561c9 100644 --- a/app/services/issues/related_branches_service.rb +++ b/app/services/issues/related_branches_service.rb @@ -5,26 +5,23 @@ module Issues class RelatedBranchesService < Issues::BaseService def execute(issue) - branch_names = branches_with_iid_of(issue) - branches_with_merge_request_for(issue) - branch_names.map { |branch_name| branch_data(branch_name) } + branch_names_with_mrs = branches_with_merge_request_for(issue) + branches = branches_with_iid_of(issue).reject { |b| branch_names_with_mrs.include?(b[:name]) } + + branches.map { |branch| branch_data(branch) } end private - def branch_data(branch_name) + def branch_data(branch) { - name: branch_name, - pipeline_status: pipeline_status(branch_name) + name: branch[:name], + pipeline_status: pipeline_status(branch) } end - def pipeline_status(branch_name) - branch = project.repository.find_branch(branch_name) - target = branch&.dereferenced_target - - return unless target - - pipeline = project.latest_pipeline(branch_name, target.sha) + def pipeline_status(branch) + pipeline = project.latest_pipeline(branch[:name], branch[:target]) pipeline.detailed_status(current_user) if can?(current_user, :read_pipeline, pipeline) end @@ -36,8 +33,16 @@ module Issues end def branches_with_iid_of(issue) - project.repository.branch_names.select do |branch| - branch =~ /\A#{issue.iid}-(?!\d+-stable)/i + branch_ref_regex = /\A#{Gitlab::Git::BRANCH_REF_PREFIX}#{issue.iid}-(?!\d+-stable)/i + + return [] unless project.repository.exists? + + project.repository.list_refs( + [Gitlab::Git::BRANCH_REF_PREFIX + "#{issue.iid}-*"] + ).each_with_object([]) do |ref, results| + if ref.name.match?(branch_ref_regex) + results << { name: ref.name.delete_prefix(Gitlab::Git::BRANCH_REF_PREFIX), target: ref.target } + end end end end diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index 4abd1dfbf4e..e003ecacb3f 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -32,11 +32,18 @@ module Issues end def perform_incident_management_actions(issue) + return unless issue.incident? + + create_timeline_event(issue) end def create_note(issue, state = issue.state) SystemNoteService.change_status(issue, issue.project, current_user, state, nil) end + + def create_timeline_event(issue) + IncidentManagement::TimelineEvents::CreateService.reopen_incident(issue, current_user) + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index d9210169005..afc61eed287 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -199,8 +199,7 @@ module Issues ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new( issue, - current_user, - status_change_reason: @escalation_status_change_reason # Defined in IssuableBaseService before save + current_user ).execute end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 57d9da4cefd..38bebc1d09d 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -84,7 +84,7 @@ module Members end def add_members - @members = source.add_users( + @members = source.add_members( invites, params[:access_level], expires_at: params[:expires_at], diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index 276093a00a9..f59a3ed77eb 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -13,9 +13,9 @@ module Members Gitlab::Access.sym_options_with_owner end - def add_users( # rubocop:disable Metrics/ParameterLists + def add_members( # rubocop:disable Metrics/ParameterLists source, - users, + invitees, access_level, current_user: nil, expires_at: nil, @@ -24,41 +24,49 @@ module Members ldap: nil, blocking_refresh: nil ) - return [] unless users.present? + return [] unless invitees.present? # If this user is attempting to manage Owner members and doesn't have permission, do not allow return [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user) - emails, users, existing_members = parse_users_list(source, users) + emails, users, existing_members = parse_users_list(source, invitees) Member.transaction do - (emails + users).map! do |user| - new(source, - user, - access_level, - existing_members: existing_members, - current_user: current_user, - expires_at: expires_at, - tasks_to_be_done: tasks_to_be_done, - tasks_project_id: tasks_project_id, - ldap: ldap, - blocking_refresh: blocking_refresh) - .execute + common_arguments = { + source: source, + access_level: access_level, + existing_members: existing_members, + current_user: current_user, + expires_at: expires_at, + tasks_to_be_done: tasks_to_be_done, + tasks_project_id: tasks_project_id, + ldap: ldap, + blocking_refresh: blocking_refresh + } + + members = emails.map do |email| + new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute end + + members += users.map do |user| + new(invitee: user, **common_arguments).execute + end + + members end end - def add_user( # rubocop:disable Metrics/ParameterLists + def add_member( # rubocop:disable Metrics/ParameterLists source, - user, + invitee, access_level, current_user: nil, expires_at: nil, ldap: nil, blocking_refresh: nil ) - add_users(source, - [user], + add_members(source, + [invitee], access_level, current_user: current_user, expires_at: expires_at, @@ -113,11 +121,11 @@ module Members end end - def initialize(source, user, access_level, **args) - @source = source - @user = user - @access_level = self.class.parsed_access_level(access_level) + def initialize(invitee:, builder: StandardMemberBuilder, **args) + @invitee = invitee + @builder = builder @args = args + @access_level = self.class.parsed_access_level(args[:access_level]) end private_class_method :new @@ -133,7 +141,7 @@ module Members private delegate :new_record?, to: :member - attr_reader :source, :user, :access_level, :member, :args + attr_reader :invitee, :access_level, :member, :args, :builder def assign_member_attributes member.attributes = member_attributes @@ -170,7 +178,7 @@ module Members # Populates the attributes of a member. # # This logic resides in a separate method so that EE can extend this logic, - # without having to patch the `add_user` method directly. + # without having to patch the `add_members` method directly. def member_attributes { created_by: member.created_by || current_user, @@ -182,14 +190,14 @@ module Members def commit_changes if member.request? approve_request - else + elsif member.changed? # Calling #save triggers callbacks even if there is no change on object. # This previously caused an incident due to the hard to predict # behaviour caused by the large number of callbacks. # See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/6351 # and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80920#note_911569038 # for details. - member.save if member.changed? + member.save end end @@ -241,43 +249,19 @@ module Members end def find_or_build_member - @user = parse_user_param - - @member = if user.is_a?(User) - find_or_initialize_member_by_user - else - source.members.build(invite_email: user) - end + @member = builder.new(source, invitee, existing_members).execute @member.blocking_refresh = args[:blocking_refresh] end - # This method is used to find users that have been entered into the "Add members" field. - # These can be the User objects directly, their IDs, their emails, or new emails to be invited. - def parse_user_param - case user - when User - user - when Integer - # might not return anything - this needs enhancement - User.find_by(id: user) # rubocop:todo CodeReuse/ActiveRecord - else - # must be an email or at least we'll consider it one - source.users_by_emails([user])[user] || user - end - end - - def find_or_initialize_member_by_user - # We have to use `members_and_requesters` here since the given `members` is modified in the models - # to act more like a scope(removing the requested_at members) and therefore ActiveRecord has issues with that - # on build and refreshing that relation. - existing_members[user.id] || source.members_and_requesters.build(user_id: user.id) # rubocop:disable CodeReuse/ActiveRecord - end - def ldap args[:ldap] || false end + def source + args[:source] + end + def existing_members args[:existing_members] || {} end diff --git a/app/services/members/invite_member_builder.rb b/app/services/members/invite_member_builder.rb new file mode 100644 index 00000000000..e925121bb1e --- /dev/null +++ b/app/services/members/invite_member_builder.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Members + class InviteMemberBuilder < StandardMemberBuilder + def execute + if user_by_email + find_or_initialize_member_by_user(user_by_email.id) + else + source.members_and_requesters.find_or_initialize_by(invite_email: invitee) # rubocop:disable CodeReuse/ActiveRecord + end + end + + private + + def user_by_email + source.users_by_emails([invitee])[invitee] + end + end +end diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb index 1bf209ab79d..6d23a9bc2dc 100644 --- a/app/services/members/invite_service.rb +++ b/app/services/members/invite_service.rb @@ -31,8 +31,8 @@ module Members return if params[:email].blank? - # we need the below due to add_users hitting Members::CreatorService.parse_users_list and ignoring invalid emails - # ideally we wouldn't need this, but we can't really change the add_users method + # we need the below due to add_member hitting Members::CreatorService.parse_users_list and ignoring invalid emails + # ideally we wouldn't need this, but we can't really change the add_members method invalid_emails.each { |email| errors[email] = s_('AddMember|Invite email is invalid') } end diff --git a/app/services/members/projects/creator_service.rb b/app/services/members/projects/creator_service.rb index cde1d0462e8..f45132749f9 100644 --- a/app/services/members/projects/creator_service.rb +++ b/app/services/members/projects/creator_service.rb @@ -32,7 +32,7 @@ module Members end def adding_the_creator_as_owner_in_a_personal_project? - # this condition is reached during testing setup a lot due to use of `.add_user` + # this condition is reached during testing setup a lot due to use of `.add_member` member.project.personal_namespace_holder?(member.user) end diff --git a/app/services/members/standard_member_builder.rb b/app/services/members/standard_member_builder.rb new file mode 100644 index 00000000000..24e71f80d7e --- /dev/null +++ b/app/services/members/standard_member_builder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Members + class StandardMemberBuilder + def initialize(source, invitee, existing_members) + @source = source + @invitee = invitee + @existing_members = existing_members + end + + def execute + find_or_initialize_member_by_user(invitee.id) + end + + private + + attr_reader :source, :invitee, :existing_members + + def find_or_initialize_member_by_user(user_id) + existing_members[user_id] || source.members_and_requesters.build(user_id: user_id) # rubocop:disable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index e3f0758699b..b8d817a15f3 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -16,7 +16,7 @@ module MergeRequests mark_pending_todos_as_done(merge_request) execute_approval_hooks(merge_request, current_user) remove_attention_requested(merge_request) - merge_request_activity_counter.track_approve_mr_action(user: current_user) + merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request) success end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 2b6a66b9dee..9bd38478796 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -128,13 +128,8 @@ module MergeRequests if draft_event = params.delete(:wip_event) # We update the title that is provided in the params or we use the mr title title = params[:title] || merge_request.title - # Supports both `wip` and `draft` permutations of draft_event - # This support can be removed >= %15.2 - # params[:title] = case draft_event - when 'wip' then MergeRequest.draft_title(title) when 'draft' then MergeRequest.draft_title(title) - when 'unwip' then MergeRequest.draftless_title(title) when 'ready' then MergeRequest.draftless_title(title) end end @@ -190,8 +185,11 @@ module MergeRequests def create_pipeline_for(merge_request, user, async: false) if async - # TODO: pass push_options to worker - MergeRequests::CreatePipelineWorker.perform_async(project.id, user.id, merge_request.id) + MergeRequests::CreatePipelineWorker.perform_async( + project.id, + user.id, + merge_request.id, + params.slice(:push_options).deep_stringify_keys) else MergeRequests::CreatePipelineService .new(project: project, current_user: user, params: params.slice(:push_options)) diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index 37c734613e7..c6a91a3b61e 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -50,12 +50,8 @@ module MergeRequests end def can_create_pipeline_in_target_project?(merge_request) - if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project) - merge_request.for_same_project? - else - can?(current_user, :create_pipeline, merge_request.target_project) && - can_update_source_branch_in_target_project?(merge_request) - end + can?(current_user, :create_pipeline, merge_request.target_project) && + can_update_source_branch_in_target_project?(merge_request) end def can_update_source_branch_in_target_project?(merge_request) diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index 414f253deb8..c139b2e11dd 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -37,11 +37,6 @@ module Namespaces interval_days: [1, 5, 10], completed_actions: [:git_write, :pipeline_created, :trial_started], incomplete_actions: [:user_added] - }, - experience: { - interval_days: [30], - completed_actions: [:created, :git_write], - incomplete_actions: [] } }.freeze diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb index 4f1bb0dc877..0a7f25f1af3 100644 --- a/app/services/notification_recipients/builder/base.rb +++ b/app/services/notification_recipients/builder/base.rb @@ -47,6 +47,8 @@ module NotificationRecipients end users = Array(users).compact + preload_users_namespace_bans(users) + recipients.concat(users.map { |u| make_recipient(u, type, reason) }) end # rubocop: enable CodeReuse/ActiveRecord @@ -240,6 +242,14 @@ module NotificationRecipients add_recipients(label.subscribers(project), :subscription, NotificationReason::SUBSCRIBED) end end + + private + + def preload_users_namespace_bans(_users) + # overridden in EE + end end end end + +NotificationRecipients::Builder::Base.prepend_mod_with('NotificationRecipients::Builder::Base') diff --git a/app/services/packages/cleanup/execute_policy_service.rb b/app/services/packages/cleanup/execute_policy_service.rb new file mode 100644 index 00000000000..b432f6d0acb --- /dev/null +++ b/app/services/packages/cleanup/execute_policy_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Packages + module Cleanup + class ExecutePolicyService + include Gitlab::Utils::StrongMemoize + + MAX_EXECUTION_TIME = 250.seconds + + DUPLICATED_FILES_BATCH_SIZE = 10_000 + MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE = 200 + + def initialize(policy) + @policy = policy + @counts = { + marked_package_files_total_count: 0, + unique_package_id_and_file_name_total_count: 0 + } + end + + def execute + cleanup_duplicated_files + end + + private + + def cleanup_duplicated_files + return if @policy.keep_n_duplicated_package_files_disabled? + + result = installable_package_files.each_batch(of: DUPLICATED_FILES_BATCH_SIZE) do |package_files| + break :timeout if cleanup_duplicated_files_on(package_files) == :timeout + end + + response_success(timeout: result == :timeout) + end + + def cleanup_duplicated_files_on(package_files) + unique_package_id_and_file_name_from(package_files).each do |package_id, file_name| + result = remove_duplicated_files_for(package_id: package_id, file_name: file_name) + @counts[:marked_package_files_total_count] += result.payload[:marked_package_files_count] + @counts[:unique_package_id_and_file_name_total_count] += 1 + + break :timeout unless result.success? + end + end + + def unique_package_id_and_file_name_from(package_files) + # This is a highly custom query for this service, that's why it's not in the model. + # rubocop: disable CodeReuse/ActiveRecord + package_files.group(:package_id, :file_name) + .having("COUNT(*) > #{@policy.keep_n_duplicated_package_files}") + .pluck(:package_id, :file_name) + # rubocop: enable CodeReuse/ActiveRecord + end + + def remove_duplicated_files_for(package_id:, file_name:) + base = ::Packages::PackageFile.for_package_ids(package_id) + .installable + .with_file_name(file_name) + ids_to_keep = base.recent + .limit(@policy.keep_n_duplicated_package_files) + .pluck_primary_key + + duplicated_package_files = base.id_not_in(ids_to_keep) + ::Packages::MarkPackageFilesForDestructionService.new(duplicated_package_files) + .execute(batch_deadline: batch_deadline, batch_size: MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE) + end + + def project + @policy.project + end + + def installable_package_files + ::Packages::PackageFile.installable + .for_package_ids( + ::Packages::Package.installable + .for_projects(project.id) + ) + end + + def batch_deadline + strong_memoize(:batch_deadline) do + MAX_EXECUTION_TIME.from_now + end + end + + def response_success(timeout:) + ServiceResponse.success( + message: "Packages cleanup policy executed for project #{project.id}", + payload: { + timeout: timeout, + counts: @counts + } + ) + end + end + end +end diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb index 2022a63a725..fbbc8159ca0 100644 --- a/app/services/packages/debian/create_package_file_service.rb +++ b/app/services/packages/debian/create_package_file_service.rb @@ -3,12 +3,15 @@ module Packages module Debian class CreatePackageFileService + include ::Packages::FIPS + def initialize(package, params) @package = package @params = params end def execute + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? raise ArgumentError, "Invalid package" unless package.present? # Debian package file are first uploaded to incoming with empty metadata, diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb index 43a4db5bdfc..30480834748 100644 --- a/app/services/packages/debian/extract_changes_metadata_service.rb +++ b/app/services/packages/debian/extract_changes_metadata_service.rb @@ -4,6 +4,7 @@ module Packages module Debian class ExtractChangesMetadataService include Gitlab::Utils::StrongMemoize + include ::Packages::FIPS ExtractionError = Class.new(StandardError) @@ -13,6 +14,8 @@ module Packages end def execute + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + { file_type: file_type, architecture: metadata[:architecture], diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index 33bf877a153..7db27f9234d 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -4,6 +4,7 @@ module Packages module Debian class GenerateDistributionService include Gitlab::Utils::StrongMemoize + include ::Packages::FIPS include ExclusiveLeaseGuard ONE_HOUR = 1.hour.freeze @@ -70,6 +71,8 @@ module Packages end def execute + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + try_obtain_lease do @distribution.transaction do # We consider `apt-get update` can take at most one hour diff --git a/app/services/packages/mark_package_files_for_destruction_service.rb b/app/services/packages/mark_package_files_for_destruction_service.rb index 3672b44b409..e7fdd88843a 100644 --- a/app/services/packages/mark_package_files_for_destruction_service.rb +++ b/app/services/packages/mark_package_files_for_destruction_service.rb @@ -9,18 +9,41 @@ module Packages @package_files = package_files end - def execute - @package_files.each_batch(of: BATCH_SIZE) do |batched_package_files| - batched_package_files.update_all(status: :pending_destruction) + def execute(batch_deadline: nil, batch_size: BATCH_SIZE) + timeout = false + updates_count = 0 + min_batch_size = [batch_size, BATCH_SIZE].min + + @package_files.each_batch(of: min_batch_size) do |batched_package_files| + if batch_deadline && Time.zone.now > batch_deadline + timeout = true + break + end + + updates_count += batched_package_files.update_all(status: :pending_destruction) end - service_response_success('Package files are now pending destruction') + payload = { marked_package_files_count: updates_count } + + return response_error(payload) if timeout + + response_success(payload) end private - def service_response_success(message) - ServiceResponse.success(message: message) + def response_success(payload) + ServiceResponse.success( + message: 'Package files are now pending destruction', + payload: payload + ) + end + + def response_error(payload) + ServiceResponse.error( + message: 'Timeout while marking package files as pending destruction', + payload: payload + ) end end end diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb index 5d7e967ceb0..b464ce4504a 100644 --- a/app/services/packages/pypi/create_package_service.rb +++ b/app/services/packages/pypi/create_package_service.rb @@ -16,6 +16,8 @@ module Packages raise ActiveRecord::RecordInvalid, meta end + params.delete(:md5_digest) if Gitlab::FIPS.enabled? + Packages::Pypi::Metadatum.upsert(meta.attributes) ::Packages::CreatePackageFileService.new(created_package, file_params).execute diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb index 95e99daeb6c..dcee4c5b665 100644 --- a/app/services/pages/delete_service.rb +++ b/app/services/pages/delete_service.rb @@ -21,7 +21,8 @@ module Pages def publish_deleted_event event = Pages::PageDeletedEvent.new(data: { project_id: project.id, - namespace_id: project.namespace_id + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id }) Gitlab::EventStore.publish(event) diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb deleted file mode 100644 index e4b6ad31e33..00000000000 --- a/app/services/pod_logs/base_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module PodLogs - class BaseService < ::BaseService - include ReactiveCaching - include Stepable - - attr_reader :cluster, :namespace, :params - - CACHE_KEY_GET_POD_LOG = 'get_pod_log' - K8S_NAME_MAX_LENGTH = 253 - - self.reactive_cache_work_type = :external_dependency - - def id - cluster.id - end - - def initialize(cluster, namespace, params: {}) - @cluster = cluster - @namespace = namespace - @params = filter_params(params.dup.stringify_keys).to_hash - end - - def execute - with_reactive_cache( - CACHE_KEY_GET_POD_LOG, - namespace, - params - ) do |result| - result - end - end - - def calculate_reactive_cache(request, _namespace, _params) - case request - when CACHE_KEY_GET_POD_LOG - execute_steps - else - exception = StandardError.new('Unknown reactive cache request') - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, request: request) - error(_('Unknown cache key')) - end - end - - private - - def valid_params - %w(pod_name container_name) - end - - def success_return_keys - %i(status logs pod_name container_name pods) - end - - def check_arguments(result) - return error(_('Cluster does not exist')) if cluster.nil? - return error(_('Namespace is empty')) if namespace.blank? - - result[:pod_name] = params['pod_name'].presence - result[:container_name] = params['container_name'].presence - - return error(_('Invalid pod_name')) if result[:pod_name] && !result[:pod_name].is_a?(String) - return error(_('Invalid container_name')) if result[:container_name] && !result[:container_name].is_a?(String) - - success(result) - end - - def get_raw_pods(result) - raise NotImplementedError - end - - def get_pod_names(result) - result[:pods] = result[:raw_pods].map { |p| p[:name] } - - success(result) - end - - def pod_logs(result) - raise NotImplementedError - end - - def filter_return_keys(result) - result.slice(*success_return_keys) - end - - def filter_params(params) - params.slice(*valid_params) - end - end -end diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb deleted file mode 100644 index 28ccace62e5..00000000000 --- a/app/services/pod_logs/elasticsearch_service.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module PodLogs - class ElasticsearchService < PodLogs::BaseService - steps :check_arguments, - :get_raw_pods, - :get_pod_names, - :check_times, - :check_search, - :check_cursor, - :pod_logs, - :filter_return_keys - - self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) } - - private - - def valid_params - super + %w(search start_time end_time cursor) - end - - def success_return_keys - super + %i(cursor) - end - - def get_raw_pods(result) - client = cluster&.elasticsearch_client - return error(_('Unable to connect to Elasticsearch')) unless client - - result[:raw_pods] = ::Gitlab::Elasticsearch::Logs::Pods.new(client).pods(namespace) - - success(result) - rescue Elasticsearch::Transport::Transport::ServerError => e - ::Gitlab::ErrorTracking.track_exception(e) - - error(_('Elasticsearch returned status code: %{status_code}') % { - # ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound" - # there is no method on the exception other than the class name to determine the type of error encountered. - status_code: e.class.name.split('::').last - }) - end - - def check_times(result) - result[:start_time] = params['start_time'] if params.key?('start_time') && Time.iso8601(params['start_time']) - result[:end_time] = params['end_time'] if params.key?('end_time') && Time.iso8601(params['end_time']) - - success(result) - rescue ArgumentError - error(_('Invalid start or end time format')) - end - - def check_search(result) - result[:search] = params['search'] if params.key?('search') - - return error(_('Invalid search parameter')) if result[:search] && !result[:search].is_a?(String) - - success(result) - end - - def check_cursor(result) - result[:cursor] = params['cursor'] if params.key?('cursor') - - return error(_('Invalid cursor parameter')) if result[:cursor] && !result[:cursor].is_a?(String) - - success(result) - end - - def pod_logs(result) - client = cluster&.elasticsearch_client - return error(_('Unable to connect to Elasticsearch')) unless client - - response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs( - namespace, - pod_name: result[:pod_name], - container_name: result[:container_name], - search: result[:search], - start_time: result[:start_time], - end_time: result[:end_time], - cursor: result[:cursor], - chart_above_v2: cluster.elastic_stack_adapter.chart_above_v2? - ) - - result.merge!(response) - - success(result) - rescue Elasticsearch::Transport::Transport::ServerError => e - ::Gitlab::ErrorTracking.track_exception(e) - - error(_('Elasticsearch returned status code: %{status_code}') % { - # ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound" - # there is no method on the exception other than the class name to determine the type of error encountered. - status_code: e.class.name.split('::').last - }) - rescue ::Gitlab::Elasticsearch::Logs::Lines::InvalidCursor - error(_('Invalid cursor value provided')) - end - end -end diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb deleted file mode 100644 index 28b1a179635..00000000000 --- a/app/services/pod_logs/kubernetes_service.rb +++ /dev/null @@ -1,151 +0,0 @@ -# frozen_string_literal: true - -module PodLogs - class KubernetesService < PodLogs::BaseService - LOGS_LIMIT = 500 - REPLACEMENT_CHAR = "\u{FFFD}" - - EncodingHelperError = Class.new(StandardError) - - steps :check_arguments, - :get_raw_pods, - :get_pod_names, - :check_pod_name, - :check_container_name, - :pod_logs, - :encode_logs_to_utf8, - :split_logs, - :filter_return_keys - - self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) } - - private - - def get_raw_pods(result) - result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace).map do |pod| - { - name: pod.metadata.name, - container_names: pod.spec.containers.map(&:name) - } - end - - success(result) - end - - def check_pod_name(result) - # If pod_name is not received as parameter, get the pod logs of the first - # pod of this namespace. - result[:pod_name] ||= result[:pods].first - - unless result[:pod_name] - return error(_('No pods available')) - end - - unless result[:pod_name].length.to_i <= K8S_NAME_MAX_LENGTH - return error(_('pod_name cannot be larger than %{max_length}'\ - ' chars' % { max_length: K8S_NAME_MAX_LENGTH })) - end - - unless result[:pod_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex - return error(_('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')) - end - - unless result[:pods].include?(result[:pod_name]) - return error(_('Pod does not exist')) - end - - success(result) - end - - def check_container_name(result) - pod_details = result[:raw_pods].find { |p| p[:name] == result[:pod_name] } - container_names = pod_details[:container_names] - - # select first container if not specified - result[:container_name] ||= container_names.first - - unless result[:container_name] - return error(_('No containers available')) - end - - unless result[:container_name].length.to_i <= K8S_NAME_MAX_LENGTH - return error(_('container_name cannot be larger than'\ - ' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH })) - end - - unless result[:container_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex - return error(_('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')) - end - - unless container_names.include?(result[:container_name]) - return error(_('Container does not exist')) - end - - success(result) - end - - def pod_logs(result) - result[:logs] = cluster.kubeclient.get_pod_log( - result[:pod_name], - namespace, - container: result[:container_name], - tail_lines: LOGS_LIMIT, - timestamps: true - ).body - - success(result) - rescue Kubeclient::ResourceNotFoundError - error(_('Pod not found')) - rescue Kubeclient::HttpError => e - ::Gitlab::ErrorTracking.track_exception(e) - - error(_('Kubernetes API returned status code: %{error_code}') % { - error_code: e.error_code - }) - end - - # Check https://gitlab.com/gitlab-org/gitlab/issues/34965#note_292261879 - # for more details on why this is necessary. - def encode_logs_to_utf8(result) - return success(result) if result[:logs].nil? - return success(result) if result[:logs].encoding == Encoding::UTF_8 - - result[:logs] = encode_utf8(result[:logs]) - - success(result) - rescue EncodingHelperError - error(_('Unable to convert Kubernetes logs encoding to UTF-8')) - end - - def split_logs(result) - result[:logs] = result[:logs].strip.lines(chomp: true).map do |line| - # message contains a RFC3339Nano timestamp, then a space, then the log line. - # resolution of the nanoseconds can vary, so we split on the first space - values = line.split(' ', 2) - { - timestamp: values[0], - message: values[1], - pod: result[:pod_name] - } - end - - success(result) - end - - def encode_utf8(logs) - utf8_logs = Gitlab::EncodingHelper.encode_utf8(logs.dup, replace: REPLACEMENT_CHAR) - - # Gitlab::EncodingHelper.encode_utf8 can return '' or nil if an exception - # is raised while encoding. We prefer to return an error rather than wrongly - # display blank logs. - no_utf8_logs = logs.present? && utf8_logs.blank? - unexpected_encoding = utf8_logs&.encoding != Encoding::UTF_8 - - if no_utf8_logs || unexpected_encoding - raise EncodingHelperError, 'Could not convert Kubernetes logs to UTF-8' - end - - utf8_logs - end - end -end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index af9c338b59e..03844c2dc7e 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -10,7 +10,7 @@ class PreviewMarkdownService < BaseService text: text, users: users, suggestions: suggestions, - commands: commands.join(' ') + commands: commands.join('<br>') ) end diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index 2ed4346e5ca..9dc957b5be2 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -46,6 +46,7 @@ module Projects update_repository_configuration rename_transferred_documents log_completion + publish_event end def first_ensure_no_registry_tags_are_present @@ -132,6 +133,18 @@ module Projects raise RenameFailedError, error end + + def publish_event + event = Projects::ProjectPathChangedEvent.new(data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id, + old_path: full_path_before, + new_path: full_path_after + }) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb index f7c1240a3ba..b324ea27360 100644 --- a/app/services/projects/blame_service.rb +++ b/app/services/projects/blame_service.rb @@ -12,6 +12,8 @@ module Projects @page = extract_page(params) end + attr_reader :page + def blame Gitlab::Blame.new(blob, commit, range: blame_range) end @@ -19,15 +21,14 @@ module Projects def pagination return unless pagination_enabled? - Kaminari.paginate_array([], total_count: blob_lines_count) + Kaminari.paginate_array([], total_count: blob_lines_count, limit: per_page) + .tap { |pagination| pagination.max_paginates_per(per_page) } .page(page) - .per(per_page) - .limit(per_page) end private - attr_reader :blob, :commit, :page + attr_reader :blob, :commit def blame_range return unless pagination_enabled? diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index c7f284bec9b..9bc8bb428fb 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -129,6 +129,8 @@ module Projects create_readme if @initialize_with_readme create_sast_commit if @initialize_with_sast + + publish_event end def create_project_settings @@ -294,6 +296,16 @@ module Projects params[:topic_list] ||= topic_list if topic_list end + + def publish_event + event = Projects::ProjectCreatedEvent.new(data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id + }) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index bc5be5bdff3..06a44b07f9f 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -132,7 +132,7 @@ module Projects destroy_web_hooks! destroy_project_bots! destroy_ci_records! - destroy_mr_diff_commits! + destroy_mr_diff_relations! # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 @@ -153,23 +153,28 @@ module Projects # cascading deletes may exceed statement timeouts, causing failures. # (see https://gitlab.com/gitlab-org/gitlab/-/issues/346166) # + # Removing merge_request_diff_files records may also cause timeouts, so they + # can be deleted in batches as well. + # # rubocop: disable CodeReuse/ActiveRecord - def destroy_mr_diff_commits! + def destroy_mr_diff_relations! mr_batch_size = 100 delete_batch_size = 1000 project.merge_requests.each_batch(column: :iid, of: mr_batch_size) do |relation_ids| - loop do - inner_query = MergeRequestDiffCommit - .select(:merge_request_diff_id, :relative_order) - .where(merge_request_diff_id: MergeRequestDiff.where(merge_request_id: relation_ids).select(:id)) - .limit(delete_batch_size) - - deleted_rows = MergeRequestDiffCommit - .where('(merge_request_diff_commits.merge_request_diff_id, merge_request_diff_commits.relative_order) IN (?)', inner_query) - .delete_all - - break if deleted_rows == 0 + [MergeRequestDiffCommit, MergeRequestDiffFile].each do |model| + loop do + inner_query = model + .select(:merge_request_diff_id, :relative_order) + .where(merge_request_diff_id: MergeRequestDiff.where(merge_request_id: relation_ids).select(:id)) + .limit(delete_batch_size) + + deleted_rows = model + .where("(#{model.table_name}.merge_request_diff_id, #{model.table_name}.relative_order) IN (?)", inner_query) # rubocop:disable GitlabSecurity/SqlInjection + .delete_all + + break if deleted_rows == 0 + end end end end @@ -212,7 +217,7 @@ module Projects # produces smaller and faster queries to the database. def destroy_web_hooks! project.hooks.find_each do |web_hook| - result = ::WebHooks::DestroyService.new(current_user).sync_destroy(web_hook) + result = ::WebHooks::DestroyService.new(current_user).execute(web_hook) unless result[:status] == :success raise_error(s_('DeleteProject|Failed to remove webhooks. Please try again or contact administrator.')) @@ -263,8 +268,12 @@ module Projects end def publish_project_deleted_event_for(project) - data = { project_id: project.id, namespace_id: project.namespace_id } - event = Projects::ProjectDeletedEvent.new(data: data) + event = Projects::ProjectDeletedEvent.new(data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id + }) + Gitlab::EventStore.publish(event) end end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 3e8d6563709..70a04cd556a 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -5,7 +5,10 @@ module Projects def execute(fork_to_project = nil) forked_project = fork_to_project ? link_existing_project(fork_to_project) : fork_new_project - refresh_forks_count if forked_project&.saved? + if forked_project&.saved? + refresh_forks_count + stream_audit_event(forked_project) + end forked_project end @@ -62,7 +65,10 @@ module Projects # exception. relations_block: -> (project) { build_fork_network_member(project) }, skip_disk_validation: skip_disk_validation, - external_authorization_classification_label: @project.external_authorization_classification_label + 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 } if @project.avatar.present? && @project.avatar.image? @@ -133,5 +139,11 @@ module Projects def target_mr_default_target_self @target_mr_default_target_self ||= params[:mr_default_target_self] end + + def stream_audit_event(forked_project) + # Defined in EE + end end end + +Projects::ForkService.prepend_mod diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb index a836b96cac3..c271b0a2307 100644 --- a/app/services/projects/group_links/update_service.rb +++ b/app/services/projects/group_links/update_service.rb @@ -37,3 +37,5 @@ module Projects end end end + +Projects::GroupLinks::UpdateService.prepend_mod diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb index 98ba5eb3f13..a45b78db383 100644 --- a/app/services/projects/move_deploy_keys_projects_service.rb +++ b/app/services/projects/move_deploy_keys_projects_service.rb @@ -5,6 +5,10 @@ module Projects def execute(source_project, remove_remaining_elements: true) return unless super + # The SHA256 fingerprint should be there, but just in case it isn't + # we want to make sure it's generated. Otherwise we might delete keys. + ensure_sha256_fingerprints + Project.transaction do move_deploy_keys_projects remove_remaining_deploy_keys_projects if remove_remaining_elements @@ -15,6 +19,11 @@ module Projects private + def ensure_sha256_fingerprints + @project.deploy_keys.each(&:ensure_sha256_fingerprint!) + source_project.deploy_keys.each(&:ensure_sha256_fingerprint!) + end + def move_deploy_keys_projects non_existent_deploy_keys_projects.update_all(project_id: @project.id) end @@ -23,7 +32,7 @@ module Projects def non_existent_deploy_keys_projects source_project.deploy_keys_projects .joins(:deploy_key) - .where.not(keys: { fingerprint: @project.deploy_keys.select(:fingerprint) }) + .where.not(keys: { fingerprint_sha256: @project.deploy_keys.select(:fingerprint_sha256) }) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index 7e4e0d7378e..b2166dc84c7 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -18,7 +18,6 @@ module Projects .merge(grafana_integration_params) .merge(prometheus_integration_params) .merge(incident_management_setting_params) - .merge(tracing_setting_params) end def alerting_setting_params @@ -132,15 +131,6 @@ module Projects { incident_management_setting_attributes: attrs } end - - def tracing_setting_params - attr = params[:tracing_setting_attributes] - return {} unless attr - - destroy = attr[:external_url].blank? - - { tracing_setting_attributes: attr.merge(_destroy: destroy) } - end end end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 8ded2516b97..dd1c2b94e18 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -2,21 +2,17 @@ module Projects class UpdatePagesService < BaseService - InvalidStateError = Class.new(StandardError) - WrongUploadedDeploymentSizeError = Class.new(StandardError) - BLOCK_SIZE = 32.kilobytes - PUBLIC_DIR = 'public' - # old deployment can be cached by pages daemon # so we need to give pages daemon some time update cache # 10 minutes is enough, but 30 feels safer OLD_DEPLOYMENTS_DESTRUCTION_DELAY = 30.minutes.freeze - attr_reader :build + attr_reader :build, :deployment_update def initialize(project, build) @project = project @build = build + @deployment_update = ::Gitlab::Pages::DeploymentUpdate.new(project, build) end def execute @@ -29,20 +25,20 @@ module Projects job.run! end - validate_state! - validate_max_size! - validate_public_folder! - validate_max_entries! + return error(deployment_update.errors.first.full_message) unless deployment_update.valid? build.artifacts_file.use_file do |artifacts_path| - create_pages_deployment(artifacts_path, build) - success + deployment = create_pages_deployment(artifacts_path, build) + + break error('The uploaded artifact size does not match the expected value') unless deployment + + if deployment_update.valid? + update_project_pages_deployment(deployment) + success + else + error(deployment_update.errors.first.full_message) + end end - rescue InvalidStateError => e - error(e.message) - rescue WrongUploadedDeploymentSizeError => e - error("Uploading artifacts to pages storage failed") - raise e rescue StandardError => e error(e.message) raise e @@ -53,13 +49,14 @@ module Projects def success @commit_status.success @project.mark_pages_as_deployed + publish_deployed_event super end def error(message) register_failure log_error("Projects::UpdatePagesService: #{message}") - @commit_status.allow_failure = !latest? + @commit_status.allow_failure = !deployment_update.latest? @commit_status.description = message @commit_status.drop(:script_failure) super @@ -75,24 +72,22 @@ module Projects def create_pages_deployment(artifacts_path, build) sha256 = build.job_artifacts_archive.file_sha256 - - deployment = nil File.open(artifacts_path) do |file| - deployment = project.pages_deployments.create!(file: file, - file_count: entries_count, - file_sha256: sha256, - ci_build_id: build.id - ) - - if deployment.size != file.size || deployment.file.size != file.size - raise(WrongUploadedDeploymentSizeError) - end + deployment = project.pages_deployments.create!( + file: file, + file_count: deployment_update.entries_count, + file_sha256: sha256, + ci_build_id: build.id + ) - validate_outdated_sha! + break if deployment.size != file.size || deployment.file.size != file.size - project.update_pages_deployment!(deployment) + deployment end + end + def update_project_pages_deployment(deployment) + project.update_pages_deployment!(deployment) DestroyPagesDeploymentsWorker.perform_in( OLD_DEPLOYMENTS_DESTRUCTION_DELAY, project.id, @@ -108,17 +103,6 @@ module Projects build.artifacts_file.path end - def latest_sha - project.commit(build.ref).try(:sha).to_s - ensure - # Close any file descriptors that were opened and free libgit2 buffers - project.cleanup - end - - def sha - build.sha - end - def register_attempt pages_deployments_total_counter.increment end @@ -135,75 +119,14 @@ module Projects @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed") end - def validate_state! - raise InvalidStateError, 'missing pages artifacts' unless build.artifacts? - raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata? - - validate_outdated_sha! - end - - def validate_outdated_sha! - return if latest? + def publish_deployed_event + event = ::Pages::PageDeployedEvent.new(data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id + }) - # use pipeline_id in case the build is retried - last_deployed_pipeline_id = project.pages_metadatum&.pages_deployment&.ci_build&.pipeline_id - - return unless last_deployed_pipeline_id - return if last_deployed_pipeline_id <= build.pipeline_id - - raise InvalidStateError, 'build SHA is outdated for this ref' - end - - def latest? - # check if sha for the ref is still the most recent one - # this helps in case when multiple deployments happens - sha == latest_sha - end - - def validate_max_size! - if total_size > max_size - raise InvalidStateError, "artifacts for pages are too large: #{total_size}" - end - end - - # Calculate page size after extract - def total_size - @total_size ||= build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true).total_size - end - - def max_size_from_settings - Gitlab::CurrentSettings.max_pages_size.megabytes - end - - def max_size - max_pages_size = max_size_from_settings - - return ::Gitlab::Pages::MAX_SIZE if max_pages_size == 0 - - max_pages_size - end - - def validate_max_entries! - if pages_file_entries_limit > 0 && entries_count > pages_file_entries_limit - raise InvalidStateError, "pages site contains #{entries_count} file entries, while limit is set to #{pages_file_entries_limit}" - end - end - - def validate_public_folder! - raise InvalidStateError, 'Error: The `public/` folder is missing, or not declared in `.gitlab-ci.yml`.' unless total_size > 0 - end - - def entries_count - # we're using the full archive and pages daemon needs to read it - # so we want the total count from entries, not only "public/" directory - # because it better approximates work we need to do before we can serve the site - @entries_count = build.artifacts_metadata_entry("", recursive: true).entries.count - end - - def pages_file_entries_limit - project.actual_limits.pages_file_entries + Gitlab::EventStore.publish(event) end end end - -Projects::UpdatePagesService.prepend_mod_with('Projects::UpdatePagesService') diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index fb810af3e6b..5708421014a 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -10,6 +10,7 @@ module Projects def execute build_topics remove_unallowed_params + mirror_operations_access_level_changes validate! ensure_wiki_exists if enabling_wiki? @@ -82,6 +83,21 @@ module Projects params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project) end + # Temporary code to sync permissions changes as operations access setting + # is being split into monitor_access_level, deployments_access_level, infrastructure_access_level. + # To be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/364240 + def mirror_operations_access_level_changes + return if Feature.enabled?(:split_operations_visibility_permissions, project) + + operations_access_level = params.dig(:project_feature_attributes, :operations_access_level) + + return if operations_access_level.nil? + + [:monitor_access_level, :infrastructure_access_level, :feature_flags_access_level, :environments_access_level].each do |key| + params[:project_feature_attributes][key] = operations_access_level + end + end + def after_update todos_features_changes = %w( issues_access_level diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb index d0d0737fd66..f604a57bcd1 100644 --- a/app/services/protected_branches/api_service.rb +++ b/app/services/protected_branches/api_service.rb @@ -10,8 +10,8 @@ module ProtectedBranches { name: params[:name], allow_force_push: allow_force_push?, - push_access_levels_attributes: AccessLevelParams.new(:push, params).access_levels, - merge_access_levels_attributes: AccessLevelParams.new(:merge, params).access_levels + push_access_levels_attributes: ::ProtectedRefs::AccessLevelParams.new(:push, params).access_levels, + merge_access_levels_attributes: ::ProtectedRefs::AccessLevelParams.new(:merge, params).access_levels } end diff --git a/app/services/protected_branches/access_level_params.rb b/app/services/protected_refs/access_level_params.rb index 6f7a289d9b4..59fc17868d1 100644 --- a/app/services/protected_branches/access_level_params.rb +++ b/app/services/protected_refs/access_level_params.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ProtectedBranches +module ProtectedRefs class AccessLevelParams attr_reader :type, :params @@ -34,4 +34,4 @@ module ProtectedBranches end end -ProtectedBranches::AccessLevelParams.prepend_mod_with('ProtectedBranches::AccessLevelParams') +ProtectedRefs::AccessLevelParams.prepend_mod_with('ProtectedRefs::AccessLevelParams') diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 4bcb15b2d9c..1d7c5d2c80a 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -69,28 +69,32 @@ module QuickActions Gitlab::QuickActions::Extractor.new(self.class.command_definitions) end + # Find users for commands like /assign + # + # eg. /assign me and @jane and jack def extract_users(params) - return [] if params.blank? - - # We are using the a simple User.by_username query here rather than a ReferenceExtractor - # because the needs here are much simpler: we only deal in usernames, and - # want to also handle bare usernames. The ReferenceExtractor also has - # different behaviour, and will return all group members for groups named - # using a user-style reference, which is not in scope here. - # - # nb: underscores may be passed in escaped to protect them from markdown rendering - args = params.split(/\s|,/).select(&:present?).uniq - ['and'] - args.map! { _1.gsub(/\\_/, '_') } - usernames = (args - ['me']).map { _1.delete_prefix('@') } - found = User.by_username(usernames).to_a.select { can?(:read_user, _1) } - found_names = found.map(&:username).map(&:downcase).to_set - missing = args.reject do |arg| - arg == 'me' || found_names.include?(arg.downcase.delete_prefix('@')) - end.map { "'#{_1}'" } - - failed_parse(format(_("Failed to find users for %{missing}"), missing: missing.to_sentence)) if missing.present? - - found + [current_user].select { args.include?('me') } + Gitlab::QuickActions::UsersExtractor + .new(current_user, project: project, group: group, target: quick_action_target, text: params) + .execute + + rescue Gitlab::QuickActions::UsersExtractor::Error => err + extract_users_failed(err) + end + + def extract_users_failed(err) + case err + when Gitlab::QuickActions::UsersExtractor::MissingError + failed_parse(format(_("Failed to find users for %{missing}"), missing: err.message)) + when Gitlab::QuickActions::UsersExtractor::TooManyRefsError + failed_parse(format(_('Too many references. Quick actions are limited to at most %{max_count} user references'), + max_count: err.limit)) + when Gitlab::QuickActions::UsersExtractor::TooManyFoundError + failed_parse(format(_("Too many users found. Quick actions are limited to at most %{max_count} users"), + max_count: err.limit)) + else + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) + failed_parse(_('Something went wrong')) + end end def find_milestones(project, params = {}) diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb index 7a78b323453..447d4d979a6 100644 --- a/app/services/repositories/changelog_service.rb +++ b/app/services/repositories/changelog_service.rb @@ -41,6 +41,9 @@ module Repositories # The `trailer` argument is the Git trailer to use for determining what # commits to include in the changelog. # + # The `config_file` arguments specifies the path to the configuration file as + # stored in the project's Git repository. + # # The `file` arguments specifies the name/path of the file to commit the # changes to. If the file doesn't exist, it's created automatically. # @@ -57,6 +60,7 @@ module Repositories to: branch, date: DateTime.now, trailer: DEFAULT_TRAILER, + config_file: Gitlab::Changelog::Config::DEFAULT_FILE_PATH, file: DEFAULT_FILE, message: "Add changelog for version #{version}" ) @@ -68,13 +72,14 @@ module Repositories @date = date @branch = branch @trailer = trailer + @config_file = config_file @file = file @message = message end # rubocop: enable Metrics/ParameterLists def execute(commit_to_changelog: true) - config = Gitlab::Changelog::Config.from_git(@project, @user) + config = Gitlab::Changelog::Config.from_git(@project, @user, @config_file) from = start_of_commit_range(config) # For every entry we want to only include the merge request that diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 316e6367aa7..eed03ba22fe 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -108,7 +108,7 @@ module ResourceAccessTokens end def create_membership(resource, user, access_level) - resource.add_user(user, access_level, expires_at: params[:expires_at]) + resource.add_member(user, access_level, expires_at: params[:expires_at]) end def log_event(token) diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 28e487aa24d..cea7fc5769e 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -11,7 +11,7 @@ class SearchService def initialize(current_user, params = {}) @current_user = current_user - @params = Gitlab::Search::Params.new(params, detect_abuse: prevent_abusive_searches?) + @params = Gitlab::Search::Params.new(params, detect_abuse: true) end # rubocop: disable CodeReuse/ActiveRecord @@ -91,12 +91,19 @@ class SearchService end end - private - - def prevent_abusive_searches? - Feature.enabled?(:prevent_abusive_searches, current_user) + def level + @level ||= + if project + 'project' + elsif group + 'group' + else + 'global' + end end + private + def page [1, params[:page].to_i].max end diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb index b850592f7ba..89cb14e6fff 100644 --- a/app/services/service_ping/submit_service.rb +++ b/app/services/service_ping/submit_service.rb @@ -52,15 +52,22 @@ module ServicePing ServicePing::DevopsReport.new(response).execute end - return unless Feature.enabled?(:measure_service_ping_metric_collection) - - submit_payload({ metadata: { metrics: metrics_collection_time(usage_data) } }, path: METADATA_PATH) + submit_payload(metadata(usage_data), path: METADATA_PATH) end private attr_reader :payload, :skip_db_write + def metadata(service_ping_payload) + { + metadata: { + uuid: service_ping_payload[:uuid], + metrics: metrics_collection_time(service_ping_payload) + } + } + end + def metrics_collection_time(payload, parents = []) return [] unless payload.is_a?(Hash) diff --git a/app/services/system_notes/incidents_service.rb b/app/services/system_notes/incidents_service.rb index d5da684a2d8..137994baa74 100644 --- a/app/services/system_notes/incidents_service.rb +++ b/app/services/system_notes/incidents_service.rb @@ -15,18 +15,14 @@ module SystemNotes def add_timeline_event(timeline_event) author = timeline_event.author - anchor = "timeline_event_#{timeline_event.id}" - path = url_helpers.project_issues_incident_path(project, noteable, anchor: anchor) - body = "added an [incident timeline event](#{path})" + body = 'added an incident timeline event' create_note(NoteSummary.new(noteable, project, author, body, action: 'timeline_event')) end def edit_timeline_event(timeline_event, author, was_changed:) - anchor = "timeline_event_#{timeline_event.id}" - path = url_helpers.project_issues_incident_path(project, noteable, anchor: anchor) changed_text = CHANGED_TEXT.fetch(was_changed, '') - body = "edited #{changed_text}[incident timeline event](#{path})" + body = "edited #{changed_text}incident timeline event" create_note(NoteSummary.new(noteable, project, author, body, action: 'timeline_event')) end diff --git a/app/services/terraform/states/trigger_destroy_service.rb b/app/services/terraform/states/trigger_destroy_service.rb index 3669bdcf716..3347d429bb4 100644 --- a/app/services/terraform/states/trigger_destroy_service.rb +++ b/app/services/terraform/states/trigger_destroy_service.rb @@ -12,9 +12,11 @@ module Terraform return unauthorized_response unless can_destroy_state? return state_locked_response if state.locked? - state.update!(deleted_at: Time.current) + state.run_after_commit do + Terraform::States::DestroyWorker.perform_async(id) + end - Terraform::States::DestroyWorker.perform_async(state.id) + state.update!(deleted_at: Time.current) ServiceResponse.success end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 20594bec28d..4978f778870 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -34,6 +34,8 @@ module Users return unless lease.try_obtain @user.update_attribute(:last_activity_on, today) + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event('unique_active_user', values: @user.id) end end end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index f2f94563e56..cd2c7402713 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -48,7 +48,6 @@ class WebHookService @force = force @request_options = { timeout: Gitlab.config.gitlab.webhook_timeout, - use_read_total_timeout: true, allow_local_requests: hook.allow_local_requests? } end @@ -70,7 +69,7 @@ class WebHookService start_time = Gitlab::Metrics::System.monotonic_time response = if parsed_url.userinfo.blank? - make_request(hook.url) + make_request(parsed_url.to_s) else make_request_with_auth end @@ -88,17 +87,19 @@ class WebHookService rescue *Gitlab::HTTP::HTTP_ERRORS, Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError => e execution_duration = Gitlab::Metrics::System.monotonic_time - start_time + error_message = e.to_s + log_execution( response: InternalErrorResponse.new, execution_duration: execution_duration, - error_message: e.to_s + error_message: error_message ) Gitlab::AppLogger.error("WebHook Error after #{execution_duration.to_i.seconds}s => #{e}") { status: :error, - message: e.to_s + message: error_message } end @@ -118,7 +119,11 @@ class WebHookService private def parsed_url - @parsed_url ||= URI.parse(hook.url) + @parsed_url ||= URI.parse(hook.interpolated_url) + rescue WebHook::InterpolationError => e + # Behavior-preserving fallback. + Gitlab::ErrorTracking.track_exception(e) + @parsed_url = URI.parse(hook.url) end def make_request(url, basic_auth = false) @@ -131,7 +136,7 @@ class WebHookService end def make_request_with_auth - post_url = hook.url.gsub("#{parsed_url.userinfo}@", '') + post_url = parsed_url.to_s.gsub("#{parsed_url.userinfo}@", '') basic_auth = { username: CGI.unescape(parsed_url.user), password: CGI.unescape(parsed_url.password.presence || '') diff --git a/app/services/web_hooks/destroy_service.rb b/app/services/web_hooks/destroy_service.rb index ecb530f0d2a..54c6c7ea71b 100644 --- a/app/services/web_hooks/destroy_service.rb +++ b/app/services/web_hooks/destroy_service.rb @@ -21,8 +21,5 @@ module WebHooks ServiceResponse.error(message: "Unable to destroy #{web_hook.model_name.human}") end end - - # Backwards compatibility with WebHooks::DestroyWorker - alias_method :sync_destroy, :execute end end diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index 0ee7c41469f..17dcf615830 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -44,6 +44,7 @@ module WebHooks end log_state_change + hook.update_last_failure end rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError raise if raise_lock_error? diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb index 534d220a846..6a773a84225 100644 --- a/app/services/work_items/create_and_link_service.rb +++ b/app/services/work_items/create_and_link_service.rb @@ -25,7 +25,11 @@ module WorkItems work_item = create_result[:work_item] return ::ServiceResponse.success(payload: payload(work_item)) if @link_params.blank? - result = IssueLinks::CreateService.new(work_item, @current_user, @link_params).execute + result = WorkItems::ParentLinks::CreateService.new( + @link_params[:parent_work_item], + @current_user, + { target_issuable: work_item } + ).execute if result[:status] == :success ::ServiceResponse.success(payload: payload(work_item)) diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb index 4203c96e676..ef1d47c560d 100644 --- a/app/services/work_items/create_from_task_service.rb +++ b/app/services/work_items/create_from_task_service.rb @@ -17,7 +17,7 @@ module WorkItems current_user: @current_user, params: @work_item_params.slice(:title, :work_item_type_id), spam_params: @spam_params, - link_params: { target_issuable: @work_item } + link_params: { parent_work_item: @work_item } ).execute if create_and_link_result.error? @@ -27,6 +27,7 @@ module WorkItems replacement_result = TaskListReferenceReplacementService.new( work_item: @work_item, + current_user: @current_user, work_item_reference: create_and_link_result[:work_item].to_reference, line_number_start: @work_item_params[:line_number_start], line_number_end: @work_item_params[:line_number_end], diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index 705735fe403..c2ceb701a2f 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true module WorkItems - class CreateService + class CreateService < Issues::CreateService include ::Services::ReturnServiceResponses + include WidgetableService - def initialize(project:, current_user: nil, params: {}, spam_params:) - @create_service = ::Issues::CreateService.new( + def initialize(project:, current_user: nil, params: {}, spam_params:, widget_params: {}) + super( project: project, current_user: current_user, params: params, spam_params: spam_params, build_service: ::WorkItems::BuildService.new(project: project, current_user: current_user, params: params) ) - @current_user = current_user - @project = project + @widget_params = widget_params end def execute @@ -21,13 +21,24 @@ module WorkItems return error(_('Operation not allowed'), :forbidden) end - work_item = @create_service.execute + work_item = super if work_item.valid? success(payload(work_item)) else error(work_item.errors.full_messages, :unprocessable_entity, pass_back: payload(work_item)) end + rescue ::WorkItems::Widgets::BaseService::WidgetError => e + error(e.message, :unprocessable_entity) + end + + def transaction_create(work_item) + super.tap do |save_result| + if save_result + execute_widgets(work_item: work_item, callback: :after_create_in_transaction, + widget_params: @widget_params) + end + end end private diff --git a/app/services/work_items/parent_links/create_service.rb b/app/services/work_items/parent_links/create_service.rb new file mode 100644 index 00000000000..9940776e367 --- /dev/null +++ b/app/services/work_items/parent_links/create_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module WorkItems + module ParentLinks + class CreateService < IssuableLinks::CreateService + private + + # rubocop: disable CodeReuse/ActiveRecord + def relate_issuables(work_item) + link = WorkItems::ParentLink.find_or_initialize_by(work_item: work_item) + link.work_item_parent = issuable + + if link.changed? && link.save + create_notes(work_item) + end + + link + end + # rubocop: enable CodeReuse/ActiveRecord + + def linkable_issuables(work_items) + @linkable_issuables ||= begin + return [] unless can?(current_user, :admin_parent_link, issuable) + + work_items.select do |work_item| + linkable?(work_item) + end + end + end + + def linkable?(work_item) + can?(current_user, :admin_parent_link, work_item) && + !previous_related_issuables.include?(work_item) + end + + def previous_related_issuables + @related_issues ||= issuable.work_item_children.to_a + end + + def extract_references + params[:issuable_references] + end + + # TODO: Create system notes when work item's parent or children are updated + # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213 + def create_notes(work_item) + # no-op + end + + def target_issuable_type + issuable.issue_type == 'issue' ? 'task' : issuable.issue_type + end + + def issuables_not_found_message + _('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} ID.' % + { issuable: target_issuable_type }) + end + end + end +end diff --git a/app/services/work_items/parent_links/destroy_service.rb b/app/services/work_items/parent_links/destroy_service.rb new file mode 100644 index 00000000000..55870d44db9 --- /dev/null +++ b/app/services/work_items/parent_links/destroy_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module WorkItems + module ParentLinks + class DestroyService < IssuableLinks::DestroyService + attr_reader :link, :current_user, :parent, :child + + def initialize(link, user) + @link = link + @current_user = user + @parent = link.work_item_parent + @child = link.work_item + end + + private + + # TODO: Create system notes when work item's parent or children are removed + # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213 + def create_notes + # no-op + end + + def not_found_message + _('No Work Item Link found') + end + + def permission_to_remove_relation? + can?(current_user, :admin_parent_link, child) && can?(current_user, :admin_parent_link, parent) + end + end + end +end diff --git a/app/services/work_items/task_list_reference_removal_service.rb b/app/services/work_items/task_list_reference_removal_service.rb index e7ec73a96e0..9152580bef0 100644 --- a/app/services/work_items/task_list_reference_removal_service.rb +++ b/app/services/work_items/task_list_reference_removal_service.rb @@ -11,6 +11,7 @@ module WorkItems @line_number_end = line_number_end @lock_version = lock_version @current_user = current_user + @task_reference = /#{Regexp.escape(@task.to_reference)}(?!\d)\+/ end def execute @@ -26,7 +27,9 @@ module WorkItems line_matches_reference = (@line_number_start..@line_number_end).any? do |line_number| markdown_line = source_lines[line_number - 1] - /#{Regexp.escape(@task.to_reference)}(?!\d)/.match?(markdown_line) + if @task_reference.match?(markdown_line) + markdown_line.sub!(@task_reference, @task.title) + end end unless line_matches_reference @@ -35,8 +38,6 @@ module WorkItems ) end - remove_task_lines!(source_lines) - ::WorkItems::UpdateService.new( project: @work_item.project, current_user: @current_user, @@ -51,13 +52,5 @@ module WorkItems rescue ActiveRecord::StaleObjectError ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE) end - - private - - def remove_task_lines!(source_lines) - source_lines.delete_if.each_with_index do |_line, index| - index >= @line_number_start - 1 && index < @line_number_end - end - end end end diff --git a/app/services/work_items/task_list_reference_replacement_service.rb b/app/services/work_items/task_list_reference_replacement_service.rb index 1044a4feb88..b098d67561b 100644 --- a/app/services/work_items/task_list_reference_replacement_service.rb +++ b/app/services/work_items/task_list_reference_replacement_service.rb @@ -4,8 +4,9 @@ module WorkItems class TaskListReferenceReplacementService STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version' - def initialize(work_item:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:) + def initialize(work_item:, current_user:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:) @work_item = work_item + @current_user = current_user @work_item_reference = work_item_reference @line_number_start = line_number_start @line_number_end = line_number_end @@ -32,7 +33,11 @@ module WorkItems source_lines[@line_number_start - 1] = markdown_task_first_line remove_additional_lines!(source_lines) - @work_item.update!(description: source_lines.join("\n")) + ::WorkItems::UpdateService.new( + project: @work_item.project, + current_user: @current_user, + params: { description: source_lines.join("\n"), lock_version: @lock_version } + ).execute(@work_item) ::ServiceResponse.success rescue ActiveRecord::StaleObjectError diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index 0b420881b4b..98818fda263 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -2,16 +2,38 @@ module WorkItems class UpdateService < ::Issues::UpdateService + include WidgetableService + def initialize(project:, current_user: nil, params: {}, spam_params: nil, widget_params: {}) + params[:widget_params] = true if widget_params.present? + super(project: project, current_user: current_user, params: params, spam_params: nil) @widget_params = widget_params end + def execute(work_item) + updated_work_item = super + + if updated_work_item.valid? + success(payload(work_item)) + else + error(updated_work_item.errors.full_messages, :unprocessable_entity, pass_back: payload(updated_work_item)) + end + rescue ::WorkItems::Widgets::BaseService::WidgetError => e + error(e.message, :unprocessable_entity) + end + private def update(work_item) - execute_widgets(work_item: work_item, callback: :update) + execute_widgets(work_item: work_item, callback: :update, widget_params: @widget_params) + + super + end + + def transaction_update(work_item, opts = {}) + execute_widgets(work_item: work_item, callback: :before_update_in_transaction, widget_params: @widget_params) super end @@ -22,10 +44,8 @@ module WorkItems GraphqlTriggers.issuable_title_updated(work_item) if work_item.previous_changes.key?(:title) end - def execute_widgets(work_item:, callback:) - work_item.widgets.each do |widget| - widget.try(callback, params: @widget_params[widget.class.api_symbol]) - end + def payload(work_item) + { work_item: work_item } end end end diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb new file mode 100644 index 00000000000..037733bbed5 --- /dev/null +++ b/app/services/work_items/widgets/base_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class BaseService < ::BaseService + WidgetError = Class.new(StandardError) + + attr_reader :widget, :current_user + + def initialize(widget:, current_user:) + @widget = widget + @current_user = current_user + end + end + end +end diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb new file mode 100644 index 00000000000..e63b6b2ee6c --- /dev/null +++ b/app/services/work_items/widgets/description_service/update_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module DescriptionService + class UpdateService < WorkItems::Widgets::BaseService + def update(params: {}) + return unless params.present? && params[:description] + + widget.work_item.description = params[:description] + end + end + end + end +end diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb new file mode 100644 index 00000000000..085d6c6b0e7 --- /dev/null +++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module HierarchyService + class BaseService < WorkItems::Widgets::BaseService + private + + def handle_hierarchy_changes(params) + return feature_flag_error unless feature_flag_enabled? + return incompatible_args_error if incompatible_args?(params) + + if params.key?(:parent) + update_work_item_parent(params.delete(:parent)) + elsif params.key?(:children) + update_work_item_children(params.delete(:children)) + else + invalid_args_error + end + end + + def update_work_item_parent(parent) + if parent.nil? + remove_parent + else + set_parent(parent) + end + end + + def set_parent(parent) + ::WorkItems::ParentLinks::CreateService + .new(parent, current_user, { target_issuable: widget.work_item }) + .execute + end + + # rubocop: disable CodeReuse/ActiveRecord + def remove_parent + link = ::WorkItems::ParentLink.find_by(work_item: widget.work_item) + return success unless link.present? + + ::WorkItems::ParentLinks::DestroyService.new(link, current_user).execute + end + # rubocop: enable CodeReuse/ActiveRecord + + def update_work_item_children(children) + ::WorkItems::ParentLinks::CreateService + .new(widget.work_item, current_user, { issuable_references: children }) + .execute + end + + def feature_flag_enabled? + Feature.enabled?(:work_items_hierarchy, widget.work_item&.project) + end + + def incompatible_args?(params) + params[:children] && params[:parent] + end + + def feature_flag_error + error(_('`work_items_hierarchy` feature flag disabled for this project')) + end + + def incompatible_args_error + error(_('A Work Item can be a parent or a child, but not both.')) + end + + def invalid_args_error + error(_("One or more arguments are invalid: %{args}." % { args: params.keys.to_sentence } )) + end + + def service_response!(result) + return result unless result[:status] == :error + + raise WidgetError, result[:message] + end + end + end + end +end diff --git a/app/services/work_items/widgets/hierarchy_service/create_service.rb b/app/services/work_items/widgets/hierarchy_service/create_service.rb new file mode 100644 index 00000000000..c97812fade2 --- /dev/null +++ b/app/services/work_items/widgets/hierarchy_service/create_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module HierarchyService + class CreateService < WorkItems::Widgets::HierarchyService::BaseService + def after_create_in_transaction(params:) + return unless params.present? + + service_response!(handle_hierarchy_changes(params)) + end + end + end + end +end diff --git a/app/services/work_items/widgets/hierarchy_service/update_service.rb b/app/services/work_items/widgets/hierarchy_service/update_service.rb new file mode 100644 index 00000000000..48b540f919e --- /dev/null +++ b/app/services/work_items/widgets/hierarchy_service/update_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module HierarchyService + class UpdateService < WorkItems::Widgets::HierarchyService::BaseService + def before_update_in_transaction(params:) + return unless params.present? + + service_response!(handle_hierarchy_changes(params)) + end + end + end + end +end diff --git a/app/services/work_items/widgets/weight_service/update_service.rb b/app/services/work_items/widgets/weight_service/update_service.rb new file mode 100644 index 00000000000..cd62a25358f --- /dev/null +++ b/app/services/work_items/widgets/weight_service/update_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module WeightService + class UpdateService < WorkItems::Widgets::BaseService + def update(params: {}) + return unless params.present? && params[:weight] + + widget.work_item.weight = params[:weight] + end + end + end + end +end diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 370d3cea07c..68eb33d6552 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -1,7 +1,7 @@ - expanded = integration_expanded?('eks_') %section.settings.as-eks.no-animate#js-eks-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Amazon EKS') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml new file mode 100644 index 00000000000..2dcd9d0d2c0 --- /dev/null +++ b/app/views/admin/application_settings/_error_tracking.html.haml @@ -0,0 +1,40 @@ +- expanded = integration_expanded?('error_tracking_') + +%section.settings.as-error-tracking.no-animate#js-error-tracking-settings{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _('GitLab Error Tracking') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p + = _('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' + + .settings-content + + %fieldset + .sub-section + %h4= _('Access Token') + .form-group + .form-text + %p.text-secondary + = s_("ErrorTracking|Access token is %{token_in_code_tag}").html_safe % { token_in_code_tag: content_tag(:code, Gitlab::CurrentSettings.error_tracking_access_token, id: 'error-tracking-access-token') } + .form-inline + = button_to _("Reset error tracking access token"), reset_error_tracking_access_token_admin_application_settings_path, + method: :put, class: 'gl-button btn btn-danger btn-sm', + data: { confirm: _('Are you sure you want to reset the error tracking access token?') } + + = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-error-tracking-settings'), html: { class: 'fieldset-form', id: 'error-tracking-settings' } do |f| + = form_errors(@application_setting, pajamas_alert: true) if expanded + + %fieldset + .sub-section + %h4= _('Configure Error Tracking') + .form-group + = f.gitlab_ui_checkbox_component :error_tracking_enabled, + _('Enable GitLab Error Tracking') + .form-group + = f.label :error_tracking_api_url, _('Opstrace endpoint for Error Tracking integration'), class: 'label-light' + = f.text_field :error_tracking_api_url, class: 'form-control gl-form-input' + + = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 4d0faf69958..f287dba9866 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -1,6 +1,6 @@ %section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('ExternalAuthorization|External authorization') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml index b5a63aa0847..d63eb2bd09d 100644 --- a/app/views/admin/application_settings/_floc.html.haml +++ b/app/views/admin/application_settings/_floc.html.haml @@ -2,7 +2,7 @@ %section.settings.no-animate#js-floc-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('FloC|Federated Learning of Cohorts') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index eb47d177701..cc1e3f968cb 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -2,7 +2,7 @@ %section.settings.no-animate#js-gitpod-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Gitpod') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') @@ -13,7 +13,7 @@ .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| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml index 7f305b9ad9c..f17f63c7df7 100644 --- a/app/views/admin/application_settings/_grafana.html.haml +++ b/app/views/admin/application_settings/_grafana.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_jira_connect_application_key.html.haml b/app/views/admin/application_settings/_jira_connect_application_key.html.haml index e395741dcaa..68a82288573 100644 --- a/app/views/admin/application_settings/_jira_connect_application_key.html.haml +++ b/app/views/admin/application_settings/_jira_connect_application_key.html.haml @@ -2,7 +2,7 @@ %section.settings.no-animate#js-jira_connect-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('JiraConnect|GitLab for Jira App') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') @@ -12,7 +12,7 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-jira-connect-application-id-settings'), html: { class: 'fieldset-form', id: 'jira-connect-application-id-settings' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml index b1dd8a282ec..c0ac924407f 100644 --- a/app/views/admin/application_settings/_kroki.html.haml +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -1,7 +1,7 @@ - expanded = integration_expanded?('kroki_') %section.settings.as-kroki.no-animate#js-kroki-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Kroki') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml index e84fdc56f93..cbe7e1c5bb6 100644 --- a/app/views/admin/application_settings/_mailgun.html.haml +++ b/app/views/admin/application_settings/_mailgun.html.haml @@ -1,7 +1,7 @@ - expanded = integration_expanded?('mailgun_') %section.settings.as-mailgun.no-animate#js-mailgun-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Mailgun') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') @@ -9,7 +9,7 @@ = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') } .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f| - = form_errors(@application_setting) if expanded + = form_errors(@application_setting, pajamas_alert: true) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index c0fabb1d42e..b31576b5c48 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -1,7 +1,7 @@ - if Gitlab.config.packages.enabled %section.settings.as-package.no-animate#js-package-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Package Registry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index 4e37c4c3c98..a7f73edcf69 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-performance-bar-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 57931544e65..8be37ff1dda 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -1,7 +1,7 @@ - expanded = integration_expanded?('plantuml_') %section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('PlantUML') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') @@ -10,7 +10,7 @@ = link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), 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 + = form_errors(@application_setting, pajamas_alert: true) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index 59681c0278e..d8dffd6bc16 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-prometheus-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index 856db32e088..db4d1cb323c 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index ef8d3ccc8ab..40d847f4949 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-check-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .sub-section diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index dad8d5f3fae..156a6bbcfa6 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-mirror-settings') do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml index d962d050ebc..a8e109ce377 100644 --- a/app/views/admin/application_settings/_repository_static_objects.html.haml +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index fccf039533b..2365daa2c70 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -1,3 +1,3 @@ -= form_errors(@application_setting) += form_errors(@application_setting, pajamas_alert: true) #js-signup-form{ data: signup_form_data } diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index e9387ab3f26..8684b909853 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -1,7 +1,7 @@ - expanded = integration_expanded?('snowplow_') %section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded), data: { qa_selector: 'snowplow_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Snowplow') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index a0cbbecb943..43ff2bc02f5 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -3,7 +3,7 @@ %section.settings.as-sourcegraph.no-animate#js-sourcegraph-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sourcegraph') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index bb512940be2..7f3125d91ba 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-spam-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset %h5 diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index c53f63e124b..5703fbb463e 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index 205e14fb8ab..397b47eefaa 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -1,7 +1,7 @@ - expanded = integration_expanded?('hide_third_party_') %section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Customer experience improvement and third-party offers') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') @@ -9,7 +9,7 @@ = _('Control whether to display customer experience improvement content and third-party offers in GitLab.') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f| - = form_errors(@application_setting) if expanded + = form_errors(@application_setting, pajamas_alert: true) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 96dcd7e1111..d35fba7d3b2 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset = render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection') @@ -24,7 +24,7 @@ .form-group = f.label :import_sources, s_('AdminSettings|Import sources'), class: 'label-bold gl-mb-0' %span.form-text.gl-mt-0.gl-mb-3#import-sources-help - = _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub') + = _('Code can be imported from enabled sources during project creation. OmniAuth must be configured for GitHub') = link_to sprite_icon('question-o'), help_page_path("integration/github") , Bitbucket = link_to sprite_icon('question-o'), help_page_path("integration/bitbucket") diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 5816bd42a83..224d9fbe953 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -1,7 +1,7 @@ - parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe = gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f| - = form_errors(@appearance) + = form_errors(@appearance, pajamas_alert: true) .row diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 5e3f0d6f2aa..0adb6cbbcf0 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -1,6 +1,6 @@ - expanded = local_assigns.fetch(:expanded) -%h4 +%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Variables') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index b635e7198cb..f0f7e6868da 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -14,7 +14,7 @@ %section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Continuous Integration and Deployment') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -29,7 +29,7 @@ - if Gitlab.config.registry.enabled %section.settings.as-registry.no-animate#js-registry-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Container Registry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -41,7 +41,7 @@ - if Feature.enabled?(:runner_registration_control) %section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('Runners|Runner registration') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? 'Collapse' : 'Expand' diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 36b9ad189d8..d7559fcd48b 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -4,18 +4,18 @@ %section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Visibility and access controls') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Set default and restrict visibility levels. Configure import sources and git access protocol.') + = _('Set visibility of project contents. Configure import sources and Git access protocols.') .settings-content = render 'visibility_and_access' %section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Account and limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -26,7 +26,7 @@ %section.settings.as-diff-limits.no-animate#js-merge-request-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Diff limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -37,7 +37,7 @@ %section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sign_up_restrictions_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sign-up restrictions') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -48,7 +48,7 @@ %section.settings.as-signin.no-animate#js-signin-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sign-in restrictions') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -60,7 +60,7 @@ %section.settings.as-terms.no-animate#js-terms-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Terms of Service and Privacy Policy') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -74,7 +74,7 @@ %section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Web terminal') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -86,7 +86,7 @@ %section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Web IDE') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -94,7 +94,7 @@ = _('Manage Web IDE features.') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: "js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group @@ -113,8 +113,11 @@ = render_if_exists 'admin/application_settings/slack' -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/417 = render_if_exists 'admin/application_settings/dingtalk_integration' +-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/640 += render_if_exists 'admin/application_settings/feishu_integration' = render 'admin/application_settings/third_party_offers' = render 'admin/application_settings/snowplow' += render 'admin/application_settings/error_tracking' if Feature.enabled?(:gitlab_error_tracking) = render 'admin/application_settings/eks' = render 'admin/application_settings/floc' = render_if_exists 'admin/application_settings/add_license' 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 7cc0ff2c28e..d4476bf838a 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -6,7 +6,7 @@ %section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Metrics - Prometheus') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -17,7 +17,7 @@ %section.settings.as-grafana.no-animate#js-grafana-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Metrics - Grafana') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -30,7 +30,7 @@ %section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'performance_bar_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Profiling - Performance bar') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -44,7 +44,7 @@ %section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } } .settings-header#usage-statistics - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Usage statistics') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -56,7 +56,7 @@ - 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' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sentry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index f3264f733ab..485b3a9828b 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -4,7 +4,7 @@ %section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Performance optimization') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -15,7 +15,7 @@ %section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'ip_limits_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('User and IP rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -27,7 +27,7 @@ %section.settings.as-packages-limits.no-animate#js-packages-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'packages_limits_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Package registry rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -39,7 +39,7 @@ %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Files API Rate Limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -50,7 +50,7 @@ %section.settings.as-search-limits.no-animate#js-search-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Search rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -61,7 +61,7 @@ %section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Deprecated API rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -73,7 +73,7 @@ %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' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Git LFS Rate Limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -85,7 +85,7 @@ %section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('OutboundRequests|Outbound requests') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do @@ -98,7 +98,7 @@ %section.settings.as-protected-paths.no-animate#js-protected-paths-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Protected paths') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -111,7 +111,7 @@ %section.settings.as-issue-limits.no-animate#js-issue-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Issues Rate Limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -123,7 +123,7 @@ %section.settings.as-note-limits.no-animate#js-note-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Notes rate limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -135,7 +135,7 @@ %section.settings.as-users-api-limits.no-animate#js-users-api-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Users API rate limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -147,7 +147,7 @@ %section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Import and export rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -159,7 +159,7 @@ %section.settings.as-pipeline-limits.no-animate#js-pipeline-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Pipeline creation rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 858f96fc0d0..bd92f7d490c 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -4,7 +4,7 @@ %section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Email') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -15,7 +15,7 @@ %section.settings.as-whats-new-page.no-animate#js-whats-new-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _("What's new") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -26,7 +26,7 @@ %section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sign-in and Help page') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -38,7 +38,7 @@ %section.settings.as-pages.no-animate#js-pages-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Pages') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -49,7 +49,7 @@ %section.settings.as-realtime.no-animate#js-realtime-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Polling interval multiplier') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -61,7 +61,7 @@ %section.settings.as-gitaly.no-animate#js-gitaly-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Gitaly timeouts') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -74,7 +74,7 @@ %section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Localization') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -85,7 +85,7 @@ %section.settings.as-sidekiq-job-limits.no-animate#js-sidekiq-job-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sidekiq job size limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index b15fcd93d1a..af9145bf1e7 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -4,7 +4,7 @@ %section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Spam and Anti-bot Protection') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -18,7 +18,7 @@ %section.settings.as-abuse.no-animate#js-abuse-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Abuse reports') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -27,3 +27,5 @@ = link_to _('Learn more.'), help_page_path('user/admin_area/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'abuse' + += render_if_exists 'admin/application_settings/git_abuse_rate_limit' diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 785261b4c7b..12063ea700b 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -4,7 +4,7 @@ %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Default branch') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -15,7 +15,7 @@ %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Repository mirroring') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? 'Collapse' : 'Expand' @@ -27,7 +27,7 @@ %section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'repository_storage_settings_content' } } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Repository storage') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -39,7 +39,7 @@ %section.settings.as-repository-check.no-animate#js-repository-check-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Repository maintenance') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') @@ -54,7 +54,7 @@ %section.settings.as-repository-static-objects.no-animate#js-repository-static-objects-settings{ class: ('expanded' if expanded_by_default?) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('External storage for repository static objects') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index fd73d4c5671..e0926221bcc 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for [:admin, @application], url: @url, html: {role: 'form'} do |f| - = form_errors(application) + = form_errors(application, pajamas_alert: true) = content_tag :div, class: 'form-group row' do .col-sm-2.col-form-label diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index dfd3b87c674..865b60a74b8 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -2,7 +2,7 @@ = render 'preview' = gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| - = form_errors(@broadcast_message) + = form_errors(@broadcast_message, pajamas_alert: true) .form-group.row.mt-4 .col-sm-2.col-form-label diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 43a8d56d584..7bcc97914e5 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -1,40 +1,52 @@ = gitlab_ui_form_for [:admin, @group] do |f| = form_errors(@group, pajamas_alert: true) - = render 'shared/group_form', f: f - = render 'shared/group_form_description', f: f - - = render 'shared/admin/admin_note_form', f: f - - = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group - = render_if_exists 'admin/namespace_plan', f: f - - .form-group.row.group-description-holder - .col-sm-2.col-form-label - = f.label :avatar, _("Group avatar") - .col-sm-10 - = render 'shared/choose_avatar_button', f: f - - = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false - - .form-group.row - .offset-sm-2.col-sm-10 - = render 'shared/allow_request_access', form: f - - = render 'groups/group_admin_settings', f: f - - = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f + .gl-border-b.gl-mb-6 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Naming, visibility') + %p + = _('Update your group name, description, avatar, and visibility.') + = link_to _('Learn more about groups.'), help_page_path('user/group/index') + .col-lg-8 + = render 'shared/group_form', f: f + = render 'shared/group_form_description', f: f + .form-group.gl-form-group{ role: 'group' } + = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label' + = render 'shared/choose_avatar_button', f: f + = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false + + .gl-border-b.gl-pb-3.gl-mb-6 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Permissions and group features') + %p + = _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.') + .col-lg-8 + = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group + = render_if_exists 'admin/namespace_plan', f: f + .form-group.gl-form-group{ role: 'group' } + = render 'shared/allow_request_access', form: f + = render 'groups/group_admin_settings', f: f + = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f + .gl-mb-3 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Admin notes') + .col-lg-8 + = render 'shared/admin/admin_note_form', f: f - if @group.new_record? - .form-group.row - .offset-sm-2.col-sm-10 - = render Pajamas::AlertComponent.new(dismissible: false) do |c| - = c.body do - = render 'shared/group_tips' - .form-actions + = render Pajamas::AlertComponent.new(dismissible: false) do |c| + = c.body do + = render 'shared/group_tips' + .gl-mt-5 = f.submit _('Create group'), class: "gl-button btn btn-confirm" = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel" - else - .form-actions + .gl-mt-5 = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } = link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel" diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index ba7687db9c7..40c4d292e9d 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -1,5 +1,5 @@ = form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f| - = form_errors(@identity) + = form_errors(@identity, pajamas_alert: true) .form-group.row .col-sm-2.col-form-label diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index b4dd92bf15c..2bab802b2c1 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -3,7 +3,6 @@ - page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' -= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right gl-button btn-confirm' - if @identities.present? .table-holder %table.table diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 7a34972dfbf..049f3d61294 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -29,9 +29,9 @@ %h4.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center = sprite_icon('clock', size: 18, css_class: 'pod-icon gl-mr-3') - = _('Uptime') + = _('System started') .data - %h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at) + %h2= time_ago_with_tooltip(Rails.application.config.booted_at) .col-sm .bg-light.info-well.p-3 %h4.page-title.d-flex diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml index 462943263df..869194a21f6 100644 --- a/app/views/admin/topics/_topic.html.haml +++ b/app/views/admin/topics/_topic.html.haml @@ -18,3 +18,4 @@ .controls.gl-flex-shrink-0.gl-ml-5 = link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default' + = link_to _('Remove'), admin_topic_path(topic), aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{topic_name}?") % { topic_name: title }, confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger' diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index cf951ae0265..b255354f2c1 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,52 +1,48 @@ -%fieldset - %legend.gl-border-bottom-0 - = s_('AdminUsers|Access') - .form-group.row - .col-12 - = f.label :projects_limit - = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input' - - .form-group.row - .col-12.gl-pt-0 - = f.label :can_create_group - = f.gitlab_ui_checkbox_component :can_create_group, '' - - .form-group.row - .col-12.gl-pt-0 - = f.label :access_level - - editing_current_user = (current_user == @user) - - = f.gitlab_ui_radio_component :access_level, :regular, - s_('AdminUsers|Regular'), - radio_options: { disabled: editing_current_user }, - help_text: s_('AdminUsers|Regular users have access to their groups and projects.') - - = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user - - - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') - - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user - = f.gitlab_ui_radio_component :access_level, :admin, - s_('AdminUsers|Administrator'), - radio_options: { disabled: editing_current_user }, - help_text: help_text - - - .form-group.row - .col-12.gl-pt-0 - = f.label :external - .hidden{ data: user_internal_regex_data } - .col-12.gl-display-flex.gl-align-items-baseline - = f.gitlab_ui_checkbox_component :external, s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') - %row.hidden#warning_external_automatically_set - = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning - - .form-group.row - - @user.credit_card_validation || @user.build_credit_card_validation - = f.fields_for :credit_card_validation do |ff| - .col-12.gl-pt-0 - = ff.label s_('AdminUsers|Validate user account') - .col-12.gl-display-flex.gl-align-items-baseline - = ff.gitlab_ui_checkbox_component :credit_card_validated_at, - s_('AdminUsers|User is validated and can use free CI minutes on shared runners.'), - help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user.'), - checkbox_options: { checked: @user.credit_card_validated_at.present? } +.gl-border-b.gl-pb-3.gl-mb-6 + .row + .col-lg-4 + %h4.gl-mt-0 + = s_('AdminUsers|Access') + .col-lg-8 + .form-group.gl-form-group{ role: 'group' } + = f.label :projects_limit, class: 'gl-display-block col-form-label' + = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input' + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group') + + %fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_('AdminUsers|Access level') + - editing_current_user = (current_user == @user) + + = f.gitlab_ui_radio_component :access_level, :regular, + s_('AdminUsers|Regular'), + radio_options: { disabled: editing_current_user }, + help_text: s_('AdminUsers|Regular users have access to their groups and projects.') + + = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user + + - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') + - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user + = f.gitlab_ui_radio_component :access_level, :admin, + s_('AdminUsers|Administrator'), + radio_options: { disabled: editing_current_user }, + help_text: help_text + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :external, + s_('AdminUsers|External'), + help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') + .hidden{ data: user_internal_regex_data } + .gl-display-flex.gl-align-items-baseline + %row.hidden#warning_external_automatically_set + = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning + + .form-group.gl-form-group{ role: 'group' } + - @user.credit_card_validation || @user.build_credit_card_validation + = f.fields_for :credit_card_validation do |ff| + = ff.gitlab_ui_checkbox_component :credit_card_validated_at, + s_('AdminUsers|Validate user account'), + help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), + checkbox_options: { checked: @user.credit_card_validated_at.present? } diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml index 10f654e0f71..dce008afb26 100644 --- a/app/views/admin/users/_admin_notes.html.haml +++ b/app/views/admin/users/_admin_notes.html.haml @@ -1,6 +1,9 @@ -%fieldset - %legend.gl-border-bottom-0= _('Admin notes') - .form-group.row - .col-12 - = f.label :note, s_('Admin|Note') - = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' +.gl-mb-3 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Admin notes') + .col-lg-8 + .form-group.gl-form-group{ role: 'group' } + = f.label :note, s_('Admin|Note') + = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 7995bc1b6f4..5ac15694922 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -2,41 +2,42 @@ = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| = form_errors(@user, pajamas_alert: true) - %fieldset - %legend.gl-border-bottom-0= _('Account') - .form-group.row - .col-12 - = f.label "#{:name} (required)" - = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label "#{:username} (required)" - = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label "#{:email} (required)" - = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' + .gl-border-b.gl-pb-3.gl-mb-6 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Account') + .col-lg-8 + .form-group.gl-form-group{ role: 'group' } + = f.label :name, _('Name'), class: 'gl-display-block col-form-label' + = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - if @user.new_record? - %fieldset - %legend.gl-border-bottom-0= _('Password') - .form-group.row - .col-12 - %strong - = _('Reset link will be generated and sent to the user. %{break} User will be forced to set the password on first sign in.').html_safe % { break: '<br />'.html_safe } - - else - %fieldset - %legend.gl-border-bottom-0= _('Password') - .form-group.row - .col-12 - = f.label :password - .col-12 - = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label :password_confirmation - .col-12 - = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :username, _('Username'), class: 'gl-display-block col-form-label' + = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' + + .form-group.gl-form-group{ role: 'group' } + = f.label :email, _('Email'), class: 'gl-display-block col-form-label' + = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' + + .gl-border-b.gl-pb-3.gl-mb-6 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Password') + .col-lg-8 + - if @user.new_record? + = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + = c.body do + = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') + - else + .form-group.gl-form-group{ role: 'group' } + = f.label :password, _('Password'), class: 'gl-display-block col-form-label' + = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation' + = render_if_exists 'shared/password_requirements_list' + .form-group.gl-form-group{ role: 'group' } + = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' + = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' = render partial: 'access_levels', locals: { f: f } @@ -44,30 +45,31 @@ = render_if_exists 'admin/users/limits', f: f - %fieldset - %legend.gl-border-bottom-0= _('Profile') - .form-group.row - .col-12 - = f.label :avatar - .col-12 - = f.file_field :avatar + .gl-border-b.gl-pb-6.gl-mb-6 + .row + .col-lg-4 + %h4.gl-mt-0 + = _('Profile') + .col-lg-8 + .form-group.gl-form-group{ role: 'group' } + = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label' + = f.file_field :avatar + + .form-group.gl-form-group{ role: 'group' } + = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' + = f.text_field :skype, class: 'form-control gl-form-input' + + .form-group.gl-form-group{ role: 'group' } + = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' + = f.text_field :linkedin, class: 'form-control gl-form-input' + + .form-group.gl-form-group{ role: 'group' } + = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' + = f.text_field :twitter, class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label :skype - = f.text_field :skype, class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label :linkedin - = f.text_field :linkedin, class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label :twitter - = f.text_field :twitter, class: 'form-control gl-form-input' - .form-group.row - .col-12 - = f.label :website_url - = f.text_field :website_url, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' + = f.text_field :website_url, class: 'form-control gl-form-input' = render 'admin/users/admin_notes', f: f diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 529692df0b6..ed453b42725 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -27,15 +27,18 @@ = render_if_exists 'admin/users/gma_user_badge' .gl-my-3.gl-display-flex.gl-flex-wrap.gl-my-n2.gl-mx-n2 + .gl-p-2 + #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) } - if @user != current_user - .gl-p-2 - - if impersonation_enabled? && @user.can?(:log_in) + - if impersonation_enabled? && @user.can?(:log_in) + .gl-p-2 = link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-default gl-button", data: { qa_selector: 'impersonate_user_link' } - - if can_force_email_confirmation?(@user) - = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do + - if can_force_email_confirmation?(@user) + .gl-p-2 + = render Pajamas::ButtonComponent.new(variant: :default, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do = _('Confirm user') .gl-p-2 - #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) } + = link_to _('New identity'), new_admin_user_identity_path(@user), class: "btn btn-primary gl-button" = gl_tabs_nav do = gl_tab_link_to _("Account"), admin_user_path(@user) = gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user) diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 2dbafb517be..8c77cb394ba 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -64,6 +64,6 @@ = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { right: true } #js-admin-users-app{ data: admin_users_data_attributes(@users) } - = gl_loading_icon(size: 'lg', css_class: 'gl-my-7') + = render Pajamas::SpinnerComponent.new(size: :lg, class: 'gl-my-7') = paginate_collection @users diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml index 62ae551fee7..4a3062def8c 100644 --- a/app/views/clusters/clusters/_integrations.html.haml +++ b/app/views/clusters/clusters/_integrations.html.haml @@ -14,15 +14,3 @@ s_('ClusterIntegration|Enable Prometheus integration'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } = prometheus_form.submit _('Save changes'), class: 'btn gl-button btn-confirm' - - - if Feature.enabled?(:monitor_logging, @project) - .sub-section.form-group - = gitlab_ui_form_for @elastic_stack_integration, as: :integration, namespace: :elastic_stack, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |elastic_stack_form| - = elastic_stack_form.hidden_field :application_type - .form-group.gl-form-group - - help_text = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Elasticsearch for pod logs.') - - help_link = link_to(_('More information.'), help_page_path("user/clusters/integrations", anchor: "elastic-stack-cluster-integration"), target: '_blank', rel: 'noopener noreferrer') - = elastic_stack_form.gitlab_ui_checkbox_component :enabled, - s_('ClusterIntegration|Enable Elastic Stack integration'), - help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } - = elastic_stack_form.submit _('Save changes'), class: 'btn gl-button btn-confirm' diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml index cedece5ad93..572f2d6d9a2 100644 --- a/app/views/clusters/clusters/_namespace.html.haml +++ b/app/views/clusters/clusters/_namespace.html.haml @@ -1,5 +1,5 @@ - 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, logs, and Web terminals.') +- 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' .js-namespace-prefixed diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index bed671832f3..3e0a8a4f88b 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -2,10 +2,11 @@ - help_path = local_assigns.fetch(:help_path) - label = local_assigns.fetch(:label) - last = local_assigns.fetch(:last, false) -- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-w-half"] +- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-0 gl-flex-grow-1 gl-min-w-0"] - conditional_classes = [("gl-mr-5" unless last)] = link_to help_path, class: classes + conditional_classes do - .svg-content.gl-p-3= image_tag logo_path, alt: label, class: "gl-w-64 gl-h-64" - %span + %span.gl-display-flex.gl-align-items-center.gl-m-3.gl-h-64 + = image_tag logo_path, alt: label, class: "gl-w-15 gl-max-h-full gl-max-w-full" + %span.gl-white-space-normal = label 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 e4128ee22a4..7039ce57bd9 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 @@ -1,14 +1,18 @@ - gke_label = s_('ClusterIntegration|Google GKE') - 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') -- gke_help_path = help_page_path('user/infrastructure/clusters/connect/new_gke_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') -.gl-p-5 +.gl-py-5.gl-md-pl-5.gl-md-pr-5 %h4.gl-mb-5 = create_cluster_label .gl-display-flex = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', locals: { label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg', help_path: eks_help_path } = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', + locals: { label: civo_label, logo_path: 'illustrations/third-party-logos/civo.svg', help_path: civo_help_path } + = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', locals: { label: gke_label, logo_path: 'illustrations/logos/google_gke.svg', help_path: gke_help_path, last: true } diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 7c948260d4b..ed6cecdcc3d 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -6,4 +6,4 @@ .content_list .loading - = gl_loading_icon(size: 'md') + = render Pajamas::SpinnerComponent.new(size: :md) diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index e0b8850357e..0658d548eab 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -39,7 +39,7 @@ %p = _('Public projects are an easy way to allow everyone to have read-only access.') - = link_to "https://docs.gitlab.com/", class: link_classes do + = link_to Gitlab::Saas::doc_url, class: link_classes do .blank-state-icon = custom_icon("lightbulb", size: 50) .blank-state-body.gl-sm-pl-0.gl-pl-6 diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index c932b416b66..8d82116bf10 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -12,7 +12,7 @@ - if todo.author = link_to_author(todo, self_added: todo.self_added?) - else - (removed) + = _('(removed)') %span.title-item.action-name{ data: { qa_selector: "todo_action_name_content" } } = todo_action_name(todo) @@ -45,17 +45,17 @@ .todo-body .todo-note.break-word .md - = first_line_in_markdown(todo, :body, 150, project: todo.project) + = first_line_in_markdown(todo, :body, 150, project: todo.project, group: todo.group) .todo-actions.gl-ml-3 - if todo.pending? = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do = gl_loading_icon(inline: true) - Done + = _('Done') = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do = gl_loading_icon(inline: true) - Undo + = _('Undo') - else = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do = gl_loading_icon(inline: true) - Add a to do + = _('Add a to do') diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 56bd30fac73..498fb08969c 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -1,17 +1,18 @@ = render 'devise/shared/tab_single', tab_title: _('Change your password') .login-box .login-body - = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f| - .devise-errors + = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors gl-pt-5' }) do |f| + .devise-errors.gl-px-5 = render "devise/shared/error_messages", resource: resource = f.hidden_field :reset_password_token - .form-group + .form-group.gl-px-5 = f.label _('New password'), for: "user_password" - = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'} - .form-group + = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top js-password-complexity-validation", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'} + = render_if_exists 'shared/password_requirements_list' + .form-group.gl-px-5 = f.label _('Confirm new password'), for: "user_password_confirmation" = f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true - .clearfix + .clearfix.gl-px-5.gl-pb-5 = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' } .clearfix.prepend-top-20 diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 4cde24f4afa..d06043c1750 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -15,5 +15,5 @@ = check_box_tag :remember_me, '1', false, id: 'remember_me' %span= _('Remember me') - .submit-container.move-submit-down.gl-px-5 + .submit-container.move-submit-down.gl-px-5.gl-pb-5 = submit_tag submit_message, class: "gl-button btn btn-confirm", data: { qa_selector: 'sign_in_button' } diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml new file mode 100644 index 00000000000..6cafcb941b4 --- /dev/null +++ b/app/views/devise/sessions/email_verification.haml @@ -0,0 +1,19 @@ +%div + = render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account') + .login-box.gl-p-5 + .login-body + = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f| + %p + = s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "<strong>#{sanitize(obfuscated_email(resource.email))}</strong>".html_safe } + %div + = f.label :verification_token, s_('IdentityVerification|Verification code') + = f.text_field :verification_token, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: s_('IdentityVerification|Please enter a valid code'), inputmode: 'numeric', maxlength: 6, pattern: '[0-9]{6}' + %p.gl-field-error.gl-mt-2 + = resource.errors.full_messages.to_sentence + .gl-mt-5 + = f.submit s_('IdentityVerification|Verify code'), class: 'gl-button btn btn-confirm' + - unless send_rate_limited?(resource) + = link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0' + %p.gl-p-5.gl-text-secondary + - support_link_start = '<a href="https://about.gitlab.com/support/" target="_blank" rel="noopener noreferrer">'.html_safe + = s_("IdentityVerification|If you've lost access to the email associated to this account or having trouble with the code, %{link_start}here are some other steps you can take.%{link_end}").html_safe % { link_start: support_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index c669f3efec6..9a09f6bee38 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,7 +1,9 @@ - page_title _("Sign in") - content_for :page_specific_javascripts do + = render "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" += render "layouts/google_tag_manager_body" #signin-container - if any_form_based_providers_enabled? diff --git a/app/views/devise/sessions/successful_verification.haml b/app/views/devise/sessions/successful_verification.haml new file mode 100644 index 00000000000..8af80fbdceb --- /dev/null +++ b/app/views/devise/sessions/successful_verification.haml @@ -0,0 +1,11 @@ += content_for :meta_tags do + %meta{ 'http-equiv': 'refresh', content: "3; url=#{@redirect_url}" } +.gl-text-center.gl-max-w-62.gl-mx-auto + .svg-content.svg-80 + = image_tag 'illustrations/success-sm.svg' + %h2 + = s_('IdentityVerification|Verification successful') + %p.gl-pt-2 + - redirect_url_start = '<a href="%{url}"">'.html_safe % { url: @redirect_url } + - redirect_url_end = '</a>'.html_safe + = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment or %{redirect_url_start}click here%{redirect_url_end} to refresh.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 57135c6cdfc..1868cfa06e9 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -45,21 +45,24 @@ = f.label :email, class: 'label-bold' = f.email_field :email, value: @invite_email, - class: 'form-control gl-form-input middle', + class: 'form-control gl-form-input middle js-validate-email', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.') %p.gl-field-hint.text-secondary= _('We recommend a work email address.') + -# 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#password-strength = f.label :password, class: 'label-bold' = f.password_field :password, - class: 'form-control gl-form-input bottom', + class: 'form-control gl-form-input bottom js-password-complexity-validation', data: { qa_selector: 'new_user_password_field' }, autocomplete: 'new-password', required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } %p.gl-field-hint.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 %div - if show_recaptcha_sign_up? diff --git a/app/views/errors/not_found.html.haml b/app/views/errors/not_found.html.haml index 291adbc0ae8..54291cd9abc 100644 --- a/app/views/errors/not_found.html.haml +++ b/app/views/errors/not_found.html.haml @@ -11,5 +11,6 @@ = form_tag search_path, method: :get, class: 'form-inline-flex' do |f| .field = search_field_tag :search, '', placeholder: _('Search for projects, issues, etc.'), class: 'form-control' - = button_tag _('Search'), class: 'gl-button btn btn-sm btn-success', name: nil, type: 'submit' + = render Pajamas::ButtonComponent.new(variant: :confirm, size: :small, type: :submit) do + = _('Search') = render 'errors/footer' diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index e114e4609f8..3090c823677 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -2,14 +2,18 @@ .container = render partial: "shared/errors/graphic_422", formats: :svg - %h3 Sign-in using #{@provider} auth failed + %h3 + = _('Sign-in using %{provider} auth failed') % { provider: @provider } - %p.light.subtitle Sign-in failed because #{@error}. + %p.light.subtitle + = _('Sign-in failed because %{error}.') % { error: @error } - %p Try logging in using your username or email. If you have forgotten your password, try recovering it + %p + = _('Try logging in using your username or email. If you have forgotten your password, try recovering it') - = link_to "Sign in", new_session_path(:user), class: 'gl-button btn primary' - = link_to "Recover password", new_password_path(:user), class: 'gl-button btn secondary' + = link_to _('Sign in'), new_session_path(:user), class: 'gl-button btn primary' + = link_to _('Recover password'), new_password_path(:user), class: 'gl-button btn secondary' %hr - %p.light If none of the options work, try contacting a GitLab administrator. + %p.light + = _('If none of the options work, try contacting a GitLab administrator.') diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 614d9610f31..757c0a836f3 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -6,4 +6,4 @@ .content_list .loading - = gl_loading_icon(size: 'md') + = render Pajamas::SpinnerComponent.new(size: :md) diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml index 0a170ebdb24..687a1fb32bf 100644 --- a/app/views/groups/_group_admin_settings.html.haml +++ b/app/views/groups/_group_admin_settings.html.haml @@ -1,34 +1,29 @@ -.form-group.row - .col-sm-2.col-form-label.pt-0 - = f.label :lfs_enabled, _('Large File Storage') - .col-sm-10 - = f.gitlab_ui_checkbox_component :lfs_enabled, checkbox_options: { checked: @group.lfs_enabled? } do |c| - = c.label do - = _('Allow projects within this group to use Git LFS') - = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index'), class: 'gl-ml-2' - = c.help_text do - = _('This setting can be overridden in each project.') -.form-group.row - .col-sm-2.col-form-label - = f.label s_('ProjectCreationLevel|Allowed to create projects') - .col-sm-10 - = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control' +%fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = _('Large File Storage') + = f.gitlab_ui_checkbox_component :lfs_enabled, checkbox_options: { checked: @group.lfs_enabled? } do |c| + = c.label do + = _('Projects in this group can use Git LFS') + = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index'), class: 'gl-ml-2' + = c.help_text do + = _('This setting can be overridden in each project.') +.form-group.gl-form-group{ role: 'group' } + = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'gl-display-block col-form-label' + = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'gl-form-select custom-select' -.form-group.row - .col-sm-2.col-form-label - = f.label s_('SubgroupCreationlevel|Allowed to create subgroups') - .col-sm-10 - = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, @group.subgroup_creation_level), {}, class: 'form-control' +.form-group.gl-form-group{ role: 'group' } + = f.label s_('SubgroupCreationlevel|Allowed to create subgroups'), class: 'gl-display-block col-form-label' + = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, @group.subgroup_creation_level), {}, class: 'gl-form-select custom-select' -.form-group.row - .col-sm-2.col-form-label.pt-0 - = f.label :require_two_factor_authentication, _('Two-factor authentication') - .col-sm-10 - - label = _("Require all users in this group to set up two-factor authentication") - - help_link = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group'), class: 'gl-ml-2' - = f.gitlab_ui_checkbox_component :require_two_factor_authentication, '%{label}%{help_link}'.html_safe % { label: label, help_link: help_link } -.form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.text_field :two_factor_grace_period, class: 'form-control' - .form-text.text-muted= _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.") +%fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = _('Two-factor authentication') + - label = _("All users in this group must set up two-factor authentication") + - help_link = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group'), class: 'gl-ml-2' + = f.gitlab_ui_checkbox_component :require_two_factor_authentication, '%{label}%{help_link}'.html_safe % { label: label, help_link: help_link } + +.form-group.gl-form-group{ role: 'group' } + = f.label :two_factor_grace_period, _('Two-factor authentication grace period'), class: 'gl-display-block col-form-label' + = f.text_field :two_factor_grace_period, class: 'form-control gl-form-input gl-form-input-sm' + %small.form-text.text-gl-muted + = _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.") 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 654ee70dbee..a9234753aa2 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 @@ -1,4 +1,4 @@ -= form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f| += form_with url: configure_import_bulk_imports_path(namespace_id: params[:parent_id]), class: 'group-form gl-show-field-errors' do |f| .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 .gl-display-flex.gl-align-items-center %h4.gl-display-flex 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 04170c30a20..022777eea27 100644 --- a/app/views/groups/_import_group_from_file_panel.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -2,46 +2,18 @@ - group_path = root_url - group_path << parent.full_path + '/' if parent -= form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f| += form_for '', url: import_gitlab_group_path, namespace: 'import_group', class: 'group-form gl-show-field-errors', multipart: true do |f| .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 %h4 = _('Import group from file') = render Pajamas::AlertComponent.new(variant: :warning, + alert_options: { class: 'gl-mb-5' }, dismissible: false) do |c| = c.body do - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } - link_end = '</a>'.html_safe = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}group migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end } - - .form-group.gl-display-flex.gl-flex-direction-column.gl-mt-5 - = f.label :name, _('New group name'), for: 'import_group_name' - = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name gl-form-input col-xs-12 col-sm-8', - required: true, - title: _('Please fill in a descriptive name for your group.'), - autofocus: true, - id: 'import_group_name' - - .form-group.gl-display-flex.gl-flex-direction-column - = f.label :import_group_path, _('New group URL'), for: 'import_group_path' - .input-group.gl-field-error-anchor.col-xs-12.col-sm-8.gl-p-0 - .group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' } - .input-group-text - %span - = root_url - - if parent - %strong= parent.full_path + '/' - = f.hidden_field :parent_id, value: parent&.id - = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path js-autofill-group-path', - id: 'import_group_path', - required: true, - pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, - title: group_url_error_message, - maxlength: ::Namespace::URL_MAX_LENGTH, - "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - %p.validation-error.gl-field-error.field-validation.hide - = _("Group path is already taken. We've suggested one that is available.") - %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.') - %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...') + = render 'shared/groups/group_name_and_path_fields', f: f .form-group = f.label :file, s_('GroupsNew|Upload file') .gl-font-weight-normal diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index 83211505f36..0527d38159b 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -2,12 +2,12 @@ = render 'shared/group_form', f: f, autofocus: true .row - .form-group.col-sm-12.gl-mb-0 + .form-group.gl-form-group.col-sm-12 %label.label-bold = _('Visibility level') %p = _('Who will be able to see this group?') - = link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank', rel: 'noopener noreferrer' + = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer' = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false - if Gitlab.config.mattermost.enabled diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index ef6410ad439..6063d160fab 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -3,5 +3,5 @@ %p= _("There are no projects shared with this group yet") %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } - .js-groups-list-holder + .js-groups-list-holder{ data: { current_group_visibility: group.visibility } } = gl_loading_icon diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 635a74d8179..d9fef8940eb 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -7,18 +7,16 @@ .col-lg-12 .gl-display-flex.gl-flex-wrap - if can_admin_group_member?(@group) - .gl-w-half.gl-xs-w-full - %h4 - = _('Group members') - %p - = group_member_header_subtext(@group) - .gl-w-half.gl-xs-w-full - .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3 - .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } - .js-invite-members-trigger{ data: { variant: 'confirm', - classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', - trigger_source: 'group-members-page', - display_text: _('Invite members') } } + %h4 + = _('Group members') + %p.gl-w-full.order-md-1 + = group_member_header_subtext(@group) + .gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3 + .js-invite-group-trigger{ data: { classes: 'gl-md-w-auto gl-w-full', display_text: _('Invite a group') } } + .js-invite-members-trigger{ data: { variant: 'confirm', + classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3', + trigger_source: 'group-members-page', + display_text: _('Invite members') } } = render 'groups/invite_groups_modal', group: @group = render 'groups/invite_members_modal', group: @group @@ -28,6 +26,7 @@ members: @members, invited: @invited_members, access_requests: @requesters, + banned: @banned || [], include_relations: @include_relations, search: params[:search_groups]).to_json } } = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml index 6a1e66520b5..a8a52b2aba7 100644 --- a/app/views/groups/harbor/repositories/index.html.haml +++ b/app/views/groups/harbor/repositories/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Harbor Registry") - @content_class = "limit-container-width" unless fluid_layout -#js-harbor-registry-list-group{ data: { endpoint: group_harbor_registries_path(@group), +#js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index b33d1443706..33fcda6129c 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -3,6 +3,8 @@ - page_title _("Merge requests") - if issuables_count_for_state(:merge_requests, :all) == 0 + = render 'shared/issuable/search_bar', type: :merge_requests + = render 'shared/empty_states/merge_requests', project_select_button: true - else .top-area diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml index 5a9d2ca858e..65e797a2e82 100644 --- a/app/views/groups/runners/show.html.haml +++ b/app/views/groups/runners/show.html.haml @@ -1,6 +1,10 @@ - add_to_breadcrumbs _('Runners'), group_runners_path(@group) -- if Feature.enabled?(:group_runner_view_ui) - #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group)} } +- if Feature.enabled?(:group_runner_view_ui, @group) + - title = "##{@runner.id} (#{@runner.short_sha})" + - breadcrumb_title title + - page_title title + + #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} } - else = render 'shared/runners/runner_details', runner: @runner diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index ad0780e869c..527791dfc04 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -1,6 +1,6 @@ -= form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f| += gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' } - = form_errors(@group) + = form_errors(@group, pajamas_alert: true) %fieldset .row @@ -30,6 +30,6 @@ - if @group.avatar? %hr = link_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary' - - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group - = f.submit s_('Groups|Save changes'), class: 'btn gl-button btn-confirm mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } + .form-group.gl-form-group + = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + = f.submit s_('Groups|Save changes'), class: 'btn gl-button btn-confirm js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 319af7be22e..a60ab43f566 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -1,33 +1,33 @@ = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-permissions-form' }, authenticity_token: true do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-permissions-settings' } - = form_errors(@group) + = form_errors(@group, pajamas_alert: true) %fieldset %h5= _('Permissions') - if @group.root? .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :prevent_sharing_groups_outside_hierarchy, - s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups').html_safe % { group: link_to_group(@group) }, + s_('GroupSettings|Members cannot invite groups outside of %{group} and its subgroups').html_safe % { group: link_to_group(@group) }, help_text: prevent_sharing_groups_outside_hierarchy_help_text(@group), checkbox_options: { disabled: !can_change_prevent_sharing_groups_outside_hierarchy?(@group) } .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :share_with_group_lock, - s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: link_to_group(@group) }, + s_('GroupSettings|Projects in %{group} cannot be shared with other groups').html_safe % { group: link_to_group(@group) }, checkbox_options: { disabled: !can_change_share_with_group_lock?(@group) }, help_text: share_with_group_lock_help_text(@group) .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :emails_disabled, - s_('GroupSettings|Disable email notifications'), + s_('GroupSettings|Email notifications are disabled'), checkbox_options: { checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group) }, help_text: s_('GroupSettings|Overrides user notification preferences for all members of the group, subgroups, and projects.') .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :mentions_disabled, - s_('GroupSettings|Disable group mentions'), + s_('GroupSettings|Group mentions are disabled'), checkbox_options: { checked: @group.mentions_disabled? }, - help_text: s_('GroupSettings|Prevents group members from being notified if the group is mentioned.') + help_text: s_('GroupSettings|Group members are not notified if the group is mentioned.') = render 'groups/settings/resource_access_token_creation', f: f, group: @group = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group @@ -48,8 +48,8 @@ %h5= _('Customer relations') .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :crm_enabled, - s_('GroupSettings|Enable customer relations'), + s_('GroupSettings|Customer relations is enabled'), checkbox_options: { checked: @group.crm_enabled? }, - help_text: s_('GroupSettings|Allows creating organizations and contacts and associating them with issues.') + help_text: s_('GroupSettings|Organizations and contacts can be created and associated with issues.') = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } diff --git a/app/views/groups/settings/_project_creation_level.html.haml b/app/views/groups/settings/_project_creation_level.html.haml index 36b714535d2..ef535b8a21c 100644 --- a/app/views/groups/settings/_project_creation_level.html.haml +++ b/app/views/groups/settings/_project_creation_level.html.haml @@ -1,3 +1,3 @@ .form-group - = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'label-bold' + = f.label s_('ProjectCreationLevel|Roles allowed to create projects'), class: 'label-bold' = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control', data: { qa_selector: 'project_creation_level_dropdown' } 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 160f8ae1e07..d304dba3250 100644 --- a/app/views/groups/settings/_resource_access_token_creation.html.haml +++ b/app/views/groups/settings/_resource_access_token_creation.html.haml @@ -6,6 +6,5 @@ - link_start_project = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: project_access_tokens_link } - 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|Allow project and group access token creation'), - checkbox_options: { checked: group.namespace_settings.resource_access_token_creation_allowed?, data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' } }, - help_text: 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 } + 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' } } diff --git a/app/views/groups/settings/_subgroup_creation_level.html.haml b/app/views/groups/settings/_subgroup_creation_level.html.haml index f36ad192bad..d92610367ae 100644 --- a/app/views/groups/settings/_subgroup_creation_level.html.haml +++ b/app/views/groups/settings/_subgroup_creation_level.html.haml @@ -1,3 +1,3 @@ .form-group - = f.label s_('SubgroupCreationLevel|Allowed to create subgroups'), class: 'label-bold' + = f.label s_('SubgroupCreationLevel|Roles allowed to create subgroups'), class: 'label-bold' = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, group.subgroup_creation_level), {}, class: 'form-control' diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index e6c88977cb1..7fe5a7a665b 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -6,7 +6,7 @@ %p= _('Transfer group to another parent group.') = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f| %ul - - learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank" rel="noopener noreferrer">'.html_safe + - learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/repository/index.html#what-happens-when-a-repository-path-changes" target="_blank" rel="noopener noreferrer">'.html_safe - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe } %li= warning_text.html_safe %li= s_('GroupSettings|You can only transfer the group to a group you manage.') diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml index f86bcb24e63..03813f6f8a2 100644 --- a/app/views/groups/settings/_two_factor_auth.html.haml +++ b/app/views/groups/settings/_two_factor_auth.html.haml @@ -8,14 +8,14 @@ .form-group = f.gitlab_ui_checkbox_component :require_two_factor_authentication, - _('Require all users in this group to set up two-factor authentication'), + _('All users in this group must set up two-factor authentication'), checkbox_options: { data: { qa_selector: 'require_2fa_checkbox' } } .form-group - = f.label :two_factor_grace_period, _('Time before enforced') + = f.label :two_factor_grace_period, _('Delay 2FA enforcement (hours)') = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto gl-form-input gl-mb-3' - .form-text.text-muted= _('Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.') + .form-text.text-muted= _("The maximum amount of time users have to set up two-factor authentication before it's enforced.") - unless group.has_parent? .form-group = f.gitlab_ui_checkbox_component :allow_mfa_for_subgroups, - _('Allow subgroups to set up their own two-factor authentication rules'), + _('Subgroups can set up their own two-factor authentication rules'), checkbox_options: { checked: group.namespace_settings&.allow_mfa_for_subgroups } 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 86c0a8d0c52..c294df5ac62 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 @@ -1,5 +1,5 @@ = gitlab_ui_form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f| - = form_errors(group) + = form_errors(group, pajamas_alert: true) %fieldset .form-group .card.auto-devops-card diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index b6f70879d17..59c67197f81 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -1,7 +1,6 @@ .row.gl-mt-3 .col-lg-12 = form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f| - = form_errors(group) %fieldset.builds-feature .form-group = f.label :max_artifacts_size, _('Maximum artifacts size'), class: 'label-bold' diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml index c4ce76c43ec..888419e463a 100644 --- a/app/views/groups/settings/packages_and_registries/show.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml @@ -2,6 +2,5 @@ - page_title _('Packages & Registries') - @content_class = 'limit-container-width' unless fluid_layout -%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, - group_path: @group.full_path, +%section#js-packages-and-registries-settings{ data: { group_path: @group.full_path, group_dependency_proxy_path: group_dependency_proxy_path(@group) } } diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml index 844a5f890a4..cae33820a05 100644 --- a/app/views/groups/settings/repository/_default_branch.html.haml +++ b/app/views/groups/settings/repository/_default_branch.html.haml @@ -8,7 +8,7 @@ = s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.') .settings-content = gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| - = form_errors(@group) + = form_errors(@group, pajamas_alert: true) - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>" %fieldset diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 3614d854036..d8da77dc5cc 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -3,8 +3,8 @@ - @skip_current_level_breadcrumb = true - add_page_specific_style 'page_bundles/group' -- if show_thanks_for_purchase_banner? - = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i +- if show_thanks_for_purchase_alert? + = render_if_exists 'shared/thanks_for_purchase_alert', plan_title: plan_title, quantity: params[:purchased_quantity].to_i = render_if_exists 'shared/qrtly_reconciliation_alert', group: @group = render_if_exists 'shared/free_user_cap_alert', source: @group @@ -25,7 +25,7 @@ = render partial: 'flash_messages' -= render_if_exists 'trials/banner', namespace: @group += render_if_exists 'trials/alert', namespace: @group = render 'groups/home_panel' diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index e69ca4663b4..b4b73e9e790 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -6,4 +6,4 @@ = sprite_icon('bitbucket', css_class: 'gl-mr-2') = _('Import projects from Bitbucket') -= render 'import/githubish_status', provider: '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 0d87cf66814..292dd9d071c 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -10,7 +10,7 @@ %p = _('Enter in your Bitbucket Server URL and personal access token below') -= form_tag configure_import_bitbucket_server_path, method: :post do += form_tag configure_import_bitbucket_server_path(namespace_id: params[:namespace_id]), method: :post do .form-group.row = label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2' .col-md-4 diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 05b42767668..7e0c7b3dd74 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -5,4 +5,4 @@ = sprite_icon('bitbucket', css_class: 'gl-mr-2') = _('Import projects from Bitbucket Server') -= render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, extra_data: { reconfigure_path: configure_import_bitbucket_server_path } += 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/status.html.haml b/app/views/import/bulk_imports/status.html.haml index 71866bab30b..1c8de23f28f 100644 --- a/app/views/import/bulk_imports/status.html.haml +++ b/app/views/import/bulk_imports/status.html.haml @@ -4,6 +4,7 @@ #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), available_namespaces_path: import_available_namespaces_path(format: :json), + default_target_namespace: @namespace&.id, create_bulk_import_path: import_bulk_imports_path(format: :json), jobs_path: realtime_changes_import_bulk_imports_path(format: :json), source_url: @source_url, diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml index b74262f2567..bd0e4b51a63 100644 --- a/app/views/import/fogbugz/new.html.haml +++ b/app/views/import/fogbugz/new.html.haml @@ -8,7 +8,7 @@ = _('Import projects from FogBugz') %hr -= form_tag callback_import_fogbugz_path do += form_tag callback_import_fogbugz_path(namespace_id: params[:namespace_id]) do %p = _("To get started you enter your FogBugz URL and login information below. In the next steps, you'll be able to map users and select the projects you want to import.") .form-group.row diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 5caee78b9c4..28836055e0e 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -8,7 +8,7 @@ = _('Import projects from FogBugz') %hr -= form_tag create_user_map_import_fogbugz_path do += form_tag create_user_map_import_fogbugz_path(namespace_id: params[:namespace_id]) do %p = _("Customize how FogBugz email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import.") %p diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index 3e303d3163d..fb05e8e9724 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -8,4 +8,4 @@ - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path) = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize } %hr -= render 'import/githubish_status', provider: 'fogbugz', filterable: false += render 'import/githubish_status', provider: 'fogbugz', filterable: false, default_namespace: @namespace diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index 13aaa41de9b..d2d49266350 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -3,4 +3,4 @@ = sprite_icon('heart', css_class: 'gl-vertical-align-middle') = _('Import projects from GitLab.com') -= render 'import/githubish_status', provider: 'gitlab', filterable: false += render 'import/githubish_status', provider: 'gitlab', filterable: false, default_namespace: @namespace diff --git a/app/views/layouts/_bizible.html.haml b/app/views/layouts/_bizible.html.haml index a2b28c138e5..8d173a7ab61 100644 --- a/app/views/layouts/_bizible.html.haml +++ b/app/views/layouts/_bizible.html.haml @@ -1,6 +1,5 @@ - if bizible_enabled? <!-- Bizible --> - = javascript_include_tag "https://cdn.bizible.com/scripts/bizible.js" = javascript_tag nonce: content_security_policy_nonce do :plain const bizibleScript = document.createElement('script'); diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 21cccb86398..ab4b3cf6afd 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,6 +1,7 @@ -# We currently only support `alert`, `notice`, `success`, 'toast', and 'raw' - icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'} - type_to_variant = {'alert' => 'danger', 'notice' => 'info', 'success' => 'success'} +- closable = %w[alert notice success] .flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } } - flash.each do |key, value| - if key == 'toast' && value @@ -13,6 +14,6 @@ %div{ class: "flash-#{key} mb-2", data: { testid: "alert-#{type_to_variant[key]}" } } = sprite_icon(icons[key], css_class: 'align-middle mr-1') unless icons[key].nil? %span= value - - if %w(alert notice success).include?(key) + - if closable.include?(key) %div{ class: "close-icon-wrapper js-close-icon" } = sprite_icon('close', css_class: 'close-icon gl-vertical-align-baseline!') diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml index 3c62180214b..28118cf4aaa 100644 --- a/app/views/layouts/_header_search.html.haml +++ b/app/views/layouts/_header_search.html.haml @@ -25,3 +25,7 @@ -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb - if ENV['RAILS_ENV'] == 'test' %noscript= button_tag 'Search' + %kbd.gl-absolute.gl-right-3.gl-top-0.keyboard-shortcut-helper.gl-z-index-1.has-tooltip{ data: { html: 'true', + placement: 'bottom' }, + title: html_escape(s_('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe } } + = '/' diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index b7cf7b7468f..59d4c81358d 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,7 +17,6 @@ = dispensable_render "shared/service_ping_consent" = dispensable_render_if_exists "layouts/header/ee_subscribable_banner" = dispensable_render_if_exists "layouts/header/seat_count_alert" - = dispensable_render_if_exists "shared/namespace_storage_limit_alert" = dispensable_render_if_exists "shared/namespace_user_cap_reached_alert" = dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert" = yield :page_level_alert diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index cee5c1b6b69..cb1a2a8c690 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,7 +1,7 @@ !!! 5 %html.devise-layout-html{ class: system_message_class } = render "layouts/head", { startup_filename: 'signin' } - %body.ui-indigo.login-page.application.navless{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } + %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } = header_message = render "layouts/init_client_detection_flags" .page-wrap diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index b5649be8917..cadba3f91e9 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,7 +1,7 @@ !!! 5 %html.devise-layout-html{ lang: "en", class: system_message_class } = render "layouts/head" - %body.ui-indigo.login-page.application.navless{ class: "#{client_class_list}" } + %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}" } = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/empty" diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 940724e0e4a..1c2ab8cf008 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -3,11 +3,11 @@ - header_title group_title(@group) unless header_title - nav "group" - display_subscription_banner! -- display_namespace_storage_limit_alert! - @left_sidebar = true - content_for :flash_message do = render "layouts/header/storage_enforcement_banner", namespace: @group + = dispensable_render_if_exists "shared/namespace_storage_limit_alert" - content_for :page_specific_javascripts do - if current_user diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml index 580b8e67a3c..8452f0d9976 100644 --- a/app/views/layouts/mailer.html.haml +++ b/app/views/layouts/mailer.html.haml @@ -3,8 +3,6 @@ %td %img.footer-logo{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png') } %div - - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, class: 'mng-notif-link') - - help_link = link_to(_("Help"), help_url, class: 'help-link') - = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } + = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html) = render 'layouts/mailer' diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 1a06ea68bcd..24553734e49 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -3,7 +3,7 @@ <%= yield -%> -- <%# signature marker %> -<%= _("You're receiving this email because of your account on %{host}.") % { host: Gitlab.config.gitlab.host } %> +<%= notification_reason_text %> <%= render_if_exists 'layouts/mailer/additional_text' %> <%= text_footer_message %> diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index fde4e74fb7a..98d6af28cf5 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -8,7 +8,7 @@ - if defined?(@left_sidebar) = button_tag class: 'toggle-mobile-nav', data: { qa_selector: 'toggle_mobile_nav_button' }, type: 'button' do %span.sr-only= _("Open sidebar") - = sprite_icon('hamburger', size: 18) + = sprite_icon('sidebar', size: 18) .breadcrumbs-links{ data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } } %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list - unless hide_top_links diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index d9f16a89fbc..d05b6951fbf 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -26,16 +26,7 @@ - else #{link_to _("View it on GitLab"), @target_url}. %br - -# Don't link the host in the line below, one link in the email is easier to quickly click than two. - = notification_reason_text(@reason) - If you'd like to receive fewer emails, you can - - if @labels_url - adjust your #{link_to 'label subscriptions', @labels_url}. - - else - - if @unsubscribe_url - = link_to "unsubscribe", @unsubscribe_url - from this thread or - adjust your notification settings. + = notification_reason_text(reason: @reason, show_manage_notifications_link: !@labels_url, show_help_link: true, manage_label_subscriptions_url: @labels_url, unsubscribe_url: @unsubscribe_url, format: :html) = email_action @target_url diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb index 49ad0b5abc5..4eae96dc376 100644 --- a/app/views/layouts/notify.text.erb +++ b/app/views/layouts/notify.text.erb @@ -11,7 +11,7 @@ <% end -%> <% end -%> -<%= notification_reason_text(@reason) %> +<%= notification_reason_text(reason: @reason) %> <%= render_if_exists 'layouts/mailer/additional_text' %> <%= text_footer_message -%> diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index a54e0351d2f..86b4c4eabe3 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -4,10 +4,13 @@ - nav "project" - page_itemtype 'http://schema.org/SoftwareSourceCode' - display_subscription_banner! -- display_namespace_storage_limit_alert! - @left_sidebar = true - @content_class = [@content_class, project_classes(@project)].compact.join(" ") +- content_for :flash_message do + = render "layouts/header/storage_enforcement_banner", namespace: @project.namespace + = dispensable_render_if_exists "shared/namespace_storage_limit_alert" + - content_for :project_javascripts do - project = @target_project || @project - if current_user diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml index f768fba84ca..4d0bb36d4b5 100644 --- a/app/views/layouts/signup_onboarding.html.haml +++ b/app/views/layouts/signup_onboarding.html.haml @@ -2,7 +2,7 @@ %html.devise-layout-html.navless{ class: system_message_class } - add_page_specific_style 'page_bundles/signup' = render "layouts/head" - %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } + %body.signup-page{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } = render "layouts/header/logo_with_title" = render "layouts/init_client_detection_flags" .page-wrap diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml index afed3c95130..fc4a063f5a9 100644 --- a/app/views/notify/_failed_builds.html.haml +++ b/app/views/notify/_failed_builds.html.haml @@ -1,12 +1,10 @@ %tr %td{ colspan: 2, style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.4; padding: 0 8px 16px; text-align: center;" } - had - = failed.size - failed - #{'job'.pluralize(failed.size)}. + = n_('had %{count} failed job', 'had %{count} failed jobs', failed.size).html_safe % { count: failed.size } + %tr.table-warning %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" } - Failed jobs + = n_('Failed job', 'Failed jobs', failed.size) %tr.section %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" } %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" } diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml index 4ab40ff2659..54e51e07c86 100644 --- a/app/views/notify/_reassigned_issuable_email.html.haml +++ b/app/views/notify/_reassigned_issuable_email.html.haml @@ -1,10 +1,7 @@ +- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : s_('Unassigned')) + %p - Assignee changed - if previous_assignees.any? - from - %strong= sanitize_name(previous_assignees.map(&:name).to_sentence) - to - - if issuable.assignees.any? - %strong= sanitize_name(issuable.assignee_list) + = html_escape(s_('Notify|Assignee changed from %{fromNames} to %{toNames}').html_safe % { fromNames: content_tag(:strong, sanitize_name(previous_assignees.map(&:name).to_sentence)), toNames: to_names }) - else - %strong Unassigned + = html_escape(s_('Notify|Assignee changed to %{toNames}').html_safe % { toNames: to_names}) diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml index 80a0de255be..41d3a63845f 100644 --- a/app/views/notify/_relabeled_issuable_email.html.haml +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -1,3 +1,2 @@ %p - #{'Label'.pluralize(@label_names.size)} added: - %em= @label_names.to_sentence + = html_escape(n_('Label added: %{labels}', 'Labels added: %{labels}', @label_names.size).html_safe % { labels: content_tag(:em, @label_names.to_sentence).html_safe }) diff --git a/app/views/notify/_removal_notification.html.haml b/app/views/notify/_removal_notification.html.haml index 590e0d569aa..1c3c84e0f41 100644 --- a/app/views/notify/_removal_notification.html.haml +++ b/app/views/notify/_removal_notification.html.haml @@ -1,9 +1,5 @@ -- if @domain.remove_at - %p - Unless you verify your domain by - %strong= @domain.remove_at.strftime('%F %T,') - it will be removed from your GitLab project. -- else - %p - 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. +%p + - if @domain.remove_at + = s_('Notify|Unless you verify your domain by %{time_start}%{time}%{time_end} it will be removed from your GitLab project.').html_safe % { time_start: '<strong>'.html_safe, time_end: '</strong>'.html_safe, time: @domain.remove_at.strftime('%F %T,') } + - else + = s_('Notify|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/_successful_pipeline.html.haml b/app/views/notify/_successful_pipeline.html.haml index 231df2e9206..e77db14a9c5 100644 --- a/app/views/notify/_successful_pipeline.html.haml +++ b/app/views/notify/_successful_pipeline.html.haml @@ -45,12 +45,12 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } - %a{ href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;" } - = @pipeline.short_sha + - commit_link = content_tag(:a, @pipeline.short_sha, href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe - if @merge_request - in - %a{ href: merge_request_url(@merge_request), style: "color:#3777b0;text-decoration:none;" } - = @merge_request.to_reference + - mr_link = content_tag(:a, @merge_request.to_reference, href: merge_request_url(@merge_request), style: "color:#3777b0;text-decoration:none;").html_safe + = s_('Notify|%{commit_link} in %{mr_link}').html_safe % { commit_link: commit_link, mr_link: mr_link } + - else + = commit_link .commit{ style: "color:#5c5c5c;font-weight:300;" } = @pipeline.git_commit_message.truncate(50) - commit = @pipeline.commit @@ -94,25 +94,22 @@ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } - Pipeline - %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" } - = "\##{@pipeline.id}" - triggered by + - common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;" + - pipeline_link = content_tag(:a, "\##{@pipeline.id}", href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe + %td{ style: "#{common_style} font-weight:500;vertical-align:baseline;" } + = s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link } - if @pipeline.user - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" } + %td{ style: "#{common_style} font-weight:500;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" } %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } + %td{ style: "#{common_style} vertical-align:baseline;" } %a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" } = @pipeline.user.name - else %td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" } - API + = _('API') + %tr %td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" } - job_count = @pipeline.total_size - stage_count = @pipeline.stages_count - successfully completed - #{job_count} #{'job'.pluralize(job_count)} - in - #{stage_count} #{'stage'.pluralize(stage_count)}. + = s_('Notify|successfully completed %{jobs} in %{stages}.').html_safe % { jobs: n_('%d job', '%d jobs', job_count) % job_count, stages: n_('%d stage', '%d stages', stage_count) % stage_count } diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml index c51fe02370d..28da1182d49 100644 --- a/app/views/notify/approved_merge_request_email.html.haml +++ b/app/views/notify/approved_merge_request_email.html.haml @@ -152,6 +152,4 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } %img{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png'), style: "display:block;margin:0 auto 1em;", width: "90" }/ %div - - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;") - - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;") - = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } + = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html) diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml index 72bcfbdf3af..bdf2a1136d3 100644 --- a/app/views/notify/autodevops_disabled_email.html.haml +++ b/app/views/notify/autodevops_disabled_email.html.haml @@ -4,46 +4,40 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; vertical-align: middle; color: #ffffff; text-align: center;" } - Auto DevOps pipeline was disabled for #{@project.name} + = s_('Notify|Auto DevOps pipeline was disabled for %{project}') % { project: @project.name } %tr.pre-section %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.7; padding: 16px 8px 0;" } - The Auto DevOps pipeline failed for pipeline - %a{ href: pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration:none;" } - = "\##{@pipeline.iid}" - and has been disabled for - %a{ href: project_url(@project), style: "color: #1b69b6; text-decoration: none;" } - = @project.name + "." - In order to use the Auto DevOps pipeline with your project, please review the - %a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: "color:#1b69b6;text-decoration:none;" } currently supported languages, - adjust your project accordingly, and turn on the Auto DevOps pipeline within your - %a{ href: project_settings_ci_cd_url(@project), style: "color: #1b69b6; text-decoration: none;" } - CI/CD project settings. + - link_style = "color: #1b69b6; text-decoration:none;" + - pipeline_link = link_to("\##{@pipeline.iid}", pipeline_url(@pipeline), style: link_style).html_safe + - project_link = link_to(@project.name, project_url(@project), style: link_style).html_safe + - supported_langs_link = link_to(s_('Notify|currently supported languages'), 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: link_style ).html_safe + - settings_link = link_to(s_('Notify|CI/CD project settings'), project_settings_ci_cd_url(@project), style: link_style).html_safe + = s_('Notify|The Auto DevOps pipeline failed for pipeline %{pipeline_link} and has been disabled for %{project_link}. In order to use the Auto DevOps pipeline with your project, please review the %{supported_langs_link}, adjust your project accordingly, and turn on the Auto DevOps pipeline within your %{settings_link}.').html_safe % { pipeline_link: pipeline_link, project_link: project_link, supported_langs_link: supported_langs_link, settings_link: settings_link } %tr.pre-section %td{ style: 'text-align: center;border-bottom:1px solid #ededed' } %a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/', style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } %button{ type: 'button', style: 'border-color: #dfdfdf; border-style: solid; border-width: 1px; border-radius: 4px; font-size: 14px; padding: 8px 16px; background-color:#fff; margin: 8px 0; cursor: pointer;' } - Learn more about Auto DevOps + = s_('Notify|Learn more about Auto DevOps') %tr.pre-section %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.4; padding: 16px 8px; text-align: center;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } %tbody %tr - %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size:14px; font-weight:500;line-height: 1.4; vertical-align: baseline;" } - Pipeline - %a{ href: pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration: none;" } - = "\##{@pipeline.id}" - triggered by + - common_style = "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;line-height: 1.4;" + - pipeline_link = link_to("\##{@pipeline.id}", pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration: none;").html_safe + %td{ style: "#{common_style} font-size:14px;font-weight:500;vertical-align:baseline;" } + = s_("Notify|Pipeline %{pipeline_link} triggered by").html_safe % { pipeline_link: pipeline_link } - if @pipeline.user - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 15px; line-height: 1.4; vertical-align: middle; padding-right: 8px; padding-left:8px", width: "24" } - %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display: block; border-radius: 12px; margin: -2px 0;", width: "24", alt: "" }/ - %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 14px; font-weight: 500; line-height: 1.4; vertical-align: baseline;" } + %td{ style: "#{common_style} font-size: 15px; vertical-align: middle; padding-right: 8px; padding-left:8px", width: "24" } + %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display: block; border-radius: 12px; margin: -2px 0;", width: "24", alt: "" } + %td{ style: "#{common_style} font-size: 14px; font-weight: 500; vertical-align: baseline;" } %a.muted{ href: user_url(@pipeline.user), style: "color: #333333; text-decoration: none;" } = @pipeline.user.name - else %td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" } - API + = _('API') = render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed diff --git a/app/views/notify/changed_milestone_email.html.haml b/app/views/notify/changed_milestone_email.html.haml index 01d27cac36b..bfc9d65d1c2 100644 --- a/app/views/notify/changed_milestone_email.html.haml +++ b/app/views/notify/changed_milestone_email.html.haml @@ -1,5 +1,5 @@ %p - Milestone changed to - %strong= link_to(@milestone.name, @milestone_url) + - milestone_link = link_to(@milestone.name, @milestone_url) + = s_('Notify|Milestone changed to %{milestone}').html_safe % { milestone: content_tag(:strong, milestone_link).html_safe } - if date_range = milestone_date_range(@milestone) = "(#{date_range})" diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml index 749584a7044..bd98003a804 100644 --- a/app/views/notify/closed_merge_request_email.html.haml +++ b/app/views/notify/closed_merge_request_email.html.haml @@ -1,3 +1,4 @@ %p - Merge request #{merge_request_reference_link(@merge_request)} - was closed by #{sanitize_name(@updated_by.name)} + - mr_link = merge_request_reference_link(@merge_request) + - closed_by = sanitize_name(@updated_by.name) + = s_('Notify|Merge request %{mr_link} was closed by %{closed_by}').html_safe % { mr_link: mr_link, closed_by: closed_by } diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml index eeef66d353d..98d3daf2107 100644 --- a/app/views/notify/member_access_denied_email.html.haml +++ b/app/views/notify/member_access_denied_email.html.haml @@ -1,12 +1,7 @@ %tr %td.text-content %p - Your request to join the - - - if @source_hidden - #{content_tag :span, 'Hidden', class: :highlight} - - else - #{link_to member_source.human_name, member_source.web_url, class: :highlight} - - #{member_source.model_name.singular} has been #{content_tag :span, 'denied', class: :highlight}. + - target_to_join = @source_hidden ? content_tag(:span, _('Hidden'), class: :highlight) : link_to(member_source.human_name, member_source.web_url, class: :highlight) + - denied_tag = content_tag :span, _('denied'), class: :highlight + = s_('Notify|Your request to join the %{target_to_join} %{target_type} has been %{denied_tag}.').html_safe % { target_to_join: target_to_join, target_type: member_source.model_name.singular, denied_tag: denied_tag } diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml index 550d386c843..f6b517d6e34 100644 --- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml +++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml @@ -148,6 +148,4 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } %img{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png'), style: "display:block;margin:0 auto 1em;", width: "90" } %div - - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;") - - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;") - = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } + = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html) diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml index ae58ccd3995..0b8fbe14228 100644 --- a/app/views/notify/unapproved_merge_request_email.html.haml +++ b/app/views/notify/unapproved_merge_request_email.html.haml @@ -151,6 +151,4 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } %img{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png'), style: "display:block;margin:0 auto 1em;", width: "90" }/ %div - - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;") - - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;") - = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } + = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html) diff --git a/app/views/notify/user_auto_banned_email.html.haml b/app/views/notify/user_auto_banned_email.html.haml index d88c06526eb..8c33cd7299d 100644 --- a/app/views/notify/user_auto_banned_email.html.haml +++ b/app/views/notify/user_auto_banned_email.html.haml @@ -2,7 +2,7 @@ - link_end = '</a>'.html_safe = email_default_heading(_("We've detected some unusual activity")) %p - = _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes } + = _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope } %p = _('If this is a mistake, you can %{link_start}unban them%{link_end}.').html_safe % { link_start: link_start % { url: admin_users_url(filter: 'banned') }, link_end: link_end } %p diff --git a/app/views/notify/user_auto_banned_email.text.erb b/app/views/notify/user_auto_banned_email.text.erb index 0469ee9788c..336973c2e42 100644 --- a/app/views/notify/user_auto_banned_email.text.erb +++ b/app/views/notify/user_auto_banned_email.text.erb @@ -1,6 +1,6 @@ <%= _("We've detected some unusual activity") %> -<%= _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes } %> +<%= _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope } %> <%= _('If this is a mistake, you can unban them: %{url}.') % { url: admin_users_url(filter: 'banned') } %> diff --git a/app/views/notify/verification_instructions_email.html.haml b/app/views/notify/verification_instructions_email.html.haml new file mode 100644 index 00000000000..63d8d1b2461 --- /dev/null +++ b/app/views/notify/verification_instructions_email.html.haml @@ -0,0 +1,12 @@ +%div{ style: 'text-align:center;color:#1F1F1F;line-height:1.25em;max-width:400px;margin:0 auto;' } + %h3 + = s_('IdentityVerification|Help us protect your account') + %p{ style: 'font-size:0.9em' } + = s_('IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page.') + %div{ style: 'margin:26px 0;width:207px;height:53px;background-color:#F0F0F0;line-height:53px;font-weight:700;font-size:1.5em;color:#303030;' } + = @token + %p{ style: 'font-size:0.75em' } + = s_('IdentityVerification|If you have not recently tried to sign into GitLab, we recommend %{password_link_start}changing your password%{link_end} and %{two_fa_link_start}setting up Two-Factor Authentication%{link_end} to keep your account safe. Your verification code expires after %{expires_in_minutes} minutes.').html_safe % { link_end: link_end, + password_link_start: link_start(@password_link), + two_fa_link_start: link_start(@two_fa_link), + expires_in_minutes: @expires_in_minutes } diff --git a/app/views/notify/verification_instructions_email.text.erb b/app/views/notify/verification_instructions_email.text.erb new file mode 100644 index 00000000000..df507b5db71 --- /dev/null +++ b/app/views/notify/verification_instructions_email.text.erb @@ -0,0 +1,8 @@ +<%= s_('IdentityVerification|Help us protect your account') %> + +<%= s_('IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page.') %> + +<%= @token %> + +<%= s_('IdentityVerification|If you have not recently tried to sign into GitLab, we recommend changing your password (%{password_link}) and setting up Two-Factor Authentication (%{two_fa_link}) to keep your account safe.') % { password_link: @password_link, two_fa_link: @two_fa_link } %> +<%= s_('IdentityVerification|Your verification code expires after %{expires_in_minutes} minutes.') % { expires_in_minutes: @expires_in_minutes } %> diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml index 457d6690a78..0ca9acba2de 100644 --- a/app/views/profiles/_email_settings.html.haml +++ b/app/views/profiles/_email_settings.html.haml @@ -22,12 +22,12 @@ { include_blank: s_("Profiles|Do not show on profile") }, { class: 'gl-form-select custom-select', disabled: email_change_disabled } %small.form-text.text-gl-muted - = s_("Profiles|This email will be displayed on your public profile") + = s_("Profiles|This email will be displayed on your public profile.") .form-group.gl-form-group - commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits', target: '_blank') - commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url } - - commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe } + - commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more.%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe } = form.label :commit_email, s_('Profiles|Commit email') .gl-md-form-input-lg = form.select :commit_email, diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml index 5af4fe24d62..d798eab7635 100644 --- a/app/views/profiles/_name.html.haml +++ b/app/views/profiles/_name.html.haml @@ -2,8 +2,8 @@ - if user.read_only_attribute?(:name) = form.text_field :name, class: 'gl-form-input form-control', required: true, readonly: true %small.form-text.text-gl-muted - = s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } + = s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) } - else = form.text_field :name, class: 'gl-form-input form-control', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead") %small.form-text.text-gl-muted - = s_("Profiles|Enter your name, so people you know can recognize you") + = s_("Profiles|Enter your name, so people you know can recognize you.") diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 745d3c62c5d..cdd5a9ae7a1 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -17,9 +17,9 @@ .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 - = s_('Profiles|Two-Factor Authentication') + = s_('Profiles|Two-factor authentication') %p - = s_("Profiles|Increase your account's security by enabling Two-Factor Authentication (2FA)") + = s_("Profiles|Increase your account's security by enabling two-factor authentication (2FA).") .col-lg-8 %p #{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')} @@ -35,9 +35,9 @@ .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 - = s_('Profiles|Social sign-in') + = s_('Profiles|Service sign-in') %p - = s_('Profiles|Activate signin with one of the following services') + = s_('Profiles|Connect a service for sign-in.') .col-lg-8 = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities] .col-lg-12 @@ -68,7 +68,7 @@ = render 'users/deletion_guidance', user: current_user -# Delete button here - %button#delete-account-button.gl-button.btn.btn-danger.disabled{ data: { qa_selector: 'delete_account_button' } } + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do = s_('Profiles|Delete account') #delete-account-modal{ data: { action_url: user_registration_path, diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index 9804a3b7735..b3784faed28 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -1,6 +1,6 @@ %div = form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f| - = form_errors(@gpg_key) + = form_errors(@gpg_key, pajamas_alert: true) .form-group = f.label :key, s_('Profiles|Key'), class: 'label-bold' diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 5d3e0720176..46ae602359f 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -15,7 +15,7 @@ - else = _('Change your password or recover your current one') = form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| - = form_errors(@user) + = form_errors(@user, pajamas_alert: true) - unless @user.password_automatically_set? .form-group @@ -25,7 +25,8 @@ = _('You must provide your current password in order to change it.') .form-group = f.label :new_password, _('New password'), class: 'label-bold' - = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' } + = render_if_exists 'shared/password_requirements_list' .form-group = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index a2180dc68a6..5bcc92dcdfd 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -9,7 +9,7 @@ %br = _('After a successful password update you will be redirected to login screen.') - = form_errors(@user) + = form_errors(@user, pajamas_alert: true) - unless @user.password_automatically_set? .form-group.row @@ -21,7 +21,8 @@ .col-sm-2.col-form-label = f.label :new_password, _('New password') .col-sm-10 - = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' } + = render_if_exists 'shared/password_requirements_list' .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation, _('Password confirmation') diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index d1f1ff892d5..dda1640968e 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -5,9 +5,7 @@ - availability = availability_values - custom_emoji = show_status_emoji?(@user.status) -= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| - = form_errors(@user) - += gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| .row.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 @@ -46,20 +44,18 @@ %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") .col-lg-8 = f.fields_for :status, @user.status do |status_form| - - emoji_button = button_tag type: :button, - class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn gl-button btn-default has-tooltip', - title: s_("Profiles|Add status emoji") do + - emoji_button = render Pajamas::ButtonComponent.new(button_options: { title: s_("Profiles|Add status emoji"), + class: 'js-toggle-emoji-menu emoji-menu-toggle-button has-tooltip' } ) do - if custom_emoji = emoji_icon(@user.status.emoji, class: 'gl-mr-0!') %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) } = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral') = sprite_icon('smiley', css_class: 'award-control-icon-positive') = sprite_icon('smile', css_class: 'award-control-icon-super-positive') - - reset_message_button = button_tag type: :button, - id: 'js-clear-user-status-button', - class: 'clear-user-status btn gl-button btn-default has-tooltip', - title: s_("Profiles|Clear status") do - = sprite_icon("close") + - reset_message_button = render Pajamas::ButtonComponent.new(icon: 'close', + button_options: { id: 'js-clear-user-status-button', + class: 'has-tooltip', + title: s_("Profiles|Clear status") } ) = status_form.hidden_field :emoji, id: 'js-status-emoji-field' .form-group.gl-form-group @@ -76,7 +72,7 @@ .form-group.gl-form-group = status_form.gitlab_ui_checkbox_component :availability, s_("Profiles|Busy"), - help_text: s_('Profiles|An indicator appears next to your name and avatar'), + help_text: s_('Profiles|An indicator appears next to your name and avatar.'), checkbox_options: { data: { testid: "user-availability-checkbox" } }, checked_value: availability["busy"], unchecked_value: availability["not_set"] @@ -85,7 +81,7 @@ .row.user-time-preferences.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0= s_("Profiles|Time settings") - %p= s_("Profiles|Set your local time zone") + %p= s_("Profiles|Set your local time zone.") .col-lg-8 %h5= _("Time zone") = dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg gl-w-full!', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) @@ -97,7 +93,7 @@ %h4.gl-mt-0 = s_("Profiles|Main settings") %p - = s_("Profiles|This information will appear on your profile") + = s_("Profiles|This information will appear on your profile.") - if current_user.ldap_user? = s_("Profiles|Some options are unavailable for LDAP accounts") .col-lg-8 @@ -111,12 +107,12 @@ = f.label :pronouns, s_('Profiles|Pronouns') = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' %small.form-text.text-gl-muted - = s_("Profiles|Enter your pronouns to let people know how to refer to you") + = s_("Profiles|Enter your pronouns to let people know how to refer to you.") .form-group.gl-form-group = f.label :pronunciation, s_('Profiles|Pronunciation') = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' %small.form-text.text-gl-muted - = s_("Profiles|Enter how your name is pronounced to help people address you correctly") + = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") = render_if_exists 'profiles/extra_settings', form: f = render_if_exists 'profiles/email_settings', form: f .form-group.gl-form-group @@ -148,17 +144,17 @@ = f.label :organization, s_('Profiles|Organization') = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' %small.form-text.text-gl-muted - = s_("Profiles|Who you represent or work for") + = s_("Profiles|Who you represent or work for.") .form-group.gl-form-group = f.label :bio, s_('Profiles|Bio') = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 %small.form-text.text-gl-muted - = s_("Profiles|Tell us about yourself in fewer than 250 characters") + = s_("Profiles|Tell us about yourself in fewer than 250 characters.") %hr %fieldset.form-group.gl-form-group %legend.col-form-label.col-form-label = _('Private profile') - - private_profile_label = s_("Profiles|Don't display activity-related personal information on your 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') = 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 @@ -166,7 +162,7 @@ = s_("Profiles|Private contributions") = f.gitlab_ui_checkbox_component :include_private_contributions, s_('Profiles|Include private contributions on my profile'), - help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") + help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") %hr = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn' = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel' diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 845baae3bb2..6304d42896d 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -78,7 +78,7 @@ .col-lg-8 - registration = webauthn_enabled ? @webauthn_registration : @u2f_registration - if registration.errors.present? - = form_errors(registration) + = form_errors(registration, pajamas_alert: true) - if webauthn_enabled = render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path - else diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 05166395067..402affc7b0e 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -11,4 +11,4 @@ .content_list.project-activity{ :"data-href" => activity_project_path(@project) } .loading - = gl_loading_icon(size: 'md') + = render Pajamas::SpinnerComponent.new(size: :md) diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index bea5d548e03..319c6333e77 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -16,7 +16,7 @@ #js-code-owners - if is_project_overview - .project-buttons.gl-mb-3.js-show-on-project-root + .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true #js-tree-list{ data: vue_file_list_data(project, ref) } diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index cb15858a935..42cdc1d6989 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -24,21 +24,21 @@ - if bitbucket_import_enabled? %div - = link_to status_import_bitbucket_path, class: "gl-button btn-default btn import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", + = link_to status_import_bitbucket_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } do .gl-button-icon = sprite_icon('bitbucket') Bitbucket Cloud - if bitbucket_server_import_enabled? %div - = link_to status_import_bitbucket_server_path, class: "gl-button btn-default btn import_bitbucket js-import-project-btn", data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } do + = link_to status_import_bitbucket_server_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_bitbucket js-import-project-btn", data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } do .gl-button-icon = sprite_icon('bitbucket') Bitbucket Server %div - if gitlab_import_enabled? %div - = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}", + = link_to status_import_gitlab_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}", data: { modal_title: _("Import projects from GitLab.com"), modal_message: import_from_gitlab_message, platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do .gl-button-icon = sprite_icon('tanuki') @@ -46,7 +46,7 @@ - if fogbugz_import_enabled? %div - = link_to new_import_fogbugz_path, class: 'gl-button btn-default btn import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } do + = link_to new_import_fogbugz_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } do .gl-button-icon = sprite_icon('bug') FogBugz diff --git a/app/views/projects/_merge_request_squash_options_settings.html.haml b/app/views/projects/_merge_request_squash_options_settings.html.haml index 4b428363646..372c0723600 100644 --- a/app/views/projects/_merge_request_squash_options_settings.html.haml +++ b/app/views/projects/_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 "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.md'), target: '_blank', rel: 'noopener noreferrer' = settings.gitlab_ui_radio_component :squash_option, :never, diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 2cbb9758703..992b46c1f7b 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -35,7 +35,7 @@ - if current_user.can_create_group? .form-text.text-muted - link_start_group_path = '<a href="%{path}">' % { path: new_group_path } - - project_tip = s_('ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' } + - project_tip = s_('ProjectsNew|Want to organize several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' } = project_tip.html_safe = render Pajamas::AlertComponent.new(alert_options: { class: "gl-mb-4 gl-display-none js-user-readme-repo" }, dismissible: false, @@ -52,10 +52,11 @@ - unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? || !Gitlab.com? .js-deployment-target-select -= f.label :visibility_level, class: 'label-bold' do - = s_('ProjectsNew|Visibility Level') - = link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer' -= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'} +.form-group.gl-form-group + = f.label :visibility_level, class: 'label-bold' do + = s_('ProjectsNew|Visibility Level') + = link_to sprite_icon('question-o'), help_page_path('user/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer' + = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'} - if !hide_init_with_readme = f.label :project_configuration, class: 'label-bold' do @@ -77,5 +78,7 @@ = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed' } +-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675 += render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label = f.submit _('Create project'), class: "btn gl-button btn-confirm js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" } = link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index d0dfbb89ca7..ed238dab4ff 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -7,7 +7,7 @@ %h4.danger-title= _('Delete project') %p %strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.') - = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'remove-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' %p %strong= _('Deleted projects cannot be restored!') #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(forks_count), stars_count: number_with_delimiter(project.star_count) } } diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml index bb51aa86170..bfc1e77118a 100644 --- a/app/views/projects/_remove_fork.html.haml +++ b/app/views/projects/_remove_fork.html.haml @@ -8,5 +8,5 @@ = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f| %p %strong= _('Once removed, the fork relationship cannot be restored. This project will no longer be able to receive or send merge requests to the source project or other forks.') - = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'remove-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) } diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 13ff8abe499..4a21cb32c20 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -2,7 +2,7 @@ - project_buttons = local_assigns.fetch(:project_buttons, false) - return unless anchors.any? -%ul.nav +%ul.nav.gl-gap-3 - anchors.each do |anchor| %li.nav-item = link_to_if(anchor.link, anchor.label, anchor.link, stat_anchor_attrs(anchor)) do diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index 9e6648c71fc..393b199fb05 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -7,7 +7,7 @@ %h4.danger-title= _('Transfer project') = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f| .form-group - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-another-namespace') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transfer-a-project-to-another-namespace') } %p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.') %p diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml index e69c4f51ec4..6a4760c3954 100644 --- a/app/views/projects/activity.html.haml +++ b/app/views/projects/activity.html.haml @@ -1,5 +1,4 @@ - page_title _("Activity") -= render_if_exists 'shared/minute_limit_banner', namespace: @project = render 'projects/last_push' = render 'projects/activity' diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index bc1e62a8980..b44c773adff 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -20,7 +20,7 @@ %span.legend-box.legend-box-9 %span.right-label Older - .table-responsive.file-content.blame.code{ class: user_color_scheme } + .table-responsive.file-content.blame.code{ class: user_color_scheme, data: { qa_selector: 'blame_file_content' } } %table - current_line = @blame.first_line - @blame.groups.each do |blame_group| diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 2c3aade1068..4139be053f8 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -2,6 +2,8 @@ - project = @project.present(current_user: current_user) - ref = local_assigns[:ref] || @ref - expanded = params[:expanded].present? +- if blob.rich_viewer + - add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) } .info-well.d-none.d-sm-block .well-segment @@ -14,7 +16,7 @@ #blob-content-holder.blob-content-holder - if @code_navigation_path #js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } } - - if Feature.enabled?(:refactor_blob_viewer, @project) && !expanded + - if !expanded -# Data info will be removed once we migrate this to use GraphQL -# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406 #js-view-blob-app{ data: { blob_path: blob.path, diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml deleted file mode 100644 index 7511de76223..00000000000 --- a/app/views/projects/blob/_remove.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -#modal-remove-blob.modal - .modal-dialog - .modal-content - .modal-header - %h1.page-title.gl-font-size-h-display Delete #{@blob.name} - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": "true" } × - - .modal-body - = form_tag project_blob_path(@project, @id), method: :delete, class: 'js-delete-blob-form js-quick-submit js-requires-input' do - = render 'shared/new_commit_form', placeholder: "Delete #{@blob.name}" - - .form-group.row - .offset-sm-2.col-sm-10 - = button_tag 'Delete file', class: 'btn gl-button btn-danger btn-remove-file' - = link_to _('Cancel'), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal" diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index a91c0d63b00..16ecc1cc5a0 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -11,8 +11,5 @@ #tree-holder.tree-holder = render 'blob', blob: @blob - - if can_modify_blob?(@blob) - = render 'projects/blob/remove' - = render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration? = render 'shared/web_ide_path' diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml index 8bf0339fc3c..7206a969fb7 100644 --- a/app/views/projects/blob/viewers/_stl.html.haml +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -3,7 +3,7 @@ = gl_loading_icon(size: "md", css_class: "gl-my-4") .text-center.gl-mt-3.gl-mb-3.stl-controls .btn-group - %button.gl-button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } } - Wireframe - %button.gl-button.btn.btn-default.btn-sm.selected.js-material-changer{ data: { type: 'default' } } - Solid + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-material-changer', data: { material: 'wireframe' } }) do + = _('Wireframe') + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-material-changer selected', data: { material: 'default' } }) do + = _('Solid') diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index e4ec7a43d61..1477ae66d80 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -1,9 +1,9 @@ - merged = local_assigns.fetch(:merged, false) - commit = @repository.commit(branch.dereferenced_target) - merge_project = merge_request_source_project_for_project(@project) -%li{ class: "branch-item js-branch-item js-branch-#{branch.name}", data: { name: branch.name } } +%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name}", data: { name: branch.name } } .branch-info - .branch-title + .gl-display-flex.gl-align-items-center = sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0') = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do = branch.name diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index c33b9b538f3..bd096ed74f5 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -132,4 +132,4 @@ = sprite_icon('play', css_class: 'gl-icon') - elsif job.retryable? = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'gl-button btn btn-default btn-icon' do - = sprite_icon('repeat', css_class: 'gl-icon') + = sprite_icon('retry', css_class: 'gl-icon') diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 81a77489075..e04c4ebfda2 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -1,3 +1,12 @@ +- can_push_code = can?(current_user, :push_code, @project) + +- if !can_push_code && selected_branch.present? + - branch_collaboration = @project.branch_allows_collaboration?(current_user, selected_branch) + - existing_branch = ERB::Util.html_escape(selected_branch) +- else + - branch_collaboration = false + - existing_branch = '' + - case type.to_s - when 'revert' - revert_merge_request = _('Revert this merge request') @@ -7,9 +16,9 @@ .js-revert-commit-modal{ data: { title: title, endpoint: revert_namespace_project_commit_path(commit, namespace_id: @project.namespace.full_path, project_id: @project), branch: @project.default_branch, - push_code: can?(current_user, :push_code, @project).to_s, - branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s, - existing_branch: ERB::Util.html_escape(selected_branch), + push_code: can_push_code.to_s, + branch_collaboration: branch_collaboration.to_s, + existing_branch: existing_branch, branches_endpoint: project_branches_path(@project) } } - when 'cherry-pick' @@ -20,8 +29,8 @@ branch: @project.default_branch, target_project_id: @project.id, target_project_name: @project.full_path, - push_code: can?(current_user, :push_code, @project).to_s, - branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s, - existing_branch: ERB::Util.html_escape(selected_branch), + push_code: can_push_code.to_s, + branch_collaboration: branch_collaboration.to_s, + existing_branch: existing_branch, branches_endpoint: refs_project_path(@project), projects: cherry_pick_projects_data(@project).to_json } } diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 4442f62b221..71485e203db 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -24,7 +24,7 @@ .avatar-cell.d-none.d-sm-block = author_avatar(commit, size: 40, has_tooltip: false) - .commit-detail.flex-list + .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-flex-start.gl-flex-grow-1.gl-min-w-0 .commit-content{ data: { qa_selector: '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?)] diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 23f9afe8352..780bb3404cc 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -3,9 +3,9 @@ .diff-content - if diff_file.has_renderable? - %div{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toHide' } } + .hidden{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'rawViewer' } } = render 'projects/diffs/viewer', viewer: diff_file.viewer - %div{ id: "#rendered-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toShow' } } + %div{ id: "#rendered-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'renderedViewer' } } = render 'projects/diffs/viewer', viewer: diff_file.rendered.viewer - else = render 'projects/diffs/viewer', viewer: diff_file.viewer diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 41d6b7086c1..a7dd69a9607 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -5,8 +5,6 @@ - expanded = expanded_by_default? - reduce_visibility_form_id = 'reduce-visibility-form' -= render_if_exists 'shared/minute_limit_banner', namespace: @project - %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') @@ -90,7 +88,7 @@ = render 'projects/errors' = form_for @project do |f| .form-group - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'renaming-a-repository') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') } %p= _("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}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %ul %li= _("Be careful. Renaming a project's repository can have unintended side effects.") diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index ce6d021ce2f..6f2e135f9d3 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -21,10 +21,10 @@ = _('You can get started by cloning the repository or start adding files to it with one of the following options.') .project-buttons.qa-quick-actions - .project-clone-holder.d-block.d-md-none.mt-2.mr-2 + .project-clone-holder.d-block.d-md-none.gl-mt-3.gl-mr-3 = render "shared/mobile_clone_panel" - .project-clone-holder.d-none.d-md-inline-block.mb-2.mr-2.float-left + .project-clone-holder.d-none.d-md-inline-block.gl-mb-3.gl-mr-3.float-left = render "projects/buttons/clone" = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index af5ad06d30e..2e024b8ffc4 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,6 +1,6 @@ - page_title _("Find File"), @ref -.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) } +.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } .nav-block .tree-ref-holder = render 'shared/ref_switcher', destination: 'find_file', path: @path diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 7243852e1f5..36347776ec9 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -4,7 +4,7 @@ endpoint: new_project_fork_path(@project, format: :json), new_group_path: new_group_path, project_full_path: project_path(@project), - visibility_help_path: help_page_path("public_access/public_access"), + visibility_help_path: help_page_path("user/public_access"), project_id: @project.id, project_name: @project.name, project_path: @project.path, diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml new file mode 100644 index 00000000000..ec977898f47 --- /dev/null +++ b/app/views/projects/google_cloud/configuration/index.html.haml @@ -0,0 +1,7 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title s_('CloudSeed|Configuration') +- page_title s_('CloudSeed|Configuration') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud-configuration{ data: @js_data } diff --git a/app/views/projects/google_cloud/databases/index.html.haml b/app/views/projects/google_cloud/databases/index.html.haml new file mode 100644 index 00000000000..ad732317d8d --- /dev/null +++ b/app/views/projects/google_cloud/databases/index.html.haml @@ -0,0 +1,7 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title s_('CloudSeed|Databases') +- page_title s_('CloudSeed|Databases') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud-databases{ data: @js_data } diff --git a/app/views/projects/google_cloud/deployments/index.html.haml b/app/views/projects/google_cloud/deployments/index.html.haml new file mode 100644 index 00000000000..b140159a7f5 --- /dev/null +++ b/app/views/projects/google_cloud/deployments/index.html.haml @@ -0,0 +1,7 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title s_('CloudSeed|Deployments') +- page_title s_('CloudSeed|Deployments') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud-deployments{ data: @js_data } diff --git a/app/views/projects/google_cloud/errors/gcp_error.html.haml b/app/views/projects/google_cloud/errors/gcp_error.html.haml deleted file mode 100644 index 69e481501d5..00000000000 --- a/app/views/projects/google_cloud/errors/gcp_error.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- breadcrumb_title _('Google Cloud') -- page_title _('Google Cloud') - -- @content_class = "limit-container-width" unless fluid_layout - -#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml deleted file mode 100644 index 69e481501d5..00000000000 --- a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- breadcrumb_title _('Google Cloud') -- page_title _('Google Cloud') - -- @content_class = "limit-container-width" unless fluid_layout - -#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml index 3a6f8ca059d..d7cabaa029b 100644 --- a/app/views/projects/google_cloud/gcp_regions/index.html.haml +++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _('Google Cloud'), @google_cloud_path -- breadcrumb_title _('Regions') -- page_title _('Regions') +- breadcrumb_title _('CloudSeed|Regions') +- page_title s_('CloudSeed|Regions') - @content_class = "limit-container-width" unless fluid_layout = form_tag project_google_cloud_gcp_regions_path(@project), method: 'post' do - #js-google-cloud{ data: @js_data } + #js-google-cloud-gcp-regions{ data: @js_data } diff --git a/app/views/projects/google_cloud/index.html.haml b/app/views/projects/google_cloud/index.html.haml deleted file mode 100644 index 69e481501d5..00000000000 --- a/app/views/projects/google_cloud/index.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- breadcrumb_title _('Google Cloud') -- page_title _('Google Cloud') - -- @content_class = "limit-container-width" unless fluid_layout - -#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml index 9b82bc0acb5..6191de577fe 100644 --- a/app/views/projects/google_cloud/service_accounts/index.html.haml +++ b/app/views/projects/google_cloud/service_accounts/index.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _('Google Cloud'), @google_cloud_path -- breadcrumb_title _('Service Account') -- page_title _('Service Account') +- breadcrumb_title s_('CloudSeed|Service Account') +- page_title s_('CloudSeed|Service Account') - @content_class = "limit-container-width" unless fluid_layout = form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do - #js-google-cloud{ data: @js_data } + #js-google-cloud-service-accounts{ data: @js_data } diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml index 270cbf3facd..0fce3b7f8aa 100644 --- a/app/views/projects/harbor/repositories/index.html.haml +++ b/app/views/projects/harbor/repositories/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Harbor Registry") - @content_class = "limit-container-width" unless fluid_layout -#js-harbor-registry-list-project{ data: { endpoint: project_harbor_registry_index_path(@project), +#js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml index 8096bc6cead..9fe541c5912 100644 --- a/app/views/projects/imports/show.html.haml +++ b/app/views/projects/imports/show.html.haml @@ -3,9 +3,10 @@ .save-project-loader .center - %h2 + %h2.gl--flex-center.gl-flex-direction-column.gl-sm-flex-direction-row = gl_loading_icon(inline: true) - = import_in_progress_title + %span.gl-ml-3 + = import_in_progress_title - if !has_ci_cd_only_params? && @project.external_import? %p.monospace git clone --bare #{@project.safe_import_url} %p diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml index 4d4607e8e36..5043f94bd5c 100644 --- a/app/views/projects/incidents/show.html.haml +++ b/app/views/projects/incidents/show.html.haml @@ -2,5 +2,6 @@ - add_to_breadcrumbs _("Incidents"), project_incidents_path(@project) - breadcrumb_title @issue.to_reference - page_title "#{@issue.title} (#{@issue.to_reference})", _("Incidents") +- add_page_specific_style 'page_bundles/issues_show' = render 'projects/issuable/show', issuable: @issue diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index a904b53515c..16b795ee3c9 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,4 +1,4 @@ -- add_page_startup_api_call Feature.enabled?(:paginated_issue_discussions, @project) ? discussions_path(@issue, per_page: 20) : discussions_path(@issue) +- add_page_startup_api_call discussions_path(@issue, per_page: 20) - @gfm_form = true diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 4c96875ce42..4d4645c7087 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -65,6 +65,9 @@ = render 'shared/issuable_meta_data', issuable: issue - .float-right.issuable-updated-at.d-none.d-sm-inline-block + .float-right.issuable-timestamp.d-none.d-sm-inline-block %span - = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') } + - if issue.closed? && issue.closed_at + = _('closed %{timeago}').html_safe % { timeago: time_ago_with_tooltip(issue.closed_at, placement: 'bottom') } + - else + = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom') } diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index 55a8eb720b6..5d478784350 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,2 +1,2 @@ - if Feature.enabled?(:work_items_hierarchy, @project) - .js-work-item-links-root{ data: { issuable_id: @issue.id } } + .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path } } diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 3572d1d6556..06c422fc4d6 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -3,6 +3,7 @@ - breadcrumb_title @issue.to_reference - page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues") - add_page_specific_style 'page_bundles/issues_show' +- add_page_specific_style 'page_bundles/work_items' = render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue) = render 'projects/invite_members_modal', project: @project diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index dfea4db4d07..d39d292fb53 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -2,12 +2,4 @@ - add_page_specific_style 'page_bundles/ci_status' - admin = local_assigns.fetch(:admin, false) -- if Feature.enabled?(:jobs_table_vue, @project) - #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } -- else - .top-area - - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } - = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - - .content-list.builds-content-list - = render "table", builds: @builds, project: @project +#js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index fedc1291a92..5f249f693ff 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -7,7 +7,4 @@ = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -- if @build.is_a? ::Ci::Build - #js-job-page{ data: jobs_data } -- else - #js-bridge-page{ data: bridge_data(@build, @project) } +#js-job-page{ data: jobs_data } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index dd63e854a36..647464b31f8 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -4,7 +4,6 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -= render_if_exists 'shared/minute_limit_banner', namespace: @project - if labels_or_filters #js-promote-label-modal = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label diff --git a/app/views/projects/logs/empty_logs.html.haml b/app/views/projects/logs/empty_logs.html.haml deleted file mode 100644 index 48403f5e55e..00000000000 --- a/app/views/projects/logs/empty_logs.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- page_title _('Logs') - -.row.empty-state - .col-sm-12 - .svg-content - = image_tag 'illustrations/operations_log_pods_empty.svg' - .col-12 - .text-content - %h4.text-center - = s_('Environments|No deployed environments') - %p.state-description.text-center - = s_('Logs|To see the logs, deploy your code to an environment.') - .text-center - = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-confirm' diff --git a/app/views/projects/logs/index.html.haml b/app/views/projects/logs/index.html.haml deleted file mode 100644 index 1f74eb52fd9..00000000000 --- a/app/views/projects/logs/index.html.haml +++ /dev/null @@ -1 +0,0 @@ -#environment-logs{ data: environment_logs_data(@project, @environment) } diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index a0810cfe37d..6b367c735c3 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -26,7 +26,7 @@ .gl-new-dropdown-item-text-wrapper = _('Close') = display_issuable_type - - elsif !@merge_request.source_project_missing? + - elsif !@merge_request.source_project_missing? && @merge_request.closed? %li.gl-new-dropdown-item = link_to reopen_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do .gl-new-dropdown-item-text-wrapper @@ -34,7 +34,7 @@ = display_issuable_type - unless current_controller?('conflicts') - - if current_user && moved_mr_sidebar_enabled? + - if current_user && moved_mr_sidebar_enabled? && !@merge_request.merged? %li.gl-new-dropdown-divider %hr.dropdown-divider %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml index 14ddaf8d2b7..7cadc37b0fd 100644 --- a/app/views/projects/merge_requests/_commits.html.haml +++ b/app/views/projects/merge_requests/_commits.html.haml @@ -8,7 +8,7 @@ - if can_update_merge_request %p = _('Push commits to the source branch or add previously merged commits to review them.') - %button.btn.gl-button.btn-confirm.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } } + = render Pajamas::ButtonComponent.new(variant: 'confirm', button_options: { class: 'add-review-item-modal-trigger', data: { commits_empty: 'true', context_commits_empty: 'true' } }) do = _('Add previously merged commits') - else %ol#commits-list.list-unstyled diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 4f4acb6103f..893f03157db 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -3,7 +3,7 @@ - can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request) - are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false) - hide_gutter_toggle = local_assigns.fetch(:hide_gutter_toggle, false) -- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language, "1.1-updated_header", moved_mr_sidebar_enabled?, hide_gutter_toggle] +- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language, "1.1-updated_header", moved_mr_sidebar_enabled?, hide_gutter_toggle, fluid_layout] = cache(cache_key, expires_in: 1.day) do - if @merge_request.closed_or_merged_without_fork? diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 8cd0d2f9e32..cee8d2e92aa 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -67,5 +67,5 @@ %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? - = form_errors(@merge_request) + = form_errors(@merge_request, pajamas_alert: true) = f.submit 'Compare branches and continue', class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 99b84339058..4ef557fbd8f 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -99,6 +99,9 @@ #js-review-bar +- if Feature.enabled?(:mr_experience_survey, @project) + #js-mr-experience-survey + - if current_user&.mr_attention_requests_enabled? #js-need-attention-sidebar-onboarding diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 5f2057df4aa..9b0508d8cb5 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,6 +1,6 @@ = form_for [@project, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| - = form_errors(@milestone) + = form_errors(@milestone, pajamas_alert: true) .form-group.row .col-form-label.col-sm-2 = f.label :title, _('Title') diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 339042eb703..a90d5224d04 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -17,7 +17,7 @@ = gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| .panel.panel-default .panel-body - %div= form_errors(@project) + %div= form_errors(@project, pajamas_alert: true) .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index e3fe098c807..d367f383e5a 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -11,7 +11,7 @@ = _('Fingerprints') .fingerprints-list.js-fingerprints-list{ data: { qa_selector: 'fingerprints_list' } } - mirror.ssh_known_hosts_fingerprints.each do |fp| - %code= fp.fingerprint + %code= fp.fingerprint_sha256 || fp.fingerprint - if verified_at .form-text.text-muted.js-fingerprint-verification = sprite_icon('check', css_class: 'gl-text-green-500') diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 511adf37b39..07c38d9845c 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -12,7 +12,7 @@ .row{ 'v-cloak': true } #blank-project-pane.tab-pane.active - = form_for @project, html: { class: 'new_project gl-mt-3' } do |f| + = gitlab_ui_form_for @project, html: { class: 'new_project gl-mt-3' } do |f| = render 'new_project_fields', f: f, project_name_id: "blank-project-name" #create-from-template-pane.tab-pane @@ -22,7 +22,7 @@ - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing' - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url } = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - = form_for @project, html: { class: 'new_project' } do |f| + = gitlab_ui_form_for @project, html: { class: 'new_project' } do |f| .project-template .form-group %div diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 54435f675a7..07e299d71ea 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -6,9 +6,9 @@ = preserve(markdown(commit.description, pipeline: :single_line)) .info-well - .well-segment.pipeline-info - .icon-container.gl-vertical-align-text-bottom - = sprite_icon('clock') + .well-segment.pipeline-info{ class: "gl-align-items-baseline!" } + .icon-container + = sprite_icon('clock', css_class: 'gl-top-0!') = pluralize @pipeline.total_size, "job" = @pipeline.ref_text - if @pipeline.duration @@ -20,7 +20,7 @@ - if has_pipeline_badges?(@pipeline) .well-segment.qa-pipeline-badges .icon-container - = sprite_icon('flag') + = sprite_icon('flag', css_class: 'gl-top-0!') - if @pipeline.child? - text = sprintf(s_('Pipelines|Child pipeline (%{link_start}parent%{link_end})'), { link_start: "<a href='#{pipeline_path(@pipeline.triggered_by_pipeline)}' class='text-underline'>", link_end: "</a>"}).html_safe = gl_badge_tag text, { variant: :info, size: :sm }, { class: 'js-pipeline-child has-tooltip', title: s_("Pipelines|This is a child pipeline within the parent pipeline") } @@ -44,13 +44,13 @@ .well-segment.branch-info .icon-container.commit-icon - = custom_icon("icon_commit") + = sprite_icon('commit', css_class: 'gl-top-0!') = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha" = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA")) .well-segment.related-merge-request-info .icon-container - = sprite_icon("git-merge") + = sprite_icon("git-merge", css_class: 'gl-top-0!') %span.related-merge-requests %span.js-truncated-mr-list = @pipeline.all_related_merge_request_text(limit: 1) diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 10ff9c31c3e..c9eb2e92193 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -27,7 +27,7 @@ = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } - if Feature.enabled?(:pipeline_tabs_vue, @project) - #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline) } + #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) } - else = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors .js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 8c616b89658..37fe80d2aaf 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -2,47 +2,45 @@ - page_title _("Members") = render_if_exists 'projects/free_user_cap_alert', project: @project -= render_if_exists 'shared/minute_limit_banner', namespace: @project .row.gl-mt-3 .col-lg-12 - - if can_invite_members_for_project?(@project) - .row - .col-md-12.col-lg-6.gl-display-flex - .gl-flex-direction-column.gl-flex-wrap.align-items-baseline - %h4 - = _("Project members") - .gl-justify-content-bottom.gl-display-flex.align-items-center - %p - = project_member_header_subtext(@project) - .col-md-12.col-lg-6 - .gl-display-flex.gl-flex-wrap.gl-justify-content-end - - if can_admin_project_member?(@project) - .js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } } - - if @project.allowed_to_share_with_group? - .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } } - = render 'projects/invite_groups_modal', project: @project - - if can_admin_project_member?(@project) - .js-invite-members-trigger{ data: { variant: 'confirm', - classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', - trigger_source: 'project-members-page', - display_text: _('Invite members') } } - = render 'projects/invite_members_modal', project: @project - - - else - - if project_can_be_shared? + .gl-display-flex.gl-flex-wrap + - if can_invite_members_for_project?(@project) %h4 = _("Project members") - - if can?(current_user, :admin_project_member, @project) - %p - = project_member_header_subtext(@project) - - else - %p - = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } + %p.gl-w-full.order-md-1 + = project_member_header_subtext(@project) + .gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3 + - invite_group_top_margin = '' + - if can_admin_project_member?(@project) + .js-import-project-members-trigger{ data: { classes: 'gl-md-w-auto gl-w-full' } } + .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name } } + - invite_group_top_margin = 'gl-md-mt-0 gl-mt-3' + - if @project.allowed_to_share_with_group? + .js-invite-group-trigger{ data: { classes: "gl-md-w-auto gl-w-full gl-md-ml-3 #{invite_group_top_margin}", display_text: _('Invite a group') } } + = render 'projects/invite_groups_modal', project: @project + - if can_admin_project_member?(@project) + .js-invite-members-trigger{ data: { variant: 'confirm', + classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3', + trigger_source: 'project-members-page', + display_text: _('Invite members') } } + = render 'projects/invite_members_modal', project: @project + - else + - if project_can_be_shared? + %h4 + = _("Project members") + - if can?(current_user, :admin_project_member, @project) + %p.gl-w-full + = project_member_header_subtext(@project) + - else + %p.gl-w-full + = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } .js-project-members-list-app{ data: { members_data: project_members_app_data_json(@project, members: @project_members, - group_links: @group_links, invited: @invited_members, - access_requests: @requesters) } } + access_requests: @requesters, + include_relations: @include_relations, + search: params[:search_groups]) } } = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index e5810930be2..3b8294a1dec 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -1,29 +1,29 @@ = form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } .card - .card-header + .card-header.gl-font-weight-bold = s_("ProtectedBranch|Protect a branch") .card-body - = form_errors(@protected_branch) + = form_errors(@protected_branch, pajamas_alert: true) .form-group.row - = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-md-2 text-left text-md-right' - .col-md-10 - = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f } + = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' + .col-sm-12 + = render partial: "projects/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: 'configure-multiple-protected-branches-by-using-a-wildcard') - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } = (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.") % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }).html_safe .form-group.row - = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-md-2 text-left text-md-right' - .col-md-10 + = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12' + .col-sm-12 = yield :merge_access_levels .form-group.row - = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-md-2 text-left text-md-right' - .col-md-10 + = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-sm-12' + .col-sm-12 = yield :push_access_levels .form-group.row - = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-md-2 gl-text-left text-md-right' - .col-md-10 + = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-sm-12' + .col-sm-12 = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle', label: s_("ProtectedBranch|Allowed to force push"), label_position: :hidden) do diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml index 67a6e8efae8..4b09d36e7c3 100644 --- a/app/views/projects/protected_branches/shared/_dropdown.html.haml +++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml @@ -1,8 +1,12 @@ +- toggle_classes = local_assigns.fetch(:toggle_classes, '') + = f.hidden_field(:name) = dropdown_tag('Select branch or create wildcard', - options: { toggle_class: 'js-protected-branch-select js-filter-submit wide monospace qa-protected-branch-select', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown", placeholder: "Search protected branches", + options: { toggle_class: "js-protected-branch-select js-filter-submit wide monospace qa-protected-branch-select #{toggle_classes}", + filter: true, + dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown", + placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], 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 ba0935fff7d..e257117a32e 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 @@ -4,7 +4,7 @@ .card-header = _('Protect a tag') .card-body - = form_errors(@protected_tag) + = form_errors(@protected_tag, pajamas_alert: true) .form-group.row = f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right' .col-md-10.protected-tags-dropdown diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index 8a080241513..2f97a068b49 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -7,14 +7,14 @@ - else = _('Archive project') - if @project.archived? - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchiving-a-project') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') } %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = link_to _('Unarchive project'), unarchive_project_path(@project), aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, method: :post, class: "gl-button btn btn-confirm" - else - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archiving-a-project') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') } %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = link_to _('Archive project'), archive_project_path(@project), aria: { label: _('Archive project') }, 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 96564e44cf2..64f45ec89d1 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -16,7 +16,7 @@ .row .col-lg-12 = gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f| - = form_errors(@project) + = form_errors(@project, pajamas_alert: true) %fieldset.builds-feature.js-auto-devops-settings .form-group = f.fields_for :auto_devops_attributes, @auto_devops do |form| diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 9419dacc16f..50e96528c0d 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -6,7 +6,7 @@ .row.gl-mt-3 .col-lg-12 = gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f| - = form_errors(@project) + = form_errors(@project, pajamas_alert: true) %fieldset.builds-feature .form-group = f.gitlab_ui_checkbox_component :public_builds, diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 5da3d2b891c..09f9ca60b3e 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -109,13 +109,15 @@ = render 'ci/token_access/index' - if show_secure_files_setting(@project, current_user) - %section.settings + %section.settings.no-animate#js-secure-files{ class: ('expanded' if expanded) } .settings-header - %h4.settings-title + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _("Secure Files") - = button_to project_ci_secure_files_path(@project), method: :get, class: 'btn gl-button btn-default' do - = _('Manage') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') %p = _("Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.") = link_to _('Learn more'), help_page_path('ci/secure_files/index'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + #js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } } diff --git a/app/views/projects/settings/operations/_tracing.html.haml b/app/views/projects/settings/operations/_tracing.html.haml deleted file mode 100644 index 3c8ebe3fb20..00000000000 --- a/app/views/projects/settings/operations/_tracing.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -- setting = tracing_setting - -%section.settings.border-0.no-animate - .settings-header{ :class => 'border-top' } - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _('Tracing') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = _('Expand') - %p - = _('Embed an image of your existing Jaeger server in GitLab.') - = link_to _('Learn more.'), help_page_path('operations/tracing'), target: '_blank', rel: 'noopener noreferrer' - .settings-content - = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f| - = form_errors(@project) - .form-group - = f.fields_for :tracing_setting_attributes, setting do |form| - = form.label :external_url, _('Jaeger URL'), class: 'label-bold' - = form.url_field :external_url, class: 'form-control gl-form-input', placeholder: 'https://jaeger.example.com' - %p.form-text.text-muted - - jaeger_help_url = 'https://www.jaegertracing.io/docs/getting-started/' - - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url } - - link_end_tag = "#{sprite_icon('external-link', css_class: 'gl-ml-2 gl-vertical-align-middle')}</a>".html_safe - = _('Learn more about %{link_start_tag}Jaeger configuration%{link_end_tag}.').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag } - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 80c22604e49..50bfd3c6976 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -16,11 +16,10 @@ .gl-alert-body %p = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.')) - = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end } if Feature.enabled?(:monitor_tracing, @project) + = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end } = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end } = render 'projects/settings/operations/metrics_dashboard' -= render 'projects/settings/operations/tracing' if Feature.enabled?(:monitor_tracing, @project) = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/alert_management' = render 'projects/settings/operations/incidents' diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 378bb0f9306..1a7821d3268 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -8,6 +8,8 @@ keep_n_options: keep_n_options.to_json, older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, + show_container_registry_settings: show_container_registry_settings(@project).to_s, + show_package_registry_settings: show_package_registry_settings(@project).to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 290ef79f261..1f529849b28 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -7,7 +7,6 @@ = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") = render_if_exists 'projects/free_user_cap_alert', project: @project -= render_if_exists 'shared/minute_limit_banner', namespace: @project = render partial: 'flash_messages', locals: { project: @project } = render 'clusters_deprecation_alert' diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml new file mode 100644 index 00000000000..5bdf1c7896c --- /dev/null +++ b/app/views/projects/tags/_edit_release_button.html.haml @@ -0,0 +1,11 @@ +- if Feature.enabled?(:edit_tag_release_notes_via_release_page, project) + - release_btn_text = s_('TagsPage|Create release') + - release_btn_path = new_project_release_path(project, tag_name: tag.name) + - if release + - release_btn_text = s_('TagsPage|Edit release') + - release_btn_path = edit_project_release_path(project, release) + = link_to release_btn_path, class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: release_btn_text, data: { container: "body" } do + = sprite_icon('pencil', css_class: 'gl-icon') +- else + = link_to edit_project_tag_release_path(project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do + = sprite_icon('pencil', css_class: 'gl-icon') diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 7654150509e..258f662420b 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -40,6 +40,5 @@ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :admin_tag, @project) - = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do - = sprite_icon('pencil', css_class: 'gl-icon') + = render 'edit_release_button', tag: tag, project: @project, release: release = render 'projects/buttons/remove_tag', project: @project, tag: tag diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 2a68ad37c1e..24da8e2db87 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -41,8 +41,7 @@ - if @tag.has_signature? = render partial: 'projects/commit/signature', object: @tag.signature - if can?(current_user, :admin_tag, @project) - = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-icon btn-edit gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do - = sprite_icon("pencil", css_class: 'gl-icon') + = render 'edit_release_button', tag: @tag, project: @project, release: @release = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse files') do = sprite_icon('folder-open', css_class: 'gl-icon') = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse commits') do @@ -58,7 +57,7 @@ = strip_signature(@tag.message) .gl-mb-3.gl-mt-3 - - if @release.description.present? + - if @release&.description.present? .description.md{ data: { qa_selector: 'tag_release_notes_content' } } = markdown_field(@release, :description) - else diff --git a/app/views/projects/tracings/_tracing_button.html.haml b/app/views/projects/tracings/_tracing_button.html.haml deleted file mode 100644 index fe3af1c6a1a..00000000000 --- a/app/views/projects/tracings/_tracing_button.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'gl-button btn btn-confirm' do - = _('Add Jaeger URL') diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml deleted file mode 100644 index 61f2cd8ac7f..00000000000 --- a/app/views/projects/tracings/show.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- @content_class = "limit-container-width" unless fluid_layout -- page_title _("Tracing") - -.gl-alert.gl-alert-danger.gl-mb-5 - - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188' - - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url } - - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976' - - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url } - - link_end = '</a>'.html_safe - .gl-alert-container - = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-title - = s_('Deprecations|Feature deprecation and removal') - .gl-alert-body - %p - = html_escape(s_('Deprecations|The logs and tracing features were deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end } - -- if @project.tracing_external_url.present? - %h1.page-title.gl-font-size-h-display= _('Tracing') - .gl-alert.gl-alert-info.gl-mb-5 - .gl-alert-container - = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.") - - jaeger_link = link_to('Jaeger tracing', 'https://www.jaegertracing.io/', target: "_blank", rel: "noreferrer") - %p.light= _("GitLab uses %{jaeger_link} to monitor distributed systems.").html_safe % { jaeger_link: jaeger_link } - - - .card - - iframe_permissions = "allow-forms allow-scripts allow-same-origin allow-popups" - %iframe.border-0{ src: sanitize(@project.tracing_external_url, scrubber: Rails::Html::TextOnlyScrubber.new), width: '100%', height: 970, sandbox: iframe_permissions } -- else - .row.empty-state - .col-12 - .svg-content - = image_tag 'illustrations/monitoring/tracing.svg' - - .col-12 - .text-content - %h4.text-left= _('Troubleshoot and monitor your application with tracing') - %p - - jaeger_help_url = "https://www.jaegertracing.io/docs/getting-started/" - - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url } - - link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe - = _('Add a Jaeger URL to replace this page with a link to your Jaeger server. You first need to %{link_start_tag}install Jaeger%{link_end_tag}.').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag } - - .text-center - = render 'tracing_button' diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index 9043b8e60fc..d24cfd61052 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -1,5 +1,5 @@ = form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f| - = form_errors(@trigger) + = form_errors(@trigger, pajamas_alert: true) - if @trigger.token .form-group diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml index 1f36afc48aa..8575fd10ad3 100644 --- a/app/views/projects/work_items/index.html.haml +++ b/app/views/projects/work_items/index.html.haml @@ -1,3 +1,5 @@ - page_title s_('WorkItem|Work Items') +- add_page_specific_style 'page_bundles/work_items' #js-work-items{ data: work_items_index_data(@project) } += render 'projects/invite_members_modal', project: @project diff --git a/app/views/pwa/offline.html.haml b/app/views/pwa/offline.html.haml index 5eae546bea9..cd1fca5d2b5 100644 --- a/app/views/pwa/offline.html.haml +++ b/app/views/pwa/offline.html.haml @@ -1,5 +1,5 @@ = link_to root_path do - = render 'shared/logo.svg' + = render partial: 'shared/logo', formats: :svg %h1= _('Offline') .container %h3= _('You are currently offline, or the GitLab instance is not reachable.') diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml index 608a0ca37d9..ab5f2fb1772 100644 --- a/app/views/shared/_allow_request_access.html.haml +++ b/app/views/shared/_allow_request_access.html.haml @@ -1,3 +1,3 @@ = form.gitlab_ui_checkbox_component :request_access_enabled, - _('Allow users to request access (if visibility is public or internal)'), + _('Users can request access (if visibility is public or internal)'), checkbox_options: { data: { qa_selector: 'request_access_checkbox' } } diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 5ae99474c70..db5e055a1c4 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -3,53 +3,4 @@ - group_path << parent.full_path + '/' if parent -- if Feature::enabled?(:group_name_path_vue, current_user) - = render 'shared/groups/group_name_and_path_fields', f: f -- else - .row - .form-group.group-name-holder.col-sm-12 - = f.label :name, class: 'label-bold' do - = s_('Groups|Group name') - = f.text_field :name, placeholder: _('My awesome group'), class: 'js-autofill-group-name form-control input-lg', data: { qa_selector: 'group_name_field' }, - required: true, - title: s_('Groups|Enter a descriptive name for your group.'), - autofocus: true - .text-muted - = s_('Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.') - - .row - .form-group.col-xs-12.col-sm-8 - = f.label :path, class: 'label-bold' do - = s_('Groups|Group URL') - .input-group.gl-field-error-anchor - .group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' } - .input-group-text - %span>= root_url - - if parent - %strong= parent.full_path + '/' - = f.hidden_field :parent_id - = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path js-autofill-group-path', data: { qa_selector: 'group_path_field' }, - autofocus: local_assigns[:autofocus] || false, required: true, - pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, - title: group_url_error_message, - maxlength: ::Namespace::URL_MAX_LENGTH, - "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - %p.validation-error.gl-field-error.field-validation.hide - = s_('Groups|Group path is unavailable. Path has been replaced with a suggested available path.') - %p.validation-success.gl-field-success.field-validation.hide= s_('Groups|Group path is available.') - %p.validation-pending.gl-field-error-ignore.field-validation.hide= s_('Groups|Checking group URL availability...') - - - if @group.persisted? - .gl-alert.gl-alert-warning.gl-mt-3.gl-mb-3 - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - = s_('Groups|Changing group URL can have unintended side effects.') - = succeed '.' do - = link_to s_('Groups|Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', class: 'gl-link' - - - if @group.persisted? - .row - .form-group.group-name-holder.col-sm-8 - = f.label :id, class: 'label-bold' do - = s_('Groups|Group ID') - = f.text_field :id, class: 'form-control', readonly: true += render 'shared/groups/group_name_and_path_fields', f: f diff --git a/app/views/shared/_integration_settings.html.haml b/app/views/shared/_integration_settings.html.haml index 84710b2ecc7..d58be0f0f4a 100644 --- a/app/views/shared/_integration_settings.html.haml +++ b/app/views/shared/_integration_settings.html.haml @@ -1,4 +1,4 @@ -= form_errors(integration) += form_errors(integration, pajamas_alert: true) %div{ data: { testid: "integration-settings-form" } } - if @default_integration diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index af5657e0e14..c0bc50fef5b 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -31,22 +31,19 @@ %ul - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group) %li - %button.js-promote-project-label-button.gl-button.btn.btn-default-tertiary{ disabled: true, type: 'button', - data: { url: promote_project_label_path(label.project, label), - label_title: label.title, - label_color: label.color, - label_text_color: label.text_color, - group_name: label.project.group.name } } + = render Pajamas::ButtonComponent.new(category: :tertiary, + button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } } ) do = _('Promote to group label') %li %span - %button.text-danger.js-delete-label-modal-button{ type: 'button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } } + = render Pajamas::ButtonComponent.new(category: :tertiary, + button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } } ) do = _('Delete') - if current_user %li.gl-display-inline-block.label-subscription.js-label-subscription.gl-ml-3 - if label.can_subscribe_to_label_in_different_levels? - %button.js-unsubscribe-button.gl-button.btn.btn-default.gl-w-full{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } - %span.gl-button-text= _('Unsubscribe') + = render Pajamas::ButtonComponent.new(button_options: { class: "js-unsubscribe-button #{('hidden' if status.unsubscribed?)}", data: { url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title } ) do + = _('Unsubscribe') .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) } = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-w-full', data: { toggle: 'dropdown' } }) do = _('Subscribe') @@ -54,11 +51,11 @@ .dropdown-menu.dropdown-open-left %ul %li - %button.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_project_label_path(@project, label) } } - %span.gl-button-text= _('Subscribe at project level') + = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button #{('hidden' unless status.unsubscribed?)}", data: { status: status, url: toggle_subscription_project_label_path(@project, label) } } ) do + = _('Subscribe at project level') %li - %button.js-subscribe-button.js-group-level{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } } - %span.gl-button-text= _('Subscribe at group level') + = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button js-group-level #{('hidden' unless status.unsubscribed?)}", data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } } ) do + = _('Subscribe at group level') - else - %button.gl-button.js-subscribe-button.btn.btn-default.gl-w-full{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } - %span.gl-button-text= label_subscription_toggle_button_text(label, @project) + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title } ) do + = label_subscription_toggle_button_text(label, @project) diff --git a/app/views/shared/_old_visibility_level.html.haml b/app/views/shared/_old_visibility_level.html.haml index 104c722ee65..6bcac2b0e6b 100644 --- a/app/views/shared/_old_visibility_level.html.haml +++ b/app/views/shared/_old_visibility_level.html.haml @@ -1,6 +1,5 @@ -.form-group.row - .col-sm-2.col-form-label +%fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label = _('Visibility level') - = link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), target: '_blank', rel: 'noopener noreferrer' - .col-sm-10 - = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label + = link_to sprite_icon('question-o'), help_page_path('user/public_access'), target: '_blank', rel: 'noopener noreferrer' + = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml index 3e30dcaf35a..763ae5a498b 100644 --- a/app/views/shared/_visibility_level.html.haml +++ b/app/views/shared/_visibility_level.html.haml @@ -1,11 +1,11 @@ - with_label = local_assigns.fetch(:with_label, true) -.form-group.visibility-level-setting +.visibility-level-setting - if with_label = f.label :visibility_level, _('Visibility level'), class: 'label-bold gl-mb-0' %p = _('Who can see this group?') - - visibility_docs_path = help_page_path('public_access/public_access') + - visibility_docs_path = help_page_path('user/public_access') - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: visibility_docs_path } = _('%{docs_link_start}Learn about visibility levels.%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } - if can_change_visibility_level diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml index 760fe18ddec..1bac75e0ff5 100644 --- a/app/views/shared/_visibility_radios.html.haml +++ b/app/views/shared/_visibility_radios.html.haml @@ -2,15 +2,13 @@ - selected_level = snippets_selected_visibility_level(available_visibility_levels, selected_level) - available_visibility_levels.each do |level| - .form-check - = form.radio_button model_method, level, checked: (selected_level == level), class: 'form-check-input', data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" } - = form.label "#{model_method}_#{level}", class: 'form-check-label' do - = visibility_level_icon(level) - .option-title - = visibility_level_label(level) - .option-description - = visibility_level_description(level, form_model) - .option-disabled-reason + + = form.gitlab_ui_radio_component model_method, level, + "#{visibility_level_icon(level)} #{visibility_level_label(level)}".html_safe, + help_text: '<span class="option-description">%{visibility_level_description}</span><span class="option-disabled-reason"></span>'.html_safe % { visibility_level_description: visibility_level_description(level, form_model)}, + radio_options: { checked: (selected_level == level), data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" } }, + label_options: { class: 'js-visibility-level-radio' } + .text-muted - if all_visibility_levels_restricted? diff --git a/app/views/shared/admin/_admin_note_form.html.haml b/app/views/shared/admin/_admin_note_form.html.haml index 0bc26f9120f..09b059774bc 100644 --- a/app/views/shared/admin/_admin_note_form.html.haml +++ b/app/views/shared/admin/_admin_note_form.html.haml @@ -1,6 +1,4 @@ -.form-group.row +.form-group.gl-form-group{ role: 'group' } = f.fields_for :admin_note do |an| - .col-sm-2.col-form-label.gl-text-right - = an.label :note, s_('Admin|Admin notes') - .col-sm-10 - = an.text_area :note, class: 'form-control' + = an.label :note, s_('Admin|Admin notes'), class: 'gl-display-block col-form-label' + = an.text_area :note, class: 'form-control gl-form-input gl-form-textarea' diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 4ab93030638..38985319ca5 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -13,14 +13,19 @@ = form.label :key, class: 'col-form-label col-sm-2' .col-sm-10 %p.light - - link_start = "<a href='#{help_page_path('ssh/index')}' target='_blank' rel='noreferrer noopener'>".html_safe + - link_start = "<a href='#{help_page_path('user/ssh')}' target='_blank' rel='noreferrer noopener'>".html_safe - link_end = '</a>' = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { qa_selector: 'deploy_key_field' } - else - = form.label :fingerprint, class: 'col-form-label col-sm-2' - .col-sm-10 - = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' + - if deploy_key.fingerprint_sha256.present? + = form.label :fingerprint, _('Fingerprint (SHA256)'), class: 'col-form-label col-sm-2' + .col-sm-10 + = form.text_field :fingerprint_sha256, class: 'form-control gl-form-input', readonly: 'readonly' + - if deploy_key.fingerprint.present? + = form.label :fingerprint, _('Fingerprint (MD5)'), class: 'col-form-label col-sm-2' + .col-sm-10 + = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' - if deploy_keys_project.present? = form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form| diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index c9edf09b350..4bedce71c0f 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f| - = form_errors(@deploy_keys.new_key) + = form_errors(@deploy_keys.new_key, pajamas_alert: true) .form-group.row = f.label :title, class: "label-bold" = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'deploy_key_title_field' } @@ -9,7 +9,7 @@ .form-group.row %p.light.gl-mb-0 = _('Paste a public key here.') - = link_to _('How do I generate it?'), help_page_path("ssh/index") + = link_to _('How do I generate it?'), help_page_path("user/ssh") = f.fields_for :deploy_keys_projects do |deploy_keys_project_form| .form-group.row diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml index b40e2630011..9810754f52b 100644 --- a/app/views/shared/doorkeeper/applications/_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f| - = form_errors(@application) + = form_errors(@application, pajamas_alert: true) .form-group = f.label :name, class: 'label-bold' diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index 0359c28794c..b14ff9b2508 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -55,7 +55,7 @@ .oauth-authorized-applications.prepend-top-20.gl-mb-3 - if oauth_applications_enabled %h5 - = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size } + = _("Authorized applications (%{size})") % { size: @authorized_tokens.size } - if @authorized_tokens.any? .table-responsive @@ -67,22 +67,22 @@ %th= _('Scope') %th %tbody - - @authorized_apps.each do |app| - - token = app.authorized_tokens.order('created_at desc').first # rubocop: disable CodeReuse/ActiveRecord - %tr{ id: "application_#{app.id}" } - %td= app.name - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', application: app - - @authorized_anonymous_tokens.each do |token| - %tr + - @authorized_tokens.each do |token| + %tr{ id: ("application_#{token.application.id}" if token.application) } %td - = _('Anonymous') - .form-text.text-muted - %em= _("Authorization was granted by entering your username and password in the application.") + - if token.application + = token.application.name + - else + = _('Anonymous') + .form-text.text-muted + %em= _("Authorization was granted by entering your username and password in the application.") %td= token.created_at %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', token: token + %td + - if token.application + = render 'doorkeeper/authorized_applications/delete_form', application: token.application + - else + = render 'doorkeeper/authorized_applications/delete_form', token: token - else .settings-message.text-center = _("You don't have any authorized applications") diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index 20ca7954479..a006a3bc0a4 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -4,15 +4,15 @@ .col-12 .svg-content = image_tag 'illustrations/snippets_empty.svg', data: { qa_selector: 'svg_content' } - .text-content.text-center.pt-0 + .text-content.gl-text-center.gl-pt-0 - if current_user %h4 = s_('SnippetsEmptyState|Code snippets') - %p.mb-0 + %p.gl-mb-0 = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.') - .mt-2< + .gl-mt-3< - if button_path = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-confirm', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' } = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation') - else - %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.') + %h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml index ca1d3d53f16..131c450ddd7 100644 --- a/app/views/shared/form_elements/_apply_template_warning.html.haml +++ b/app/views/shared/form_elements/_apply_template_warning.html.haml @@ -7,7 +7,7 @@ %p = _("Applying a template will replace the existing issue description. Any changes you have made will be lost.") - %button.js-override-template.btn.gl-button.btn-confirm.mr-2{ type: 'button' } + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3 js-override-template' }) do = _("Apply template") - %button.js-close-btn.js-cancel-btn.btn.gl-button.btn-default{ type: 'button' } + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close-btn js-cancel-btn' }) do = _("Cancel") diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index a87aa8de679..2c46b2191c6 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -21,7 +21,7 @@ classes: 'note-textarea rspec-issuable-form-description', placeholder: placeholder, supports_quick_actions: supports_quick_actions, - qa_selector: 'issuable_form_description' + qa_selector: 'issuable_form_description_field' = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .clearfix .error-alert diff --git a/app/views/shared/groups/_group_name_and_path_fields.html.haml b/app/views/shared/groups/_group_name_and_path_fields.html.haml index 709130a47d3..634b8448535 100644 --- a/app/views/shared/groups/_group_name_and_path_fields.html.haml +++ b/app/views/shared/groups/_group_name_and_path_fields.html.haml @@ -1,5 +1,5 @@ .js-group-name-and-path{ data: group_name_and_path_app_data(@group) } = f.hidden_field :name, data: { js_name: 'name' } = f.hidden_field :path, maxlength: ::Namespace::URL_MAX_LENGTH, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, data: { js_name: 'path' } - = f.hidden_field :parent_id, data: { js_name: 'parentId' } + = f.hidden_field :parent_id, value: @group.parent&.id, data: { js_name: 'parentId' } = f.hidden_field :id, data: { js_name: 'groupId' } diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index 8409f224158..0bec94f70ea 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -26,16 +26,16 @@ = render_if_exists 'shared/issuable/epic_dropdown', parent: @project.group .block .title + = _('Labels') + .filter-item.labels-filter + = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true + .block + .title = _('Milestone') .filter-item = dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, use_id: true, default_label: _("Milestone") } }) - if is_issue = render_if_exists 'shared/issuable/iterations_dropdown', parent: @project.group - .block - .title - = _('Labels') - .filter-item.labels-filter - = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true - if is_issue = render_if_exists 'shared/issuable/health_status_dropdown', parent: @project .block diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index da49a301087..e90ea35f28e 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -67,11 +67,11 @@ = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2', data: { track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - if issuable.new_record? - = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default' + = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default js-reset-autosave' - else - = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default' + = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default js-reset-autosave' - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project) - = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: _('%{issuableType} will be removed! Are you sure?') % { issuableType: issuable.human_class_name } }, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right' + = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: _('%{issuableType} will be removed! Are you sure?') % { issuableType: issuable.human_class_name } }, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right js-reset-autosave' - if issuable.respond_to?(:issue_type) = form.hidden_field :issue_type diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml index c9dda22de46..f9c70236c8f 100644 --- a/app/views/shared/issuable/form/_contribution.html.haml +++ b/app/views/shared/issuable/form/_contribution.html.haml @@ -9,11 +9,10 @@ %hr -.form-group.row - %label.col-form-label.col-sm-2.pt-sm-0 +.form-group + %label = _('Contribution') - .col-sm-10 - = form.gitlab_ui_checkbox_component :allow_collaboration, - _('Allow commits from members who can merge to the target branch. %{link_start}About this feature.%{link_end}').html_safe % { link_start: contribution_help_link_start, link_end: '</a>'.html_safe }, - checkbox_options: { disabled: !issuable.can_allow_collaboration?(current_user) }, - help_text: allow_collaboration_unavailable_reason(issuable) + = form.gitlab_ui_checkbox_component :allow_collaboration, + _('Allow commits from members who can merge to the target branch. %{link_start}About this feature.%{link_end}').html_safe % { link_start: contribution_help_link_start, link_end: '</a>'.html_safe }, + checkbox_options: { disabled: !issuable.can_allow_collaboration?(current_user) }, + help_text: allow_collaboration_unavailable_reason(issuable) diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index d5c696b1698..a94ef70b2d5 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -1,35 +1,35 @@ - return unless issuable.supports_issue_type? && can?(current_user, :create_issue, @project) .form-group - = form.label :type, _('Type') - .gl-display-flex.gl-align-items-center - .issuable-form-select-holder.selectbox.form-group.gl-mb-0 - .dropdown.js-issuable-type-filter-dropdown-wrap - %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span.dropdown-toggle-text.is-default - = issuable.issue_type.capitalize || _("Select type") - = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") - .dropdown-menu.dropdown-menu-selectable.dropdown-select - .dropdown-title.gl-display-flex - %span.gl-ml-auto - = _("Select type") - %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') } - = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon') - .dropdown-content{ data: { testid: 'issue-type-select-dropdown' } } - %ul - - if create_issue_type_allowed?(@project, :issue) - %li.js-filter-issuable-type - = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do - #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_('Issue')} - - if create_issue_type_allowed?(@project, :incident) - %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } } - = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do - #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_('Incident')} - + = form.label :type do + = _('Type') #js-type-popover - - if issuable.incident? - %p.form-text.text-muted - - incident_docs_url = help_page_path('operations/incident_management/incidents.md') - - 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 + .issuable-form-select-holder.selectbox.form-group.gl-mb-0.gl-display-block + .dropdown.js-issuable-type-filter-dropdown-wrap + %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } + %span.dropdown-toggle-text.is-default + = issuable.issue_type.capitalize || _("Select type") + = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") + .dropdown-menu.dropdown-menu-selectable.dropdown-select + .dropdown-title.gl-display-flex + %span.gl-ml-auto + = _("Select type") + %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') } + = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon') + .dropdown-content{ data: { testid: 'issue-type-select-dropdown' } } + %ul + - if create_issue_type_allowed?(@project, :issue) + %li.js-filter-issuable-type + = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do + #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_('Issue')} + - if create_issue_type_allowed?(@project, :incident) + %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } } + = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do + #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_('Incident')} + + - if issuable.incident? + %p.form-text.text-muted + - incident_docs_url = help_page_path('operations/incident_management/incidents.md') + - 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/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index 7c5b3fd4b3c..39e7d196965 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -18,6 +18,7 @@ = render 'projects/issues/design_management' = render_if_exists 'projects/issues/work_item_links' + = render_if_exists 'projects/issues/linked_resources' = render_if_exists 'projects/issues/related_issues' #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index ba0e5e492f4..23f78f4be45 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -45,7 +45,8 @@ · %span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_s(:medium) if member.expires?) } - if member.expires? - = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(member.expires_at) } + - preposition = current_user.time_display_relative ? '' : 'on' + = _("Expires %{preposition} %{expires_at}").html_safe % { expires_at: time_ago_with_tooltip(member.expires_at), preposition: preposition } - else = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml index 541d7a52385..18db556e024 100644 --- a/app/views/shared/milestones/_header.html.haml +++ b/app/views/shared/milestones/_header.html.haml @@ -11,23 +11,21 @@ .milestone-buttons - if can?(current_user, :admin_milestone, @group || @project) - = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn gl-button btn-grouped' + = render Pajamas::ButtonComponent.new(href: edit_milestone_path(milestone), button_options: { class: 'btn-grouped' }) do + = _('Edit') - if milestone.project_milestone? && milestone.project.group - %button.js-promote-project-milestone-button.btn.gl-button.btn-grouped{ data: { milestone_title: milestone.title, - group_name: milestone.project.group.name, - url: promote_project_milestone_path(milestone.project, milestone)}, - disabled: true, - type: 'button' } + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-promote-project-milestone-button btn-grouped', data: { milestone_title: milestone.title, group_name: milestone.project.group.name, url: promote_project_milestone_path(milestone.project, milestone) }, disabled: true }) do = _('Promote') #promote-milestone-modal - if milestone.active? - = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn gl-button btn-grouped btn-close' + = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), button_options: { class: 'btn-grouped btn-close', data: { method: 'put' }, rel: 'nofollow' }) do + = _('Close milestone') - else - = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped' + = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), button_options: { class: 'btn-grouped', data: { method: 'put' }, rel: 'nofollow' }) do + = _('Reopen milestone') = render 'shared/milestones/delete_button' - %button.btn.gl-button.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' } - = sprite_icon('chevron-double-lg-left') + = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', button_options: { 'aria-label' => _('Toggle sidebar'), class: 'btn-grouped gl-float-right! gl-sm-display-none js-sidebar-toggle' }) diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index 024b06fe97a..e0079a95cec 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for runner, url: runner_form_url do |f| - = form_errors(runner) + = form_errors(runner, pajamas_alert: true) .form-group.row = label :active, _("Active"), class: 'col-form-label col-sm-2' .col-sm-10 diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 5f511b35b61..33b0e1f693e 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -1,2 +1,2 @@ - available_visibility_levels = available_visibility_levels(@snippet) -#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } } +#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("user/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } } diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index afe72767b9a..fe68244f1da 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -1,4 +1,4 @@ -= form_errors(hook) += form_errors(hook, pajamas_alert: true) .form-group = form.label :url, s_('Webhooks|URL'), class: 'label-bold' diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml index 6bbce6b80d8..fc56a191cad 100644 --- a/app/views/shared/wikis/edit.html.haml +++ b/app/views/shared/wikis/edit.html.haml @@ -1,5 +1,7 @@ - wiki_page_title @page, @page.persisted? ? _('Edit') : _('New') - add_page_specific_style 'page_bundles/wiki' +- @gfm_form = true +- @noteable_type = 'Wiki' - if @error #js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } } diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index 3b0186e84e1..b62440fcbde 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -3,4 +3,7 @@ @#{@user.username} - if can?(current_user, :read_user_profile, @user) = render 'middle_dot_divider' do + = s_('UserProfile|User ID: %{id}') % { id: @user.id } + = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) + = render 'middle_dot_divider' do = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index ab75abff9ba..966a1202db2 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -219,6 +219,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:ci_runners_reconcile_existing_runner_versions_cron + :worker_name: Ci::Runners::ReconcileExistingRunnerVersionsCronWorker + :feature_category: :runner_fleet + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:ci_schedule_delete_objects_cron :worker_name: Ci::ScheduleDeleteObjectsCronWorker :feature_category: :continuous_integration @@ -948,15 +957,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: gcp_cluster:clusters_applications_activate_service - :worker_name: Clusters::Applications::ActivateServiceWorker - :feature_category: :kubernetes_management - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: gcp_cluster:clusters_applications_deactivate_integration :worker_name: Clusters::Applications::DeactivateIntegrationWorker :feature_category: :kubernetes_management @@ -966,15 +966,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: gcp_cluster:clusters_applications_deactivate_service - :worker_name: Clusters::Applications::DeactivateServiceWorker - :feature_category: :kubernetes_management - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: gcp_cluster:clusters_applications_uninstall :worker_name: Clusters::Applications::UninstallWorker :feature_category: :kubernetes_management @@ -1038,6 +1029,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_import_issue_event + :worker_name: Gitlab::GithubImport::ImportIssueEventWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_import_lfs_object :worker_name: Gitlab::GithubImport::ImportLfsObjectWorker :feature_category: :importers @@ -1110,6 +1110,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_stage_import_issue_events + :worker_name: Gitlab::GithubImport::Stage::ImportIssueEventsWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_stage_import_issues_and_diff_notes :worker_name: Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker :feature_category: :importers @@ -1452,6 +1461,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: package_cleanup:packages_cleanup_execute_policy + :worker_name: Packages::Cleanup::ExecutePolicyWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_cleanup:packages_cleanup_package_file :worker_name: Packages::CleanupPackageFileWorker :feature_category: :package_registry @@ -2344,6 +2362,15 @@ :weight: 2 :idempotent: false :tags: [] +- :name: google_cloud_create_cloudsql_instance + :worker_name: GoogleCloud::CreateCloudsqlInstanceWorker + :feature_category: :not_owned + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: group_destroy :worker_name: GroupDestroyWorker :feature_category: :subgroups @@ -2389,6 +2416,15 @@ :weight: 2 :idempotent: true :tags: [] +- :name: incident_management_close_incident + :worker_name: IncidentManagement::CloseIncidentWorker + :feature_category: :incident_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: integrations_create_external_cross_reference :worker_name: Integrations::CreateExternalCrossReferenceWorker :feature_category: :integrations @@ -2425,15 +2461,6 @@ :weight: 2 :idempotent: false :tags: [] -- :name: irker - :worker_name: IrkerWorker - :feature_category: :integrations - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: issuable_export_csv :worker_name: IssuableExportCsvWorker :feature_category: :team_planning @@ -2704,14 +2731,14 @@ :weight: 1 :idempotent: false :tags: [] -- :name: pages_transfer - :worker_name: PagesTransferWorker +- :name: pages_invalidate_domain_cache + :worker_name: Pages::InvalidateDomainCacheWorker :feature_category: :pages :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: false + :idempotent: true :tags: [] - :name: phabricator_import_import_tasks :worker_name: Gitlab::PhabricatorImport::ImportTasksWorker @@ -2767,15 +2794,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: project_service - :worker_name: ProjectServiceWorker - :feature_category: :integrations - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: projects_after_import :worker_name: Projects::AfterImportWorker :feature_category: :importers @@ -3092,15 +3110,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: web_hooks_destroy - :worker_name: WebHooks::DestroyWorker - :feature_category: :integrations - :has_external_dependencies: false - :urgency: :high - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: web_hooks_log_destroy :worker_name: WebHooks::LogDestroyWorker :feature_category: :integrations diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index afe14369d43..4312ba41367 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -14,17 +14,6 @@ class AuthorizedProjectsWorker idempotent! loggable_arguments 1 # For the job waiter key - # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the - # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231 - # for more details. - if Rails.env.test? - def self.bulk_perform_and_wait(args_list, timeout: 10) - end - - def self.bulk_perform_inline(args_list) - end - end - def perform(user_id) user = User.find_by_id(user_id) diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 78244e0941e..5c08344bfe3 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -18,4 +18,16 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker .try(:execute_hooks) end # rubocop: enable CodeReuse/ActiveRecord + + def self.perform_async(build) + Gitlab::AppLogger.info( + message: "Enqueuing hooks for Build #{build.id}: #{build.status}", + class: self.name, + build_id: build.id, + pipeline_id: build.pipeline_id, + project_id: build.project_id, + build_status: build.status) + + super(build.id) + end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 9c95e25e2e8..e171ec1e194 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -53,12 +53,8 @@ module BulkImports pipeline_tracker.update!(status_event: 'start', jid: jid) pipeline_tracker.pipeline_class.new(context).run pipeline_tracker.finish! - rescue BulkImports::NetworkError => e - if e.retriable?(pipeline_tracker) - retry_tracker(e) - else - fail_tracker(e) - end + rescue BulkImports::RetryPipelineError => e + retry_tracker(e) rescue StandardError => e fail_tracker(e) end diff --git a/app/workers/ci/archive_trace_worker.rb b/app/workers/ci/archive_trace_worker.rb index 47d77c15b4a..edbaf0536a2 100644 --- a/app/workers/ci/archive_trace_worker.rb +++ b/app/workers/ci/archive_trace_worker.rb @@ -4,17 +4,13 @@ module Ci class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - data_consistency :sticky, feature_flag: :sticky_ci_archive_trace_worker + data_consistency :sticky sidekiq_options retry: 3 include PipelineBackgroundQueue def perform(job_id) - archivable_jobs = Ci::Build.without_archived_trace - - if Feature.enabled?(:sticky_ci_archive_trace_worker) - archivable_jobs = archivable_jobs.eager_load_for_archiving_trace - end + archivable_jobs = Ci::Build.without_archived_trace.eager_load_for_archiving_trace archivable_jobs.find_by_id(job_id).try do |job| Ci::ArchiveTraceService.new.execute(job, worker_name: self.class.name) diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb index 2d7f3a67004..25c7637a79f 100644 --- a/app/workers/ci/build_finished_worker.rb +++ b/app/workers/ci/build_finished_worker.rb @@ -37,9 +37,10 @@ module Ci Ci::BuildReportResultService.new.execute(build) # We execute these async as these are independent operations. - BuildHooksWorker.perform_async(build.id) + BuildHooksWorker.perform_async(build) ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? build.track_deployment_usage + build.track_verify_usage if build.failed? && !build.auto_retry_expected? ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id) @@ -57,15 +58,7 @@ module Ci # See https://gitlab.com/gitlab-org/gitlab/-/issues/267112 for more # details. # - archive_trace_worker_class(build).perform_in(ARCHIVE_TRACES_IN, build.id) - end - - def archive_trace_worker_class(build) - if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project) - Ci::ArchiveTraceWorker - else - ::ArchiveTraceWorker - end + Ci::ArchiveTraceWorker.perform_in(ARCHIVE_TRACES_IN, build.id) end end end diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb index 8ee518e3ae6..127eb3b6f44 100644 --- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb @@ -15,8 +15,14 @@ module Ci idempotent! def perform(pipeline_id) - Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - Ci::PipelineArtifacts::CoverageReportService.new(pipeline).execute + pipeline = Ci::Pipeline.find_by_id(pipeline_id) + + return unless pipeline + + pipeline.root_ancestor.try do |root_ancestor_pipeline| + next unless root_ancestor_pipeline.self_and_descendants_complete? + + Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute end end end diff --git a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb new file mode 100644 index 00000000000..035b2563e56 --- /dev/null +++ b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ci + module Runners + class ReconcileExistingRunnerVersionsCronWorker + include ApplicationWorker + + # This worker does not schedule other workers that require context. + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :runner_fleet + urgency :low + + idempotent! + + def perform + result = ::Ci::Runners::ReconcileExistingRunnerVersionsService.new.execute + result.each { |key, value| log_extra_metadata_on_done(key, value) } + end + end + end +end diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb deleted file mode 100644 index abc84bcd093..00000000000 --- a/app/workers/clusters/applications/activate_service_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -# This worker was renamed in 15.1, we can delete it in 15.2. -# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112 -# -# rubocop:disable Scalability/IdempotentWorker -module Clusters - module Applications - class ActivateServiceWorker < ActivateIntegrationWorker - end - end -end diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb deleted file mode 100644 index 88219b8b17e..00000000000 --- a/app/workers/clusters/applications/deactivate_service_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -# This worker was renamed in 15.1, we can delete it in 15.2. -# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112 -# -# rubocop:disable Scalability/IdempotentWorker -module Clusters - module Applications - class DeactivateServiceWorker < DeactivateIntegrationWorker - end - end -end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index e1f404b250d..c2cd50d8c21 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -23,6 +23,12 @@ module Gitlab # client - An instance of `Gitlab::GithubImport::Client` # hash - A Hash containing the details of the object to import. def import(project, client, hash) + if project.import_state&.canceled? + info(project.id, message: 'project import canceled') + + return + end + object = representation_class.from_json_hash(hash) # To better express in the logs what object is being imported. diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 225716f6bf3..b12c2311ea8 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -9,6 +9,12 @@ module Gitlab return unless (project = find_project(project_id)) + if project.import_state&.canceled? + info(project_id, message: 'project import canceled') + + return + end + client = GithubImport.new_client_for(project) try_import(client, project) diff --git a/app/workers/concerns/packages/cleanup_artifact_worker.rb b/app/workers/concerns/packages/cleanup_artifact_worker.rb index a01d7e8abba..7e647ddd229 100644 --- a/app/workers/concerns/packages/cleanup_artifact_worker.rb +++ b/app/workers/concerns/packages/cleanup_artifact_worker.rb @@ -9,14 +9,21 @@ module Packages def perform_work return unless artifact - artifact.transaction do - log_metadata(artifact) + begin + artifact.transaction do + log_metadata(artifact) - artifact.destroy! - rescue StandardError + artifact.destroy! + end + rescue StandardError => exception unless artifact&.destroyed? artifact&.update_column(:status, :error) end + + Gitlab::ErrorTracking.log_exception( + exception, + class: self.class.name + ) end after_destroy diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index f8b945b8892..336d60d46ac 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -5,25 +5,13 @@ module WaitableWorker class_methods do # Schedules multiple jobs and waits for them to be completed. - def bulk_perform_and_wait(args_list, timeout: 10) + def bulk_perform_and_wait(args_list) # Short-circuit: it's more efficient to do small numbers of jobs inline - return bulk_perform_inline(args_list) if args_list.size <= 3 - - # Don't wait if there's too many jobs to be waited for. Not including the - # waiter allows them to be deduplicated and it skips waiting for jobs that - # are not likely to finish within the timeout. This assumes we can process - # 10 jobs per second: - # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/205 - return bulk_perform_async(args_list) if args_list.length >= 10 * timeout - - waiter = Gitlab::JobWaiter.new(args_list.size, worker_label: self.to_s) - - # Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]] - # into [[1, "key"], [2, "key"], [3, "key"]] - waiting_args_list = args_list.map { |args| [*args, waiter.key] } - bulk_perform_async(waiting_args_list) + if args_list.size == 1 + return bulk_perform_inline(args_list) + end - waiter.wait(timeout) + bulk_perform_async(args_list) end # Performs multiple jobs directly. Failed jobs will be put into sidekiq so diff --git a/app/workers/container_registry/migration/enqueuer_worker.rb b/app/workers/container_registry/migration/enqueuer_worker.rb index f3c8dfa63ad..1dd29eff86e 100644 --- a/app/workers/container_registry/migration/enqueuer_worker.rb +++ b/app/workers/container_registry/migration/enqueuer_worker.rb @@ -125,17 +125,18 @@ module ContainerRegistry def next_repository strong_memoize(:next_repository) do - # Using .limit(2)[0] instead of take here. Using a LIMIT 1 caused the query planner to - # use an inefficient sequential scan instead of picking an index. LIMIT 2 works around + # Using .limit(25)[0] instead of take here. Using a LIMIT 1 and 2 caused the query planner to + # use an inefficient sequential scan instead of picking an index. LIMIT 25 works around # this issue. - # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87733 for details. - ContainerRepository.ready_for_import.limit(2)[0] # rubocop:disable CodeReuse/ActiveRecord + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87733 and + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90735 for details. + ContainerRepository.ready_for_import.limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord end end def next_aborted_repository strong_memoize(:next_aborted_repository) do - ContainerRepository.with_migration_state('import_aborted').limit(2)[0] # rubocop:disable CodeReuse/ActiveRecord + ContainerRepository.with_migration_state('import_aborted').limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord end end diff --git a/app/workers/deployments/hooks_worker.rb b/app/workers/deployments/hooks_worker.rb index 608601b4eb9..62e75638c7d 100644 --- a/app/workers/deployments/hooks_worker.rb +++ b/app/workers/deployments/hooks_worker.rb @@ -16,7 +16,7 @@ module Deployments log_extra_metadata_on_done(:deployment_project_id, deploy.project.id) log_extra_metadata_on_done(:deployment_id, params[:deployment_id]) - deploy.execute_hooks(params[:status_changed_at].to_time) + deploy.execute_hooks(params[:status], params[:status_changed_at].to_time) end end end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 06f0ef623c2..70d18d8004c 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -23,6 +23,7 @@ module Gitlab pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker, pull_request_reviews: Stage::ImportPullRequestsReviewsWorker, issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker, + issue_events: Stage::ImportIssueEventsWorker, notes: Stage::ImportNotesWorker, lfs_objects: Stage::ImportLfsObjectsWorker, finish: Stage::FinishImportWorker diff --git a/app/workers/gitlab/github_import/import_issue_event_worker.rb b/app/workers/gitlab/github_import/import_issue_event_worker.rb new file mode 100644 index 00000000000..d7071d3ee09 --- /dev/null +++ b/app/workers/gitlab/github_import/import_issue_event_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportIssueEventWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Representation::IssueEvent + end + + def importer_class + Importer::IssueEventImporter + end + + def object_type + :issue_event + end + end + 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 new file mode 100644 index 00000000000..8155b910677 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportIssueEventsWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 3 + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter + return skip_to_next_stage(project, importer) if feature_disabled?(project) + + start_importer(project, importer, client) + end + + private + + def start_importer(project, importer, client) + info(project.id, message: "starting importer", importer: importer.name) + waiter = importer.new(project, client).execute + move_to_next_stage(project, waiter.key => waiter.jobs_remaining) + end + + def skip_to_next_stage(project, importer) + info(project.id, message: "skipping importer", importer: importer.name) + move_to_next_stage(project) + end + + def move_to_next_stage(project, waiters = {}) + AdvanceStageWorker.perform_async(project.id, waiters, :notes) + end + + def feature_disabled?(project) + Feature.disabled?(:github_importer_issue_events_import, project.group, type: :ops) + end + end + 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 34996b710d4..7922c1113c4 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 @@ -21,7 +21,7 @@ module Gitlab hash[waiter.key] = waiter.jobs_remaining end - AdvanceStageWorker.perform_async(project.id, waiters, :notes) + AdvanceStageWorker.perform_async(project.id, waiters, :issue_events) end # The importers to run in this stage. Issues can't be imported earlier diff --git a/app/workers/gitlab_service_ping_worker.rb b/app/workers/gitlab_service_ping_worker.rb index 0f7b3ba56a5..a974667e5e0 100644 --- a/app/workers/gitlab_service_ping_worker.rb +++ b/app/workers/gitlab_service_ping_worker.rb @@ -30,8 +30,6 @@ class GitlabServicePingWorker # rubocop:disable Scalability/IdempotentWorker end def usage_data - return unless Feature.enabled?(:prerecord_service_ping_data) - ServicePing::BuildPayload.new.execute.tap do |payload| record = { recorded_at: payload[:recorded_at], diff --git a/app/workers/google_cloud/create_cloudsql_instance_worker.rb b/app/workers/google_cloud/create_cloudsql_instance_worker.rb new file mode 100644 index 00000000000..3c15c59b8d9 --- /dev/null +++ b/app/workers/google_cloud/create_cloudsql_instance_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module GoogleCloud + class CreateCloudsqlInstanceWorker + include ApplicationWorker + + data_consistency :always + feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned + idempotent! + + def perform(user_id, project_id, options = {}) + user = User.find(user_id) + project = Project.find(project_id) + + google_oauth2_token = options[:google_oauth2_token] + gcp_project_id = options[:gcp_project_id] + instance_name = options[:instance_name] + database_version = options[:database_version] + environment_name = options[:environment_name] + is_protected = options[:is_protected] + + params = { + google_oauth2_token: google_oauth2_token, + gcp_project_id: gcp_project_id, + instance_name: instance_name, + database_version: database_version, + environment_name: environment_name, + is_protected: is_protected + } + + response = GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute + + if response[:status] == :error + raise response[:message] + end + end + end +end diff --git a/app/workers/incident_management/close_incident_worker.rb b/app/workers/incident_management/close_incident_worker.rb new file mode 100644 index 00000000000..7d45a6785ea --- /dev/null +++ b/app/workers/incident_management/close_incident_worker.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module IncidentManagement + class CloseIncidentWorker + include ApplicationWorker + + idempotent! + deduplicate :until_executed + data_consistency :always + feature_category :incident_management + urgency :low + + # Issues:CloseService execute webhooks which are treated as external dependencies + worker_has_external_dependencies! + + def perform(issue_id) + incident = Issue.incident.opened.find_by_id(issue_id) + + return unless incident + + close_incident(incident) + add_system_note(incident) + end + + private + + def user + @user ||= User.alert_bot + end + + def close_incident(incident) + ::Issues::CloseService + .new(project: incident.project, current_user: user) + .execute(incident, system_note: false) + end + + def add_system_note(incident) + return unless incident.reset.closed? + + SystemNoteService.auto_resolve_prometheus_alert(incident, incident.project, user) + end + end +end diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb deleted file mode 100644 index a054021e418..00000000000 --- a/app/workers/irker_worker.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -# This worker was renamed in 15.1, we can delete it in 15.2. -# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112 -# -# rubocop: disable Gitlab/NamespacedClass -# rubocop:disable Scalability/IdempotentWorker -class IrkerWorker < Integrations::IrkerWorker -end diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb index 0d04c503fbf..0a3a834578a 100644 --- a/app/workers/loose_foreign_keys/cleanup_worker.rb +++ b/app/workers/loose_foreign_keys/cleanup_worker.rb @@ -34,7 +34,7 @@ module LooseForeignKeys # If two DBs are configured (Main, CI): minute 1 -> Main, minute 2 -> CI def current_connection_name_and_base_model minutes_since_epoch = Time.current.to_i / 60 - connections_with_name = Gitlab::Database.database_base_models.to_a # this will never be empty + connections_with_name = Gitlab::Database.database_base_models_with_gitlab_shared.to_a # this will never be empty connections_with_name[minutes_since_epoch % connections_with_name.count] end end diff --git a/app/workers/packages/cleanup/execute_policy_worker.rb b/app/workers/packages/cleanup/execute_policy_worker.rb new file mode 100644 index 00000000000..59f0f0250c8 --- /dev/null +++ b/app/workers/packages/cleanup/execute_policy_worker.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Packages + module Cleanup + class ExecutePolicyWorker + include ApplicationWorker + include LimitedCapacity::Worker + include Gitlab::Utils::StrongMemoize + + data_consistency :always + queue_namespace :package_cleanup + feature_category :package_registry + urgency :low + worker_resource_boundary :unknown + idempotent! + + COUNTS_KEYS = %i[ + marked_package_files_total_count + unique_package_id_and_file_name_total_count + ].freeze + + def perform_work + return unless next_policy + + log_extra_metadata_on_done(:project_id, next_policy.project_id) + result = ::Packages::Cleanup::ExecutePolicyService.new(next_policy).execute + + if result.success? + timeout = !!result.payload[:timeout] + counts = result.payload[:counts] + log_extra_metadata_on_done(:execution_timeout, timeout) + COUNTS_KEYS.each do |count_key| + log_extra_metadata_on_done(count_key, counts[count_key]) + end + end + end + + def remaining_work_count + ::Packages::Cleanup::Policy.runnable + .limit(max_running_jobs + 1) + .count + end + + def max_running_jobs + ::Gitlab::CurrentSettings.package_registry_cleanup_policies_worker_capacity + end + + private + + def next_policy + strong_memoize(:next_policy) do + ::Packages::Cleanup::Policy.transaction do + # the #lock call is specific to this worker + # rubocop: disable CodeReuse/ActiveRecord + policy = ::Packages::Cleanup::Policy.runnable + .limit(1) + .lock('FOR UPDATE SKIP LOCKED') + .first + # rubocop: enable CodeReuse/ActiveRecord + + next nil unless policy + + policy.set_next_run_at + policy.save! + + policy + end + end + end + end + end +end diff --git a/app/workers/packages/cleanup_package_registry_worker.rb b/app/workers/packages/cleanup_package_registry_worker.rb index a849e055b64..5f14102b5a1 100644 --- a/app/workers/packages/cleanup_package_registry_worker.rb +++ b/app/workers/packages/cleanup_package_registry_worker.rb @@ -12,6 +12,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? log_counts end @@ -22,6 +23,10 @@ module Packages Packages::CleanupPackageFileWorker.perform_with_capacity end + def enqueue_cleanup_policy_jobs + Packages::Cleanup::ExecutePolicyWorker.perform_with_capacity + end + def log_counts use_replica_if_available do pending_destruction_package_files_count = Packages::PackageFile.pending_destruction.count @@ -31,6 +36,9 @@ module Packages log_extra_metadata_on_done(:pending_destruction_package_files_count, pending_destruction_package_files_count) log_extra_metadata_on_done(:processing_package_files_count, processing_package_files_count) log_extra_metadata_on_done(:error_package_files_count, error_package_files_count) + + pending_cleanup_policies_count = Packages::Cleanup::Policy.runnable.count + log_extra_metadata_on_done(:pending_cleanup_policies_count, pending_cleanup_policies_count) end end diff --git a/app/workers/packages/debian/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb index 1eff3ea02dd..822fe5a1517 100644 --- a/app/workers/packages/debian/generate_distribution_worker.rb +++ b/app/workers/packages/debian/generate_distribution_worker.rb @@ -4,6 +4,7 @@ module Packages module Debian class GenerateDistributionWorker include ApplicationWorker + include ::Packages::FIPS data_consistency :always include Gitlab::Utils::StrongMemoize @@ -20,6 +21,8 @@ module Packages loggable_arguments 0 def perform(container_type, distribution_id) + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + @container_type = container_type @distribution_id = distribution_id diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb index 0a716c61203..d477a6f2e1f 100644 --- a/app/workers/packages/debian/process_changes_worker.rb +++ b/app/workers/packages/debian/process_changes_worker.rb @@ -4,6 +4,7 @@ module Packages module Debian class ProcessChangesWorker include ApplicationWorker + include ::Packages::FIPS data_consistency :always include Gitlab::Utils::StrongMemoize @@ -15,6 +16,8 @@ module Packages feature_category :package_registry def perform(package_file_id, user_id) + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + @package_file_id = package_file_id @user_id = user_id @@ -22,6 +25,8 @@ module Packages ::Packages::Debian::ProcessChangesService.new(package_file, user).execute rescue StandardError => e + raise if e.instance_of?(DisabledError) + Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id) package_file.destroy! end diff --git a/app/workers/pages/invalidate_domain_cache_worker.rb b/app/workers/pages/invalidate_domain_cache_worker.rb new file mode 100644 index 00000000000..63b6f5c05b5 --- /dev/null +++ b/app/workers/pages/invalidate_domain_cache_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Pages + class InvalidateDomainCacheWorker + include Gitlab::EventStore::Subscriber + + idempotent! + + feature_category :pages + + def handle_event(event) + if event.data[:project_id] + ::Gitlab::Pages::CacheControl + .for_project(event.data[:project_id]) + .clear_cache + end + + if event.data[:root_namespace_id] + ::Gitlab::Pages::CacheControl + .for_namespace(event.data[:root_namespace_id]) + .clear_cache + end + end + end +end diff --git a/app/workers/pages_transfer_worker.rb b/app/workers/pages_transfer_worker.rb deleted file mode 100644 index 6d3918e7ab6..00000000000 --- a/app/workers/pages_transfer_worker.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class PagesTransferWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - TransferFailedError = Class.new(StandardError) - - feature_category :pages - loggable_arguments 0, 1 - - def perform(method, args) - # noop - # This worker is not necessary anymore and will be removed - # https://gitlab.com/gitlab-org/gitlab/-/issues/340616 - end -end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 45af15216fc..68a0934e2b7 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -131,11 +131,24 @@ class PostReceive repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs) SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks) Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes) + emit_snowplow_event(project, user) end def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end + + def emit_snowplow_event(project, user) + return unless Feature.enabled?(:route_hll_to_snowplow_phase2, project.namespace) + + Gitlab::Tracking.event( + 'PostReceive', + 'source_code_pushes', + project: project, + namespace: project.namespace, + user: user + ) + end end PostReceive.prepend_mod_with('PostReceive') diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb deleted file mode 100644 index 56ac4bc046a..00000000000 --- a/app/workers/project_service_worker.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This worker was renamed in 15.1, we can delete it in 15.2. -# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112 -# -# rubocop: disable Gitlab/NamespacedClass -# rubocop: disable Scalability/IdempotentWorker -class ProjectServiceWorker < Integrations::ExecuteWorker - data_consistency :always - sidekiq_options retry: 3 - sidekiq_options dead: false - feature_category :integrations - urgency :low - - worker_has_external_dependencies! -end diff --git a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb index a91af72cc2c..705bf0534f7 100644 --- a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb +++ b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb @@ -5,10 +5,6 @@ module Projects include ApplicationWorker include LimitedCapacity::Worker - MAX_RUNNING_LOW = 2 - MAX_RUNNING_MEDIUM = 20 - MAX_RUNNING_HIGH = 50 - data_consistency :always feature_category :build_artifacts @@ -37,12 +33,8 @@ module Projects end def max_running_jobs - if ::Feature.enabled?(:projects_build_artifacts_size_refresh_high) - MAX_RUNNING_HIGH - elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_medium) - MAX_RUNNING_MEDIUM - elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_low) - MAX_RUNNING_LOW + if ::Feature.enabled?(:projects_build_artifacts_size_refresh, type: :ops) + 10 else 0 end diff --git a/app/workers/web_hooks/destroy_worker.rb b/app/workers/web_hooks/destroy_worker.rb deleted file mode 100644 index 8f9b194f88a..00000000000 --- a/app/workers/web_hooks/destroy_worker.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module WebHooks - class DestroyWorker - include ApplicationWorker - - DestroyError = Class.new(StandardError) - - data_consistency :always - sidekiq_options retry: 3 - feature_category :integrations - urgency :high - - idempotent! - - def perform(user_id, web_hook_id) - user = User.find_by_id(user_id) - hook = WebHook.find_by_id(web_hook_id) - - return unless user && hook - - result = ::WebHooks::DestroyService.new(user).sync_destroy(hook) - - result.track_and_raise_exception(as: DestroyError, web_hook_id: hook.id) - end - end -end |