diff options
Diffstat (limited to 'app')
1598 files changed, 20479 insertions, 11690 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico Binary files differdeleted file mode 100644 index 48b1095370d..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico Binary files differdeleted file mode 100644 index 623c728faf6..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_created.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico Binary files differdeleted file mode 100644 index 3073fe5a761..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico Binary files differdeleted file mode 100644 index 6c713d7b675..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico Binary files differdeleted file mode 100644 index dbf855fdafd..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico Binary files differdeleted file mode 100644 index ccd00606aeb..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico Binary files differdeleted file mode 100644 index 6cdf3ae2e36..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico Binary files differdeleted file mode 100644 index 968e7c4c2d4..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_running.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico b/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico Binary files differdeleted file mode 100644 index 5444b8e41dc..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico Binary files differdeleted file mode 100644 index 7e3be35cc3a..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico Binary files differdeleted file mode 100644 index a1fb6e91d65..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_success.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico Binary files differdeleted file mode 100644 index 5d931619fb2..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico +++ /dev/null diff --git a/app/assets/images/mr_favicons/favicon_status_merged.png b/app/assets/images/mr_favicons/favicon_status_merged.png Binary files differnew file mode 100644 index 00000000000..0acb2e463a9 --- /dev/null +++ b/app/assets/images/mr_favicons/favicon_status_merged.png diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js new file mode 100644 index 00000000000..02a4a3c2f15 --- /dev/null +++ b/app/assets/javascripts/access_level/constants.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; + +// Matches `lib/gitlab/access.rb` +export const ACCESS_LEVEL_NO_ACCESS_INTEGER = 0; +export const ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER = 5; +export const ACCESS_LEVEL_GUEST_INTEGER = 10; +export const ACCESS_LEVEL_REPORTER_INTEGER = 20; +export const ACCESS_LEVEL_DEVELOPER_INTEGER = 30; +export const ACCESS_LEVEL_MAINTAINER_INTEGER = 40; +export const ACCESS_LEVEL_OWNER_INTEGER = 50; + +export const ACCESS_LEVEL_LABELS = { + [ACCESS_LEVEL_NO_ACCESS_INTEGER]: __('No access'), + [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: __('Minimal Access'), + [ACCESS_LEVEL_GUEST_INTEGER]: __('Guest'), + [ACCESS_LEVEL_REPORTER_INTEGER]: __('Reporter'), + [ACCESS_LEVEL_DEVELOPER_INTEGER]: __('Developer'), + [ACCESS_LEVEL_MAINTAINER_INTEGER]: __('Maintainer'), + [ACCESS_LEVEL_OWNER_INTEGER]: __('Owner'), +}; 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 c66b595ffdc..a5f8f369604 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 @@ -1,10 +1,15 @@ <script> -import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import { createAlert } from '~/alert'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { + OPERATORS_IS, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import eventHub from '../event_hub'; import { findCommitIndex, @@ -12,6 +17,7 @@ import { removeIfReadyToBeRemoved, removeIfPresent, } from '../utils'; +import Token from './token.vue'; export default { components: { @@ -19,9 +25,9 @@ export default { GlTabs, GlTab, ReviewTabContainer, - GlSearchBoxByType, GlSprintf, GlBadge, + GlFilteredSearch, }, props: { contextCommitsPath: { @@ -41,6 +47,49 @@ export default { required: true, }, }, + data() { + return { + availableTokens: [ + { + icon: 'pencil', + title: __('Author'), + type: TOKEN_TYPE_AUTHOR, + operators: OPERATORS_IS, + token: UserToken, + defaultAuthors: [], + unique: true, + fetchAuthors: this.fetchAuthors, + initialAuthors: [], + }, + { + formattedKey: __('Committed-before'), + key: 'committed-before', + type: 'committed-before-date', + param: '', + symbol: '', + icon: 'clock', + tag: 'committed_before', + title: __('Committed-before'), + operators: OPERATORS_IS, + token: Token, + unique: true, + }, + { + formattedKey: __('Committed-after'), + key: 'committed-after', + type: 'committed-after-date', + param: '', + symbol: '', + icon: 'clock', + tag: 'committed_after', + title: __('Committed-after'), + operators: OPERATORS_IS, + token: Token, + unique: true, + }, + ], + }; + }, computed: { ...mapState([ 'tabIndex', @@ -98,8 +147,6 @@ export default { }, beforeDestroy() { eventHub.$off('openModal', this.openModal); - clearTimeout(this.timeout); - this.timeout = null; }, methods: { ...mapActions([ @@ -114,10 +161,8 @@ export default { 'setSearchText', 'setToRemoveCommits', 'resetModalState', + 'fetchAuthors', ]), - focusSearch() { - this.$refs.searchInput.focusInput(); - }, openModal() { this.searchCommits(); this.fetchContextCommits(); @@ -125,7 +170,6 @@ export default { }, handleTabChange(tabIndex) { if (tabIndex === 0) { - this.focusSearch(); if (this.shouldPurge) { this.setSelectedCommits( [...this.commits, ...this.selectedCommits].filter((commit) => commit.isSelected), @@ -133,17 +177,36 @@ export default { } } }, - handleSearchCommits(value) { - // We only call the service, if we have 3 characters or we don't have any characters - if (value.length >= 3) { - clearTimeout(this.timeout); - this.timeout = setTimeout(() => { - this.searchCommits(value); - }, 500); - } else if (value.length === 0) { - this.searchCommits(); + blurSearchInput() { + const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector( + '.gl-filtered-search-token-segment-input', + ); + if (searchInputEl) { + searchInputEl.blur(); } - this.setSearchText(value); + }, + handleSearchCommits(value = []) { + const searchValues = value.reduce((acc, searchFilter) => { + const isEqualSearch = searchFilter?.value?.operator === '='; + + if (!isEqualSearch && typeof searchFilter === 'object') return acc; + + if (typeof searchFilter === 'string' && searchFilter.length >= 3) { + acc.searchText = searchFilter; + } else if (searchFilter?.type === 'author' && searchFilter?.value?.data?.length >= 3) { + acc.author = searchFilter?.value?.data; + } else if (searchFilter?.type === 'committed-before-date') { + acc.committed_before = searchFilter?.value?.data; + } else if (searchFilter?.type === 'committed-after-date') { + acc.committed_after = searchFilter?.value?.data; + } + + return acc; + }, {}); + + this.searchCommits(searchValues); + this.blurSearchInput(); + this.setSearchText(searchValues.searchText); }, handleCommitRowSelect(event) { const index = event[0]; @@ -208,11 +271,12 @@ export default { }, handleModalClose() { this.resetModalState(); - clearTimeout(this.timeout); }, handleModalHide() { this.resetModalState(); - clearTimeout(this.timeout); + }, + shouldShowInputDateFormat(value) { + return ['Committed-before', 'Committed-after'].indexOf(value) !== -1; }, }, }; @@ -223,13 +287,14 @@ export default { ref="modal" cancel-variant="light" size="md" + no-focus-on-show + modal-class="add-review-item-modal" body-class="add-review-item pt-0" :scrollable="true" :ok-title="__('Save changes')" modal-id="add-review-item" :title="__('Add or remove previously merged commits')" :ok-disabled="disableSaveButton" - @shown="focusSearch" @ok="handleCreateContextCommits" @cancel="handleModalClose" @close="handleModalClose" @@ -245,11 +310,24 @@ export default { </gl-sprintf> </template> <div class="gl-mt-3"> - <gl-search-box-by-type - ref="searchInput" - :placeholder="__(`Search by commit title or SHA`)" - @input="handleSearchCommits" - /> + <gl-filtered-search + ref="filteredSearchInput" + class="flex-grow-1" + :placeholder="__(`Search or filter commits`)" + :available-tokens="availableTokens" + @clear="handleSearchCommits" + @submit="handleSearchCommits" + > + <template #title="{ value }"> + <div> + {{ value }} + <span v-if="shouldShowInputDateFormat(value)" class="title-hint-text"> + <{{ __('yyyy-mm-dd') }}> + </span> + </div> + </template> + </gl-filtered-search> + <review-tab-container :is-loading="isLoadingCommits" :loading-error="commitsLoadingError" diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue new file mode 100644 index 00000000000..c403adbbf60 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue @@ -0,0 +1,28 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; + +export default { + components: { + GlFilteredSearchToken, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + val: '', + }; + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" /> +</template> diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js index de9c7488ace..f085b0d0e5e 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/actions.js +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -1,8 +1,11 @@ import _ from 'lodash'; +import * as Sentry from '@sentry/browser'; import Api from '~/api'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants'; import * as types from './mutation_types'; export const setBaseConfig = ({ commit }, options) => { @@ -11,14 +14,14 @@ export const setBaseConfig = ({ commit }, options) => { export const setTabIndex = ({ commit }, tabIndex) => commit(types.SET_TABINDEX, tabIndex); -export const searchCommits = ({ dispatch, commit, state }, searchText) => { +export const searchCommits = ({ dispatch, commit, state }, search = {}) => { commit(types.FETCH_COMMITS); let params = {}; - if (searchText) { + if (search) { params = { params: { - search: searchText, + ...search, per_page: 40, }, }; @@ -37,7 +40,7 @@ export const searchCommits = ({ dispatch, commit, state }, searchText) => { } return c; }); - if (!searchText) { + if (!search) { dispatch('setCommits', { commits: [...commits, ...state.contextCommits] }); } else { dispatch('setCommits', { commits }); @@ -131,6 +134,23 @@ export const setSelectedCommits = ({ commit }, selected) => { commit(types.SET_SELECTED_COMMITS, selectedCommits); }; +export const fetchAuthors = ({ dispatch, state }, author = null) => { + const { projectId } = state; + return axios + .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), { + params: { + project_id: projectId, + states: ACTIVE_AND_BLOCKED_USER_STATES, + search: author, + }, + }) + .then(({ data }) => data) + .catch((error) => { + Sentry.captureException(error); + dispatch('receiveAuthorsError'); + }); +}; + export const setSearchText = ({ commit }, searchText) => commit(types.SET_SEARCH_TEXT, searchText); export const setToRemoveCommits = ({ commit }, data) => commit(types.SET_TO_REMOVE_COMMITS, data); diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js index 0bf3441379b..560834a26ae 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/index.js +++ b/app/assets/javascripts/add_context_commits_modal/store/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters'; import * as actions from './actions'; import mutations from './mutations'; import state from './state'; @@ -12,4 +13,5 @@ export default () => state: state(), actions, mutations, + modules: { filters }, }); diff --git a/app/assets/javascripts/add_context_commits_modal/store/state.js b/app/assets/javascripts/add_context_commits_modal/store/state.js index 37239adccbb..fed3148bc9e 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/state.js +++ b/app/assets/javascripts/add_context_commits_modal/store/state.js @@ -1,4 +1,5 @@ export default () => ({ + projectId: '', contextCommitsPath: '', tabIndex: 0, isLoadingCommits: false, diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue new file mode 100644 index 00000000000..f2271f8af24 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue @@ -0,0 +1,148 @@ +<script> +import { GlButton, GlDropdown, GlModal } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { ACTIONS_I18N } from '../constants'; + +const modalActionButtonAttributes = { + block: { + text: __('OK'), + attributes: { + variant: 'confirm', + }, + }, + removeUserAndReport: { + text: __('OK'), + attributes: { + variant: 'danger', + }, + }, + secondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, +}; + +export default { + name: 'AbuseReportActions', + components: { + GlButton, + GlDropdown, + GlModal, + }, + modalId: 'abuse-report-row-action-confirm-modal', + modalActionButtonAttributes, + i18n: ACTIONS_I18N, + props: { + report: { + type: Object, + required: true, + }, + }, + data() { + return { + userBlocked: this.report.userBlocked, + confirmModalShown: false, + actionToConfirm: 'block', + }; + }, + computed: { + blockUserButtonText() { + const { alreadyBlocked, blockUser } = this.$options.i18n; + + return this.userBlocked ? alreadyBlocked : blockUser; + }, + removeUserAndReportConfirmText() { + return sprintf(this.$options.i18n.removeUserAndReportConfirm, { + user: this.report.reportedUser.name, + }); + }, + modalData() { + return { + block: { + action: this.blockUser, + confirmText: this.$options.i18n.blockUserConfirm, + }, + removeUserAndReport: { + action: this.removeUserAndReport, + confirmText: this.removeUserAndReportConfirmText, + }, + }; + }, + }, + methods: { + showConfirmModal(action) { + this.confirmModalShown = true; + this.actionToConfirm = action; + }, + blockUser() { + axios + .put(this.report.blockUserPath) + .then(this.handleBlockUserResponse) + .catch(this.handleError); + }, + removeUserAndReport() { + axios + .delete(this.report.removeUserAndReportPath) + .then(this.handleRemoveReportResponse) + .catch(this.handleError); + }, + removeReport() { + axios + .delete(this.report.removeReportPath) + .then(this.handleRemoveReportResponse) + .catch(this.handleError); + }, + handleRemoveReportResponse() { + window.location.reload(); + }, + handleBlockUserResponse({ data }) { + const message = data?.error || data?.notice; + const alertOptions = data?.notice ? { variant: VARIANT_SUCCESS } : {}; + + if (message) { + createAlert({ message, ...alertOptions }); + } + + if (!data?.error) { + this.userBlocked = true; + } + }, + handleError(error) { + createAlert({ + message: __('Something went wrong. Please try again.'), + captureError: true, + error, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown text="Actions" text-sr-only icon="ellipsis_v" category="tertiary" no-caret right> + <div class="gl-px-2"> + <gl-button block variant="danger" @click="showConfirmModal('removeUserAndReport')"> + {{ $options.i18n.removeUserAndReport }} + </gl-button> + <gl-button block :disabled="userBlocked" @click="showConfirmModal('block')"> + {{ blockUserButtonText }} + </gl-button> + <gl-button block @click="removeReport"> + {{ $options.i18n.removeReport }} + </gl-button> + </div> + <gl-modal + v-model="confirmModalShown" + :modal-id="$options.modalId" + :title="modalData[actionToConfirm].confirmText" + size="sm" + :action-primary="$options.modalActionButtonAttributes[actionToConfirm]" + :action-secondary="$options.modalActionButtonAttributes.secondary" + @primary="modalData[actionToConfirm].action" + /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue new file mode 100644 index 00000000000..f49411604f1 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue @@ -0,0 +1,66 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlCollapse } from '@gitlab/ui'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + components: { + GlButton, + GlCollapse, + }, + directives: { SafeHtml }, + props: { + report: { + type: Object, + required: true, + }, + }, + data() { + return { + isVisible: false, + collapseId: uniqueId('abuse-report-detail-'), + }; + }, + computed: { + toggleText() { + return this.isVisible ? __('Hide details') : __('Show details'); + }, + reportedUserCreatedAt() { + const { reportedUser } = this.report; + return sprintf(__('User joined %{timeAgo}'), { + timeAgo: getTimeago().format(reportedUser.createdAt), + }); + }, + }, + methods: { + toggleCollapse() { + this.isVisible = !this.isVisible; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <gl-collapse :id="collapseId" v-model="isVisible"> + <dl class="gl-mb-2"> + <dd>{{ reportedUserCreatedAt }}</dd> + + <dt>{{ __('Message') }}</dt> + <dd v-safe-html="report.message"></dd> + </dl> + </gl-collapse> + <div> + <gl-button + :aria-expanded="`${isVisible}`" + :aria-controls="collapseId" + size="small" + variant="link" + @click="toggleCollapse" + >{{ toggleText }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue index a4211002f71..a9fe59a7b85 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue @@ -1,11 +1,20 @@ <script> +import { GlSprintf, GlLink } from '@gitlab/ui'; import { getTimeago } from '~/lib/utils/datetime_utility'; +import { queryToObject } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { SORT_UPDATED_AT } from '../constants'; +import AbuseReportActions from './abuse_report_actions.vue'; +import AbuseReportDetails from './abuse_report_details.vue'; export default { name: 'AbuseReportRow', components: { + AbuseReportDetails, + GlLink, + GlSprintf, + AbuseReportActions, ListItem, }, props: { @@ -15,14 +24,31 @@ export default { }, }, computed: { - updatedAt() { - const template = __('Updated %{timeAgo}'); - return sprintf(template, { timeAgo: getTimeago().format(this.report.updatedAt) }); + displayDate() { + const { sort } = queryToObject(window.location.search); + const { createdAt, updatedAt } = this.report; + const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort) + ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt } + : { template: __('Created %{timeAgo}'), timeAgo: createdAt }; + + return sprintf(template, { timeAgo: getTimeago().format(timeAgo) }); + }, + reported() { + const { reportedUser } = this.report; + return sprintf('%{userLinkStart}%{reported}%{userLinkEnd}', { + reported: reportedUser.name, + }); + }, + reporter() { + const { reporter } = this.report; + return sprintf('%{reporterLinkStart}%{reporter}%{reporterLinkEnd}', { + reporter: reporter.name, + }); }, title() { - const { reportedUser, reporter, category } = this.report; + const { category } = this.report; const template = __('%{reported} reported for %{category} by %{reporter}'); - return sprintf(template, { reported: reportedUser.name, reporter: reporter.name, category }); + return sprintf(template, { reported: this.reported, reporter: this.reporter, category }); }, }, }; @@ -31,11 +57,25 @@ export default { <template> <list-item data-testid="abuse-report-row"> <template #left-primary> - <div class="gl-font-weight-normal" data-testid="title">{{ title }}</div> + <div class="gl-font-weight-normal gl-mb-2" data-testid="title"> + <gl-sprintf :message="title"> + <template #userLink="{ content }"> + <gl-link :href="report.reportedUserPath">{{ content }}</gl-link> + </template> + <template #reporterLink="{ content }"> + <gl-link :href="report.reporterPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + + <template #left-secondary> + <abuse-report-details :report="report" /> </template> <template #right-secondary> - <div data-testid="updated-at">{{ updatedAt }}</div> + <div data-testid="abuse-report-date">{{ displayDate }}</div> + <abuse-report-actions :report="report" /> </template> </list-item> </template> diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue index b60fe3ae9b8..e1989cadd86 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue @@ -8,7 +8,7 @@ import { SORT_OPTIONS, isValidSortKey, } from '~/admin/abuse_reports/constants'; -import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils'; +import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils'; export default { name: 'AbuseReportsFilteredSearchBar', @@ -32,7 +32,7 @@ export default { // Backend shows open reports by default if status param is not specified. // To match that behavior, update the current URL to include status=open // query when no status query is specified on load. - if (!query.status) { + if (!isValidStatus(query.status)) { query.status = 'open'; updateHistory({ url: setUrlParams(query), replace: true }); } diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js index ee2e9ab2cbf..ee002f269ac 100644 --- a/app/assets/javascripts/admin/abuse_reports/constants.js +++ b/app/assets/javascripts/admin/abuse_reports/constants.js @@ -40,25 +40,24 @@ export const FILTERED_SEARCH_TOKEN_STATUS = { }; export const DEFAULT_SORT = 'created_at_desc'; - -export const SORT_OPTIONS = [ - { - id: 10, - title: __('Created date'), - sortDirection: { - descending: DEFAULT_SORT, - ascending: 'created_at_asc', - }, +export const SORT_UPDATED_AT = Object.freeze({ + id: 20, + title: __('Updated date'), + sortDirection: { + descending: 'updated_at_desc', + ascending: 'updated_at_asc', }, - { - id: 20, - title: __('Updated date'), - sortDirection: { - descending: 'updated_at_desc', - ascending: 'updated_at_asc', - }, +}); +const SORT_CREATED_AT = Object.freeze({ + id: 10, + title: __('Created date'), + sortDirection: { + descending: DEFAULT_SORT, + ascending: 'created_at_asc', }, -]; +}); + +export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT]; export const isValidSortKey = (key) => SORT_OPTIONS.some( @@ -79,3 +78,12 @@ export const FILTERED_SEARCH_TOKENS = [ FILTERED_SEARCH_TOKEN_REPORTER, FILTERED_SEARCH_TOKEN_STATUS, ]; + +export const ACTIONS_I18N = { + blockUserConfirm: __('USER WILL BE BLOCKED! Are you sure?'), + blockUser: __('Block user'), + alreadyBlocked: __('Already blocked'), + removeUserAndReportConfirm: __('USER %{user} WILL BE REMOVED! Are you sure?'), + removeUserAndReport: __('Remove user & report'), + removeReport: __('Remove report'), +}; diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js index 84221901089..d30e8fb0ae5 100644 --- a/app/assets/javascripts/admin/abuse_reports/utils.js +++ b/app/assets/javascripts/admin/abuse_reports/utils.js @@ -1,6 +1,9 @@ -import { FILTERED_SEARCH_TOKEN_CATEGORY } from './constants'; +import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants'; export const buildFilteredSearchCategoryToken = (categories) => { const options = categories.map((c) => ({ value: c, title: c })); return { ...FILTERED_SEARCH_TOKEN_CATEGORY, options }; }; + +export const isValidStatus = (status) => + FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status); diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue index 4482198675d..3168d693234 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -3,6 +3,7 @@ import { GlButton, GlBroadcastMessage, GlForm, + GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup, GlFormInput, @@ -18,7 +19,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { THEMES, TYPES, TYPE_BANNER } from '../constants'; -import MessageFormGroup from './message_form_group.vue'; import DatetimePicker from './datetime_picker.vue'; const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } }; @@ -31,13 +31,13 @@ export default { GlButton, GlBroadcastMessage, GlForm, + GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup, GlFormInput, GlFormSelect, GlFormText, GlFormTextarea, - MessageFormGroup, }, directives: { SafeHtml, @@ -189,7 +189,7 @@ export default { <div v-safe-html:[$options.safeHtmlConfig]="messagePreview"></div> </gl-broadcast-message> - <message-form-group :label="$options.i18n.message" label-for="message-textarea"> + <gl-form-group :label="$options.i18n.message" label-for="message-textarea"> <gl-form-textarea id="message-textarea" v-model="message" @@ -198,23 +198,23 @@ export default { :placeholder="$options.i18n.messagePlaceholder" data-testid="message-input" /> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.type" label-for="type-select"> + <gl-form-group :label="$options.i18n.type" label-for="type-select"> <gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" /> - </message-form-group> + </gl-form-group> <template v-if="isBanner"> - <message-form-group :label="$options.i18n.theme" label-for="theme-select"> + <gl-form-group :label="$options.i18n.theme" label-for="theme-select"> <gl-form-select id="theme-select" v-model="theme" :options="$options.messageThemes" data-testid="theme-select" /> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox"> + <gl-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox"> <gl-form-checkbox id="dismissable-checkbox" v-model="dismissable" @@ -223,10 +223,10 @@ export default { > <span>{{ $options.i18n.dismissableDescription }}</span> </gl-form-checkbox> - </message-form-group> + </gl-form-group> </template> - <message-form-group + <gl-form-group v-if="glFeatures.roleTargetedBroadcastMessages" :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes" @@ -235,24 +235,24 @@ export default { <gl-form-text> {{ $options.i18n.targetRolesDescription }} </gl-form-text> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> + <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> <gl-form-input id="target-path-input" v-model="targetPath" /> <gl-form-text> {{ $options.i18n.targetPathDescription }} </gl-form-text> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.startsAt"> + <gl-form-group :label="$options.i18n.startsAt"> <datetime-picker v-model="startsAt" /> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.endsAt"> + <gl-form-group :label="$options.i18n.endsAt"> <datetime-picker v-model="endsAt" /> - </message-form-group> + </gl-form-group> - <div class="form-actions gl-my-3"> + <div class="gl-my-5"> <gl-button type="submit" variant="confirm" diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue deleted file mode 100644 index eec51c0c28b..00000000000 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue +++ /dev/null @@ -1,34 +0,0 @@ -<script> -import { GlFormGroup } from '@gitlab/ui'; - -export default { - name: 'MessageFormGroup', - components: { - GlFormGroup, - }, - props: { - label: { - type: String, - required: true, - }, - labelFor: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> -<template> - <div> - <gl-form-group - :label="label" - :label-for="labelFor" - label-cols-sm="2" - label-class="gl-mt-3" - label-align-sm="right" - > - <slot></slot> - </gl-form-group> - </div> -</template> diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue index 0099c8da8e6..af09c7618e2 100644 --- a/app/assets/javascripts/admin/users/components/actions/activate.vue +++ b/app/assets/javascripts/admin/users/components/actions/activate.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -15,7 +15,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -52,7 +52,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 52560ebe5b1..2060528c7a0 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -17,7 +17,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -54,7 +54,9 @@ export default { </script> <template> - <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item data-qa-selector="approve_user_button" @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index 203d076914f..d7bdceb4798 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; @@ -30,7 +30,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -67,7 +67,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue index d50b76aaa92..534e1c76b8f 100644 --- a/app/assets/javascripts/admin/users/components/actions/block.vue +++ b/app/assets/javascripts/admin/users/components/actions/block.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -18,7 +18,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -53,7 +53,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue index ab1069601d2..40911131d6d 100644 --- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue +++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -25,7 +25,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -62,7 +62,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index d4f9ff4e529..83aa78c9f03 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -1,11 +1,11 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -49,9 +49,11 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <span class="gl-text-red-500"> - <slot></slot> - </span> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <span class="gl-text-red-500"> + <slot></slot> + </span> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index 413804c9a3b..24f0cac73f5 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { associationsCount } from '~/api/user_api'; import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub'; @@ -9,7 +9,7 @@ export default { loading: __('Loading'), }, components: { - GlDropdownItem, + GlDisclosureDropdownItem, GlLoadingIcon, }, props: { @@ -71,13 +71,15 @@ export default { </script> <template> - <gl-dropdown-item :disabled="loading" :aria-busy="loading" @click.capture.native.stop="onClick"> - <div v-if="loading" class="gl-display-flex gl-align-items-center"> - <gl-loading-icon class="gl-mr-3" /> - {{ $options.i18n.loading }} - </div> - <span v-else class="gl-text-red-500"> - <slot></slot> - </span> - </gl-dropdown-item> + <gl-disclosure-dropdown-item :disabled="loading" :aria-busy="loading" @action="onClick"> + <template #list-item> + <div v-if="loading" class="gl-display-flex gl-align-items-center"> + <gl-loading-icon class="gl-mr-3" /> + {{ $options.i18n.loading }} + </div> + <span v-else class="gl-text-red-500"> + <slot></slot> + </span> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue index 2b9c4acfcb5..7f786991709 100644 --- a/app/assets/javascripts/admin/users/components/actions/reject.vue +++ b/app/assets/javascripts/admin/users/components/actions/reject.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; @@ -28,7 +28,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -65,7 +65,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue index 42b6fb3bdd4..f84c7594f87 100644 --- a/app/assets/javascripts/admin/users/components/actions/unban.vue +++ b/app/assets/javascripts/admin/users/components/actions/unban.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -11,7 +11,7 @@ const messageHtml = `<p>${s__( export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -48,7 +48,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index f94e128a945..064f05ef8b1 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -1,12 +1,12 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -42,7 +42,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue index c78c260b4fe..039ab3d651e 100644 --- a/app/assets/javascripts/admin/users/components/actions/unlock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue @@ -1,12 +1,12 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -41,7 +41,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index c1fb80959cf..38c7d3f9b90 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -1,10 +1,9 @@ <script> import { GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlTooltipDirective, } from '@gitlab/ui'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; @@ -17,10 +16,9 @@ import Actions from './actions'; export default { components: { GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, ...Actions, }, directives: { @@ -63,6 +61,9 @@ export default { hasEditAction() { return this.userActions.includes('edit'); }, + hasEditActionOnly() { + return this.hasEditAction === true && this.hasDeleteActions === false; + }, userPaths() { return generateUserPaths(this.paths, this.user.username); }, @@ -93,10 +94,13 @@ export default { class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2" :data-testid="`user-actions-${user.id}`" > - <div v-if="hasEditAction" class="gl-p-2"> - <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{ - $options.i18n.edit - }}</gl-button> + <div v-if="hasEditAction" class="gl-p-2" :class="{ 'gl-mr-3': hasEditActionOnly }"> + <gl-button + v-if="showButtonLabels" + v-bind="editButtonAttrs" + :class="{ 'gl-mr-7': hasEditActionOnly }" + >{{ $options.i18n.edit }}</gl-button + > <gl-button v-else v-gl-tooltip="$options.i18n.edit" @@ -107,12 +111,15 @@ export default { </div> <div v-if="hasDropdownActions" class="gl-p-2"> - <gl-dropdown - :text="$options.i18n.userAdministration" + <gl-disclosure-dropdown + icon="ellipsis_v" + category="tertiary" + :toggle-text="$options.i18n.userAdministration" + text-sr-only data-testid="dropdown-toggle" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" - left + no-caret > <template v-for="action in dropdownSafeActions"> <component @@ -125,28 +132,32 @@ export default { > {{ $options.i18n[action] }} </component> - <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action"> - {{ $options.i18n[action] }} - </gl-dropdown-item> - </template> - - <gl-dropdown-divider v-if="hasDeleteActions" /> - - <template v-for="action in dropdownDeleteActions"> - <component - :is="getActionComponent(action)" - v-if="getActionComponent(action)" + <gl-disclosure-dropdown-item + v-else-if="isLdapAction(action)" :key="action" - :paths="userPaths" - :username="user.name" - :user-id="user.id" - :user-deletion-obstacles="obstaclesForUserDeletion" - :data-testid="`delete-${action}`" + :data-testid="action" > {{ $options.i18n[action] }} - </component> + </gl-disclosure-dropdown-item> </template> - </gl-dropdown> + + <gl-disclosure-dropdown-group v-if="hasDeleteActions" bordered> + <template v-for="action in dropdownDeleteActions"> + <component + :is="getActionComponent(action)" + v-if="getActionComponent(action)" + :key="action" + :paths="userPaths" + :username="user.name" + :user-id="user.id" + :user-deletion-obstacles="obstaclesForUserDeletion" + :data-testid="`delete-${action}`" + > + {{ $options.i18n[action] }} + </component> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </div> </div> </template> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index e55622d40ba..2d2c598f953 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -135,7 +135,7 @@ export default { </template> <template #cell(settings)="{ item: user }"> - <user-actions :user="user" :paths="paths" /> + <user-actions :user="user" :paths="paths" :show-button-labels="true" /> </template> </gl-table> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 5229d4c9ae2..170bd6895aa 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -12,6 +12,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql'; +import { STATUS_CLOSED } from '~/issues/constants'; import { sortObjectToString } from '~/lib/utils/table_utility'; import { fetchPolicies } from '~/lib/graphql'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; @@ -229,7 +230,7 @@ export default { }, getIssueMeta({ issue: { iid, state } }) { return { - state: state === 'closed' ? `(${this.$options.i18n.closed})` : '', + state: state === STATUS_CLOSED ? `(${this.$options.i18n.closed})` : '', link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid), }; }, diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index c98d3865621..4ab772e4523 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -37,12 +37,10 @@ export const ALERTS_STATUS_TABS = [ }, ]; -/* eslint-disable @gitlab/require-i18n-strings */ - /** * Tracks snowplow event when user views alerts list */ export const trackAlertListViewsOptions = { - category: 'Alert Management', + category: 'Alert Management', // eslint-disable-line @gitlab/require-i18n-strings action: 'view_alerts_list', }; diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index 2733a59f62d..1a586bd1e91 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -142,7 +142,7 @@ export default { {{ $options.i18n.columns.fallbackKeyTitle }} <gl-icon v-gl-tooltip - name="question" + name="question-o" class="gl-text-gray-500" :title="$options.i18n.fallbackTooltip" /> diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js index e45ea772ddd..4df5ed425a5 100644 --- a/app/assets/javascripts/alerts_settings/services/index.js +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import axios from '~/lib/utils/axios_utils'; export default { @@ -9,7 +8,7 @@ export default { return axios.post(endpoint, data, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}`, // eslint-disable-line @gitlab/require-i18n-strings }, }); }, diff --git a/app/assets/javascripts/analytics/cycle_analytics/bundle.js b/app/assets/javascripts/analytics/cycle_analytics/bundle.js new file mode 100644 index 00000000000..9fe31620938 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/bundle.js @@ -0,0 +1 @@ +export { default } from '~/analytics/cycle_analytics'; diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index 704b4ce9c8a..365cbeaf6a2 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -107,7 +107,9 @@ export default { }, showLinkToDashboard() { return Boolean( - this.features?.groupLevelAnalyticsDashboard && this.features?.groupAnalyticsDashboardsPage, + this.features?.groupLevelAnalyticsDashboard && + this.features?.groupAnalyticsDashboardsPage && + this.groupPath, ); }, dashboardsPath() { @@ -129,6 +131,9 @@ export default { page: this.pagination?.page || null, }; }, + filterBarNamespacePath() { + return this.groupPath || this.namespace.fullPath; + }, }, methods: { ...mapActions([ @@ -168,10 +173,11 @@ export default { <div> <h3>{{ $options.i18n.pageTitle }}</h3> <value-stream-filters - :group-path="groupPath" + :namespace-path="filterBarNamespacePath" :has-project-filter="false" :start-date="createdAfter" :end-date="createdBefore" + :group-path="groupPath" @setDateRange="onSetDateRange" /> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 54b632968e2..133513d6c21 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -30,7 +30,7 @@ export default { UrlSync, }, props: { - groupPath: { + namespacePath: { type: String, required: true, }, @@ -141,7 +141,7 @@ export default { <div> <filtered-search-bar class="gl-flex-grow-1" - :namespace="groupPath" + :namespace="namespacePath" recent-searches-storage-key="value-stream-analytics" :search-input-placeholder="__('Filter results')" :tokens="tokens" diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue index 4c7e18f9895..b9d1c4b0fe0 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue @@ -31,6 +31,10 @@ export default { required: false, default: true, }, + namespacePath: { + type: String, + required: true, + }, groupPath: { type: String, required: true, @@ -69,7 +73,7 @@ export default { <filter-bar data-testid="vsa-filter-bar" class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none" - :group-path="groupPath" + :namespace-path="namespacePath" /> <div v-if="hasDateRangeFilter || hasProjectFilter" diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js index 9265ff952e0..d7c3804113e 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/utils.js +++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js @@ -1,3 +1,4 @@ +import { extractVSAFeaturesFromGON } from '~/analytics/shared/utils'; import { parseSeconds } from '~/lib/utils/datetime_utility'; import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility'; import { joinPaths } from '~/lib/utils/url_utility'; @@ -64,28 +65,6 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => stages.filter(({ hidden = false }) => hidden === isHidden); /** - * @typedef {Object} MetricData - * @property {String} title - Title of the metric measured - * @property {String} value - String representing the decimal point value, e.g '1.5' - * @property {String} [unit] - String representing the decimal point value, e.g '1.5' - * - * @typedef {Object} TransformedMetricData - * @property {String} label - Title of the metric measured - * @property {String} value - String representing the decimal point value, e.g '1.5' - * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute - * @property {String} description - String to display for a description - * @property {String} unit - String representing the decimal point value, e.g '1.5' - */ - -const extractFeatures = (gon) => ({ - // licensed feature toggles - cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), - groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard), - // feature flags - groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage), -}); - -/** * Builds the initial data object for Value Stream Analytics with data loaded from the backend * * @param {Object} dataset - dataset object paseed to the frontend via data-* properties @@ -99,11 +78,10 @@ export const buildCycleAnalyticsInitialData = ({ createdBefore, namespaceName, namespaceFullPath, - gon, } = {}) => { return { projectId: parseInt(projectId, 10), - groupPath: `groups/${groupPath}`, + groupPath, namespace: { name: namespaceName, fullPath: namespaceFullPath, @@ -111,7 +89,7 @@ export const buildCycleAnalyticsInitialData = ({ createdAfter: new Date(createdAfter), createdBefore: new Date(createdBefore), selectedStage: stage ? JSON.parse(stage) : null, - features: extractFeatures(gon), + features: extractVSAFeaturesFromGON(), }; }; diff --git a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue index 95a6447ebaf..6c79c8af54a 100644 --- a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue +++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue @@ -13,6 +13,7 @@ export default { }, i18n: { title: __('Related'), + // eslint-disable-next-line @gitlab/require-valid-i18n-helpers linkText: __('Value Streams Dashboard | DORA'), }, }; diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index a85f3fb3730..88a0f6f30cb 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -129,12 +129,28 @@ export const fetchMetricsData = (requests = [], requestPath, params) => { * @param {Array} projectPaths - Array of project paths to include in the `query` parameter * @returns a URL or blank string if there is no groupPath set */ -export const generateValueStreamsDashboardLink = (groupPath, projectPaths = []) => { - if (groupPath.length) { +export const generateValueStreamsDashboardLink = (namespacePath, projectPaths = []) => { + if (namespacePath.length) { const query = projectPaths.length ? `?query=${projectPaths.join(',')}` : ''; const dashboardsSlug = '/-/analytics/dashboards/value_streams_dashboard'; - const segments = [gon.relative_url_root || '', '/', groupPath, dashboardsSlug]; + const segments = [gon.relative_url_root || '', '/', namespacePath, dashboardsSlug]; return joinPaths(...segments).concat(query); } return ''; }; + +/** + * Extracts the relevant feature and license flags needed for VSA + * + * @param {Object} gon the global `window.gon` object populated when the page loads + * @returns an object containing the extracted feature flags and their boolean status + */ +export const extractVSAFeaturesFromGON = () => ({ + // licensed feature toggles + cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), + cycleAnalyticsForProjects: Boolean(gon?.licensed_features?.cycleAnalyticsForProjects), + groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard), + // feature flags + groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage), + vsaGroupAndProjectParity: Boolean(gon?.features?.vsaGroupAndProjectParity), +}); diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index 5c0d101ef5b..c72a913aacd 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -21,7 +21,7 @@ export function getProjects(query, options, callback = () => {}) { defaults.membership = true; } - if (gon.features.fullPathProjectSearch && query?.includes('/')) { + if (query?.includes('/')) { defaults.search_namespaces = true; } @@ -35,6 +35,13 @@ export function getProjects(query, options, callback = () => {}) { }); } +export function createProject(projectData) { + const url = buildApiUrl(PROJECTS_PATH); + return axios.post(url, projectData).then(({ data }) => { + return data; + }); +} + export function importProjectMembers(sourceId, targetId) { const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH) .replace(':id', sourceId) diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index bcb0f079d3d..3ebb07807d2 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -1,6 +1,4 @@ import { DEFAULT_PER_PAGE } from '~/api'; -import { createAlert } from '~/alert'; -import { __ } from '~/locale'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; @@ -44,22 +42,12 @@ export function getUserStatus(id, options) { }); } -export function getUserProjects(userId, query, options, callback) { +export function getUserProjects(userId, options) { const url = buildApiUrl(USER_PROJECTS_PATH).replace(':id', userId); - const defaults = { - search: query, - per_page: DEFAULT_PER_PAGE, - }; - return axios - .get(url, { - params: { ...defaults, ...options }, - }) - .then(({ data }) => callback(data)) - .catch(() => - createAlert({ - message: __('Something went wrong while fetching projects'), - }), - ); + + return axios.get(url, { + params: options, + }); } export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) { diff --git a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue deleted file mode 100644 index cc08551fdb7..00000000000 --- a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue +++ /dev/null @@ -1,182 +0,0 @@ -<script> -import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; -import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; -import { removeArtifactFromStore } from '../graphql/cache_update'; -import { - I18N_BULK_DELETE_BANNER, - I18N_BULK_DELETE_CLEAR_SELECTION, - I18N_BULK_DELETE_DELETE_SELECTED, - I18N_BULK_DELETE_MODAL_TITLE, - I18N_BULK_DELETE_BODY, - I18N_BULK_DELETE_ACTION, - I18N_BULK_DELETE_PARTIAL_ERROR, - I18N_BULK_DELETE_ERROR, - I18N_MODAL_CANCEL, - BULK_DELETE_MODAL_ID, -} from '../constants'; - -export default { - name: 'ArtifactsBulkDelete', - components: { - GlButton, - GlModal, - GlSprintf, - }, - inject: ['projectId'], - props: { - selectedArtifacts: { - type: Array, - required: true, - }, - queryVariables: { - type: Object, - required: true, - }, - }, - data() { - return { - isModalVisible: false, - isDeleting: false, - }; - }, - computed: { - checkedCount() { - return this.selectedArtifacts.length || 0; - }, - modalActionPrimary() { - return { - text: I18N_BULK_DELETE_ACTION(this.checkedCount), - attributes: { - loading: this.isDeleting, - variant: 'danger', - }, - }; - }, - modalActionCancel() { - return { - text: I18N_MODAL_CANCEL, - attributes: { - loading: this.isDeleting, - }, - }; - }, - }, - methods: { - async onConfirmDelete(e) { - // don't close modal until deletion is complete - if (e) { - e.preventDefault(); - } - this.isDeleting = true; - - try { - await this.$apollo.mutate({ - mutation: bulkDestroyJobArtifactsMutation, - variables: { - projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), - ids: this.selectedArtifacts, - }, - update: (store, { data }) => { - const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts; - if (errors?.length) { - createAlert({ - message: I18N_BULK_DELETE_PARTIAL_ERROR, - captureError: true, - error: new Error(errors.join(' ')), - }); - } - if (destroyedIds?.length) { - this.$emit('deleted', destroyedCount); - - // Remove deleted artifacts from the cache - destroyedIds.forEach((id) => { - removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables); - }); - store.gc(); - - this.$emit('clearSelectedArtifacts'); - } - }, - }); - } catch (error) { - this.onError(error); - } finally { - this.isDeleting = false; - this.isModalVisible = false; - } - }, - onError(error) { - createAlert({ - message: I18N_BULK_DELETE_ERROR, - captureError: true, - error, - }); - }, - handleClearSelection() { - this.$emit('clearSelectedArtifacts'); - }, - handleModalShow() { - this.isModalVisible = true; - }, - handleModalHide() { - this.isModalVisible = false; - }, - }, - i18n: { - banner: I18N_BULK_DELETE_BANNER, - clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION, - deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED, - modalTitle: I18N_BULK_DELETE_MODAL_TITLE, - modalBody: I18N_BULK_DELETE_BODY, - }, - BULK_DELETE_MODAL_ID, -}; -</script> -<template> - <div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> - <div class="gl-display-flex gl-align-items-center"> - <div> - <gl-sprintf :message="$options.i18n.banner(checkedCount)"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </div> - <div class="gl-ml-auto"> - <gl-button - variant="default" - data-testid="bulk-delete-clear-button" - @click="handleClearSelection" - > - {{ $options.i18n.clearSelection }} - </gl-button> - <gl-button - variant="danger" - data-testid="bulk-delete-delete-button" - @click="handleModalShow" - > - {{ $options.i18n.deleteSelected }} - </gl-button> - </div> - </div> - <gl-modal - size="sm" - :modal-id="$options.BULK_DELETE_MODAL_ID" - :visible="isModalVisible" - :title="$options.i18n.modalTitle(checkedCount)" - :action-primary="modalActionPrimary" - :action-cancel="modalActionCancel" - @hide="handleModalHide" - @primary="onConfirmDelete" - > - <gl-sprintf - data-testid="bulk-delete-modal-content" - :message="$options.i18n.modalBody(checkedCount)" - /> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue new file mode 100644 index 00000000000..7808620cca1 --- /dev/null +++ b/app/assets/javascripts/authentication/password/components/password_input.vue @@ -0,0 +1,82 @@ +<script> +import { GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { SHOW_PASSWORD, HIDE_PASSWORD, PASSWORD_TITLE } from '../constants'; + +export default { + name: 'PasswordInput', + i18n: { + showPassword: SHOW_PASSWORD, + hidePassword: HIDE_PASSWORD, + }, + components: { + GlFormInput, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + resourceName: { + type: String, + required: true, + }, + minimumPasswordLength: { + type: String, + required: true, + }, + qaSelector: { + type: String, + required: true, + }, + }, + data() { + return { + isMasked: true, + }; + }, + computed: { + passwordTitle() { + return sprintf(PASSWORD_TITLE, { minimum_password_length: this.minimumPasswordLength }); + }, + type() { + return this.isMasked ? 'password' : 'text'; + }, + toggleVisibilityLabel() { + return this.isMasked ? this.$options.i18n.showPassword : this.$options.i18n.hidePassword; + }, + toggleVisibilityIcon() { + return this.isMasked ? 'eye' : 'eye-slash'; + }, + }, + methods: { + handleToggleVisibilityButtonClick() { + this.isMasked = !this.isMasked; + }, + }, +}; +</script> + +<template> + <div class="gl-field-error-anchor input-icon-wrapper"> + <gl-form-input + :id="`${resourceName}_password`" + class="js-password-complexity-validation gl-pr-8!" + required + autocomplete="new-password" + :name="`${resourceName}[password]`" + :minlength="minimumPasswordLength" + :data-qa-selector="qaSelector" + :title="passwordTitle" + :type="type" + /> + <gl-button + v-gl-tooltip="toggleVisibilityLabel" + class="input-icon-right gl-right-0!" + category="tertiary" + :aria-label="toggleVisibilityLabel" + :icon="toggleVisibilityIcon" + @click="handleToggleVisibilityButtonClick" + /> + </div> +</template> diff --git a/app/assets/javascripts/authentication/password/constants.js b/app/assets/javascripts/authentication/password/constants.js new file mode 100644 index 00000000000..97e1a882d9d --- /dev/null +++ b/app/assets/javascripts/authentication/password/constants.js @@ -0,0 +1,8 @@ +import { __, s__ } from '~/locale'; + +export const SHOW_PASSWORD = __('Show password'); +export const HIDE_PASSWORD = __('Hide password'); + +export const PASSWORD_TITLE = s__( + 'SignUp|Minimum length is %{minimum_password_length} characters.', +); diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js new file mode 100644 index 00000000000..36e3b74263c --- /dev/null +++ b/app/assets/javascripts/authentication/password/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import GlFieldErrors from '~/gl_field_errors'; +import PasswordInput from './components/password_input.vue'; + +export const initTogglePasswordVisibility = () => { + const el = document.querySelector('.js-password'); + + if (!el) { + return null; + } + + const { form } = el; + const { resourceName, minimumPasswordLength, qaSelector } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'PasswordInputRoot', + render(createElement) { + return createElement(PasswordInput, { + props: { + resourceName, + minimumPasswordLength, + qaSelector, + }, + }); + }, + }); + + // Since we replaced password input, we need to re-initialize the field errors handler + return new GlFieldErrors(form); +}; diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue index 98ed2a31730..907b68e6ffc 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue @@ -7,7 +7,6 @@ export const i18n = { currentPassword: __('Current password'), confirmTitle: __('Are you sure?'), confirmWebAuthn: __('This will invalidate your registered applications and WebAuthn devices.'), - confirm: __('This will invalidate your registered applications and WebAuthn devices.'), disableTwoFactor: __('Disable two-factor authentication'), disable: __('Disable'), cancel: __('Cancel'), @@ -41,7 +40,6 @@ export default { GlModal, }, inject: [ - 'webauthnEnabled', 'isCurrentPasswordRequired', 'profileTwoFactorAuthPath', 'profileTwoFactorAuthMethod', @@ -59,11 +57,7 @@ export default { }, computed: { confirmText() { - if (this.webauthnEnabled) { - return i18n.confirmWebAuthn; - } - - return i18n.confirm; + return i18n.confirmWebAuthn; }, }, methods: { diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js index 7d21c19ac4c..cec80335ba0 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/index.js +++ b/app/assets/javascripts/authentication/two_factor_auth/index.js @@ -13,7 +13,6 @@ export const initManageTwoFactorForm = () => { } const { - webauthnEnabled = false, currentPasswordRequired, profileTwoFactorAuthPath = '', profileTwoFactorAuthMethod = '', @@ -26,7 +25,6 @@ export const initManageTwoFactorForm = () => { return new Vue({ el, provide: { - webauthnEnabled, isCurrentPasswordRequired, profileTwoFactorAuthPath, profileTwoFactorAuthMethod, diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index beda251aa1e..107796a31e0 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,19 +1,10 @@ <script> -import { - GlDropdown, - GlButton, - GlIcon, - GlForm, - GlFormGroup, - GlLink, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { createAlert } from '~/alert'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; import Autosave from '~/autosave'; -import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { @@ -22,7 +13,6 @@ export default { GlIcon, GlForm, GlFormGroup, - GlLink, GlFormCheckbox, MarkdownField, ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'), @@ -102,9 +92,6 @@ export default { }, }, restrictedToolbarItems: ['full-screen'], - helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', { - anchor: 'submit-a-review', - }), }; </script> @@ -126,14 +113,6 @@ export default { <gl-form-group label-for="review-note-body" label-class="gl-mb-2"> <template #label> {{ __('Summary comment (optional)') }} - <gl-link - :href="$options.helpPagePath" - :aria-label="__('More information')" - target="_blank" - class="gl-ml-2" - > - <gl-icon name="question-o" /> - </gl-link> </template> <div class="common-note-form gfm-form"> <div diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js index 2a8786134cc..f6e9bfd6690 100644 --- a/app/assets/javascripts/batch_comments/index.js +++ b/app/assets/javascripts/batch_comments/index.js @@ -7,6 +7,8 @@ import store from '~/mr_notes/stores'; export const initReviewBar = () => { const el = document.getElementById('js-review-bar'); + if (!el) return; + Vue.use(VueApollo); // eslint-disable-next-line no-new @@ -18,7 +20,7 @@ export const initReviewBar = () => { ReviewBar: () => import('./components/review_bar.vue'), }, provide: { - newSavedRepliesPath: el.dataset.savedRepliesNewPath, + newCommentTemplatePath: el.dataset.newCommentTemplatePath, }, computed: { ...mapGetters('batchComments', ['draftsCount']), diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js index 11fe01ca48d..89f1ad9c89e 100644 --- a/app/assets/javascripts/behaviors/date_picker.js +++ b/app/assets/javascripts/behaviors/date_picker.js @@ -9,7 +9,7 @@ export default function initDatePickers() { const calendar = new Pikaday({ field: $datePicker.get(0), - theme: 'gitlab-theme animate-picker', + theme: 'gl-datepicker-theme animate-picker', format: 'yyyy-mm-dd', container: $datePicker.parent().get(0), parse: (dateString) => parsePikadayDate(dateString), diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index b1bf6ebcb13..b2348cf0bad 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -97,7 +97,7 @@ class SafeMathRenderer { <button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button> </div> </div> - <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <button type="button" class="close js-close" aria-label="Close"> ${spriteIcon('close', 's16')} </button> </div> @@ -184,17 +184,24 @@ class SafeMathRenderer { attachEvents() { document.body.addEventListener('click', (event) => { - if (!event.target.classList.contains('js-lazy-render-math')) { + const alert = event.target.closest('.js-lazy-render-math-container'); + + if (!alert) { return; } - const parent = event.target.closest('.js-lazy-render-math-container'); - - const pre = parent.nextElementSibling; - - parent.remove(); + // Handle alert close + if (event.target.closest('.js-close')) { + alert.remove(); + return; + } - this.renderElement(pre); + // Handle "render anyway" + if (event.target.classList.contains('js-lazy-render-math')) { + const pre = alert.nextElementSibling; + alert.remove(); + this.renderElement(pre); + } }); } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 6a7ce4f1c41..301dd1c5669 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -204,7 +204,11 @@ export default class Shortcuts { } static focusSearch(e) { - $('#search').focus(); + if (gon.use_new_navigation) { + document.querySelector('#super-sidebar-search')?.click(); + } else { + document.querySelector('#search')?.focus(); + } if (e.preventDefault) { e.preventDefault(); diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js index 2c1c6339fdb..834aa3e5354 100644 --- a/app/assets/javascripts/blob/sketch_viewer.js +++ b/app/assets/javascripts/blob/sketch_viewer.js @@ -1,8 +1,7 @@ -/* eslint-disable no-new */ import SketchLoader from './sketch'; export default () => { const el = document.getElementById('js-sketch-viewer'); - new SketchLoader(el); + new SketchLoader(el); // eslint-disable-line no-new }; diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 7eb699eacbe..59b7f82c10e 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -1,5 +1,3 @@ -/* eslint-disable class-methods-use-this */ - import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -70,6 +68,7 @@ export default class TemplateSelector { return this.requestFile(item); } + // eslint-disable-next-line class-methods-use-this requestFile() { // This `requestFile` method is an abstract method that should // be added by all subclasses. diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 01d35a0980f..7e667409556 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new */ - import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { createAlert } from '~/alert'; @@ -54,6 +52,7 @@ export default () => { import('./edit_blob') .then(({ default: EditBlob } = {}) => { + // eslint-disable-next-line no-new new EditBlob({ assetsPath: `${urlRoot}${assetsPath}`, filePath, @@ -80,7 +79,7 @@ export default () => { window.onbeforeunload = null; }); - new NewCommitForm(editBlobForm); + new NewCommitForm(editBlobForm); // eslint-disable-line no-new // returning here blocks page navigation window.onbeforeunload = () => ''; diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 48dfcf81f1e..c7e6cb38d15 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -4,6 +4,7 @@ import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility'; import BoardContent from '~/boards/components/board_content.vue'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export default { components: { @@ -11,7 +12,7 @@ export default { BoardSettingsSidebar, BoardTopBar, }, - inject: ['initialBoardId', 'initialFilterParams'], + inject: ['initialBoardId', 'initialFilterParams', 'isIssueBoard', 'isApolloBoard'], data() { return { boardId: this.initialBoardId, @@ -19,11 +20,31 @@ export default { isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), }; }, + apollo: { + activeBoardItem: { + query: activeBoardItemQuery, + variables() { + return { + isIssue: this.isIssueBoard, + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, + computed: { ...mapGetters(['isSidebarOpen']), isSwimlanesOn() { return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false; }, + isAnySidebarOpen() { + if (this.isApolloBoard) { + return this.activeBoardItem?.id; + } + return this.isSidebarOpen; + }, }, created() { window.addEventListener('popstate', refreshCurrentPage); @@ -45,7 +66,7 @@ export default { </script> <template> - <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> + <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }"> <board-top-bar :board-id="boardId" :is-swimlanes-on="isSwimlanesOn" diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 3071c1f334e..18495f285da 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,6 +1,8 @@ <script> import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import BoardCardInner from './board_card_inner.vue'; export default { @@ -9,7 +11,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], - inject: ['disabled', 'isApolloBoard'], + inject: ['disabled', 'isIssueBoard', 'isApolloBoard'], props: { list: { type: Object, @@ -37,14 +39,30 @@ export default { default: true, }, }, + apollo: { + activeBoardItem: { + query: activeBoardItemQuery, + variables() { + return { + isIssue: this.isIssueBoard, + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { ...mapState(['selectedBoardItems', 'activeId']), + activeItemId() { + return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId; + }, isActive() { - return this.item.id === this.activeId; + return this.item.id === this.activeItemId; }, multiSelectVisible() { return ( - !this.activeId && + !this.activeItemId && this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 ); }, @@ -83,10 +101,23 @@ export default { if (isMultiSelect && gon?.features?.boardMultiSelect) { this.toggleBoardItemMultiSelection(this.item); } else { - this.toggleBoardItem({ boardItem: this.item }); + if (this.isApolloBoard) { + this.toggleItem(); + } else { + this.toggleBoardItem({ boardItem: this.item }); + } this.track('click_card', { label: 'right_sidebar' }); } }, + toggleItem() { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: this.item, + isIssue: this.isIssueBoard, + }, + }); + }, }, }; </script> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 88f51c71e06..befd04c29ae 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -275,7 +275,7 @@ export default { <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 gl-text-secondary" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > <work-item-type-icon diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 84a8781db1c..946f3712834 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -206,6 +206,7 @@ export default { <epics-swimlanes v-else-if="boardListsToUse.length" ref="swimlanes" + :board-id="boardId" :lists="boardListsToUse" :can-admin-list="canAdminList" :filters="filterParams" diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 675878683ab..1b97214ff8b 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -3,10 +3,12 @@ import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import { __, sprintf } from '~/locale'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { ISSUABLE, INCIDENT } from '~/boards/constants'; +import { INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; @@ -16,7 +18,6 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -39,7 +40,6 @@ export default { SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), }, - mixins: [glFeatureFlagMixin()], inject: { multipleAssigneesFeatureAvailable: { default: false, @@ -71,31 +71,46 @@ export default { isGroupBoard: { default: false, }, + isApolloBoard: { + default: false, + }, }, inheritAttrs: false, + apollo: { + activeBoardCard: { + query: activeBoardItemQuery, + variables: { + isIssue: true, + }, + update(data) { + if (!data.activeBoardItem?.id) { + return { id: '', iid: '' }; + } + return { + ...data.activeBoardItem, + assignees: data.activeBoardItem.assignees?.nodes || [], + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { - ...mapGetters([ - 'isSidebarOpen', - 'activeBoardItem', - 'groupPathForActiveIssue', - 'projectPathForActiveIssue', - ]), + ...mapGetters(['activeBoardItem']), ...mapState(['sidebarType']), - isIssuableSidebar() { - return this.sidebarType === ISSUABLE; + activeBoardIssuable() { + return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem; }, - isIncidentSidebar() { - return this.activeBoardItem.type === INCIDENT; + isSidebarOpen() { + return Boolean(this.activeBoardIssuable?.id); }, - showSidebar() { - return this.isIssuableSidebar && this.isSidebarOpen; + isIncidentSidebar() { + return this.activeBoardIssuable?.type === INCIDENT; }, sidebarTitle() { return this.isIncidentSidebar ? __('Incident details') : __('Issue details'); }, - fullPath() { - return this.activeBoardItem?.referencePath?.split('#')[0] || ''; - }, parentType() { return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT; }, @@ -120,6 +135,14 @@ export default { ? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue) : this.labelsFilterBasePath; }, + groupPathForActiveIssue() { + const { referencePath = '' } = this.activeBoardIssuable; + return referencePath.slice(0, referencePath.lastIndexOf('/')); + }, + projectPathForActiveIssue() { + const { referencePath = '' } = this.activeBoardIssuable; + return referencePath.slice(0, referencePath.indexOf('#')); + }, }, methods: { ...mapActions([ @@ -131,7 +154,19 @@ export default { 'setActiveItemHealthStatus', ]), handleClose() { - this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); + if (this.isApolloBoard) { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: null, + }, + }); + } else { + this.toggleBoardItem({ + boardItem: this.activeBoardIssuable, + sidebarType: this.sidebarType, + }); + } }, handleUpdateSelectedLabels({ labels, id }) { this.setActiveBoardItemLabels({ @@ -143,7 +178,7 @@ export default { }, handleLabelRemove(removeLabelId) { this.setActiveBoardItemLabels({ - iid: this.activeBoardItem.iid, + iid: this.activeBoardIssuable.iid, projectPath: this.projectPathForActiveIssue, removeLabelIds: [removeLabelId], }); @@ -156,7 +191,7 @@ export default { <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append> <gl-drawer v-bind="$attrs" - :open="showSidebar" + :open="isSidebarOpen" class="boards-sidebar" variant="sidebar" @close="handleClose" @@ -167,25 +202,27 @@ export default { <template #header> <sidebar-todo-widget class="gl-mt-3" - :issuable-id="activeBoardItem.id" - :issuable-iid="activeBoardItem.iid" - :full-path="fullPath" + :issuable-id="activeBoardIssuable.id" + :issuable-iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" /> </template> <template #default> - <board-sidebar-title data-testid="sidebar-title" /> + <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" /> <sidebar-assignees-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :initial-assignees="activeBoardItem.assignees" + v-if="activeBoardItem.assignees" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" + :initial-assignees="activeBoardIssuable.assignees" :allow-multiple-assignees="multipleAssigneesFeatureAvailable" :editable="canUpdate" - @assignees-updated="setAssignees" + @assignees-updated="!isApolloBoard && setAssignees($event)" /> <sidebar-dropdown-widget v-if="epicFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" + :key="`epic-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" @@ -194,7 +231,8 @@ export default { /> <div> <sidebar-dropdown-widget - :iid="activeBoardItem.iid" + :key="`milestone-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="projectPathForActiveIssue" @@ -203,7 +241,8 @@ export default { /> <sidebar-iteration-widget v-if="iterationFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" + :key="`iteration-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" @@ -213,14 +252,14 @@ export default { </div> <board-sidebar-time-tracker /> <sidebar-date-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" data-testid="sidebar-due-date" /> <sidebar-labels-widget class="block labels" - :iid="activeBoardItem.iid" + :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :allow-label-remove="allowLabelEdit" :allow-multiselect="true" @@ -232,40 +271,40 @@ export default { workspace-type="project" :issuable-type="issuableType" :label-create-type="labelType" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" + @onLabelRemove="!isApolloBoard && handleLabelRemove($event)" + @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)" > {{ __('None') }} </sidebar-labels-widget> <sidebar-severity-widget v-if="isIncidentSidebar" - :iid="activeBoardItem.iid" - :project-path="fullPath" - :initial-severity="activeBoardItem.severity" + :iid="activeBoardIssuable.iid" + :project-path="projectPathForActiveIssue" + :initial-severity="activeBoardIssuable.severity" /> <sidebar-weight-widget v-if="weightFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @weightUpdated="setActiveItemWeight($event)" + @weightUpdated="!isApolloBoard && setActiveItemWeight($event)" /> <sidebar-health-status-widget v-if="healthStatusFeatureAvailable" - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @statusUpdated="setActiveItemHealthStatus($event)" + @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)" /> <sidebar-confidentiality-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @confidentialityUpdated="setActiveItemConfidential($event)" + @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)" /> <sidebar-subscriptions-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" data-testid="sidebar-notifications" /> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 2e14afad963..46612320136 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -22,7 +22,7 @@ import { TOKEN_TYPE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { AssigneeFilterType } from '~/boards/constants'; +import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants'; import { TYPENAME_ITERATION } from '~/graphql_shared/constants'; import eventHub from '../eventhub'; @@ -33,6 +33,11 @@ export default { components: { FilteredSearch }, inject: ['initialFilterParams', 'isApolloBoard'], props: { + isSwimlanesOn: { + type: Boolean, + required: false, + default: false, + }, tokens: { type: Array, required: true, @@ -321,6 +326,7 @@ export default { release_tag: releaseTag, confidential, health_status: healthStatus, + group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined, }, (value) => { if (value || value === false) { diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 9ea801dc9a2..604e71f5993 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -226,12 +226,10 @@ export default { } this.cancel(); - if (!this.isApolloBoard) { - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); - } + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); } catch { this.setError({ message: this.$options.i18n.saveErrorMessage }); } finally { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index a47db661445..5f082066ad4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { mapActions, mapState } from 'vuex'; +import { STATUS_CLOSED } from '~/issues/constants'; import { sprintf, __ } from '~/locale'; import { defaultSortableOptions } from '~/sortable/constants'; import { sortableStart, sortableEnd } from '~/sortable/utils'; @@ -158,10 +159,10 @@ export default { return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore; }, epicCreateFormVisible() { - return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; + return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm; }, issueCreateFormVisible() { - return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; + return !this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showIssueForm; }, listRef() { // When list is draggable, the reference to the list needs to be accessed differently @@ -418,7 +419,6 @@ export default { v-if="loadingMore" size="sm" :label="$options.i18n.loadingMoreboardItems" - data-testid="count-loading-icon" /> <span v-if="showingAllItems">{{ showingAllItemsText }}</span> <span v-else>{{ paginatedIssueText }}</span> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index f4358315d45..7dc3e464af0 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -229,9 +229,6 @@ export default { context: { isSingleRequest: true, }, - skip() { - return this.isEpicBoard; - }, }, }, created() { @@ -426,7 +423,7 @@ export default { <div v-if="list.maxIssueCount !== 0"> • <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> - <template #issuesSize>{{ itemsTooltipLabel }}</template> + <template #issuesSize>{{ itemsCount }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template> </gl-sprintf> </div> diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index fad57758be1..c186346b2ac 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -98,6 +98,7 @@ export default { <issue-board-filtered-search v-if="isIssueBoard" :board="board" + :is-swimlanes-on="isSwimlanesOn" @setFilters="$emit('setFilters', $event)" /> <epic-board-filtered-search diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index cdcc7b8e5a6..3c056f296e1 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -52,6 +52,11 @@ export default { required: false, default: () => {}, }, + isSwimlanesOn: { + type: Boolean, + required: false, + default: false, + }, }, computed: { tokensCE() { @@ -203,6 +208,7 @@ export default { data-testid="issue-board-filtered-search" :tokens="tokens" :board="board" + :is-swimlanes-on="isSwimlanesOn" @setFilters="$emit('setFilters', $event)" /> </template> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index c3f7c7d3ca2..1f28974afd1 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -95,9 +95,12 @@ export default { class="board-card-info-icon gl-mr-2" name="calendar" /> - <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ - body - }}</time> + <time + :class="{ 'text-danger': isPastDue }" + datetime="date" + class="gl-font-sm board-card-info-text" + >{{ body }}</time + > </span> <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index bc12717a92d..611e875fa40 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -38,7 +38,7 @@ export default { <span> <span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help"> <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" /> - <time class="board-card-info-text">{{ timeEstimate }}</time> + <time class="gl-font-sm board-card-info-text">{{ timeEstimate }}</time> </span> <gl-tooltip :target="() => $refs.issueTimeEstimate" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index 43a2b13b81c..020edcb01b8 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -5,6 +5,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { titleQueries } from 'ee_else_ce/boards/constants'; export default { components: { @@ -19,6 +20,13 @@ export default { directives: { autofocusonshow, }, + inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'], + props: { + activeItem: { + type: Object, + required: true, + }, + }, data() { return { title: '', @@ -27,7 +35,10 @@ export default { }; }, computed: { - ...mapGetters({ item: 'activeBoardItem' }), + ...mapGetters(['activeBoardItem']), + item() { + return this.isApolloBoard ? this.activeItem : this.activeBoardItem; + }, pendingChangesStorageKey() { return this.getPendingChangesKey(this.item); }, @@ -67,8 +78,9 @@ export default { }, async setPendingState() { const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey); + const shouldOpen = pendingChanges !== this.title; - if (pendingChanges) { + if (pendingChanges && shouldOpen) { this.title = pendingChanges; this.showChangesAlert = true; await this.$nextTick(); @@ -83,6 +95,26 @@ export default { this.showChangesAlert = false; localStorage.removeItem(this.pendingChangesStorageKey); }, + async setActiveBoardItemTitle() { + if (!this.isApolloBoard) { + await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); + return; + } + const { fullPath, issuableType, isEpicBoard, title } = this; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: this.projectPath }; + await this.$apollo.mutate({ + mutation: titleQueries[issuableType].mutation, + variables: { + input: { + ...workspacePath, + iid: String(this.item.iid), + title, + }, + }, + }); + }, async setTitle() { this.$refs.sidebarItem.collapse(); @@ -92,7 +124,7 @@ export default { try { this.loading = true; - await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); + await this.setActiveBoardItemTitle(); localStorage.removeItem(this.pendingChangesStorageKey); this.showChangesAlert = false; } catch (e) { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index b557dc9205e..d12270e58a4 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -12,6 +12,11 @@ import groupBoardQuery from './graphql/group_board.query.graphql'; import projectBoardQuery from './graphql/project_board.query.graphql'; import listIssuesQuery from './graphql/lists_issues.query.graphql'; +export const BoardType = { + project: 'project', + group: 'group', +}; + export const ListType = { assignee: 'assignee', milestone: 'milestone', diff --git a/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql new file mode 100644 index 00000000000..81b1b68a038 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" + +query activeBoardItem { + activeBoardItem @client { + ...Issue + } +} diff --git a/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql new file mode 100644 index 00000000000..cce558c649e --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" + +mutation setActiveBoardItem($boardItem: Issue) { + setActiveBoardItem(boardItem: $boardItem) @client { + ...Issue + } +} diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index e895df01f2c..4d1c4be73a3 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,5 +1,3 @@ -/* eslint-disable func-names */ - import $ from 'jquery'; import { hide, initTooltips, show } from '~/tooltips'; import { parseBoolean } from './lib/utils/common_utils'; @@ -24,6 +22,7 @@ export default class BuildArtifacts { // eslint-disable-next-line class-methods-use-this setupEntryClick() { + // eslint-disable-next-line func-names return $('.tree-holder').on('click', 'tr[data-link]', function () { visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink)); }); diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/ci/artifacts/components/app.vue index 3a07be65341..3a07be65341 100644 --- a/app/assets/javascripts/artifacts/components/app.vue +++ b/app/assets/javascripts/ci/artifacts/components/app.vue diff --git a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue index 14edd73824e..14edd73824e 100644 --- a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue index f37c4c6f107..f37c4c6f107 100644 --- a/app/assets/javascripts/artifacts/components/artifact_row.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue diff --git a/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue new file mode 100644 index 00000000000..b864fc00bdb --- /dev/null +++ b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue @@ -0,0 +1,65 @@ +<script> +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { + I18N_BULK_DELETE_BANNER, + I18N_BULK_DELETE_CLEAR_SELECTION, + I18N_BULK_DELETE_DELETE_SELECTED, +} from '../constants'; + +export default { + name: 'ArtifactsBulkDelete', + components: { + GlButton, + GlSprintf, + }, + props: { + selectedArtifacts: { + type: Array, + required: true, + }, + }, + computed: { + checkedCount() { + return this.selectedArtifacts.length || 0; + }, + }, + i18n: { + banner: I18N_BULK_DELETE_BANNER, + clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION, + deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED, + }, +}; +</script> +<template> + <div + v-if="selectedArtifacts.length > 0" + class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100" + data-testid="bulk-delete-container" + > + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="$options.i18n.banner(checkedCount)"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button + variant="default" + data-testid="bulk-delete-clear-button" + @click="$emit('clearSelectedArtifacts')" + > + {{ $options.i18n.clearSelection }} + </gl-button> + <gl-button + variant="danger" + data-testid="bulk-delete-delete-button" + @click="$emit('showBulkDeleteModal')" + > + {{ $options.i18n.deleteSelected }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue index 7d675251ffd..7d675251ffd 100644 --- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue diff --git a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue new file mode 100644 index 00000000000..00f5b2eab7d --- /dev/null +++ b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue @@ -0,0 +1,73 @@ +<script> +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { + I18N_BULK_DELETE_MODAL_TITLE, + I18N_BULK_DELETE_BODY, + I18N_BULK_DELETE_ACTION, + I18N_MODAL_CANCEL, + BULK_DELETE_MODAL_ID, +} from '../constants'; + +export default { + name: 'BulkDeleteModal', + components: { + GlModal, + GlSprintf, + }, + props: { + visible: { + type: Boolean, + required: true, + }, + artifactsToDelete: { + type: Array, + required: true, + }, + isDeleting: { + type: Boolean, + required: true, + }, + }, + computed: { + checkedCount() { + return this.artifactsToDelete.length || 0; + }, + modalActionPrimary() { + return { + text: I18N_BULK_DELETE_ACTION(this.checkedCount), + attributes: { + loading: this.isDeleting, + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: I18N_MODAL_CANCEL, + attributes: { + disabled: this.isDeleting, + }, + }; + }, + }, + BULK_DELETE_MODAL_ID, + i18n: { + modalTitle: I18N_BULK_DELETE_MODAL_TITLE, + modalBody: I18N_BULK_DELETE_BODY, + }, +}; +</script> +<template> + <gl-modal + size="sm" + :modal-id="$options.BULK_DELETE_MODAL_ID" + :visible="visible" + :title="$options.i18n.modalTitle(checkedCount)" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + v-bind="$attrs" + v-on="$listeners" + > + <gl-sprintf :message="$options.i18n.modalBody(checkedCount)" /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue index d2c96b1a201..d2c96b1a201 100644 --- a/app/assets/javascripts/artifacts/components/feedback_banner.vue +++ b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index ba4026190a2..a93964eef99 100644 --- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -11,12 +11,15 @@ import { GlFormCheckbox, } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils'; +import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; +import { removeArtifactFromStore } from '../graphql/cache_update'; import { STATUS_BADGE_VARIANTS, I18N_DOWNLOAD, @@ -36,10 +39,13 @@ import { JOBS_PER_PAGE, INITIAL_LAST_PAGE_SIZE, BULK_DELETE_FEATURE_FLAG, + I18N_BULK_DELETE_ERROR, + I18N_BULK_DELETE_PARTIAL_ERROR, I18N_BULK_DELETE_CONFIRMATION_TOAST, } from '../constants'; import JobCheckbox from './job_checkbox.vue'; import ArtifactsBulkDelete from './artifacts_bulk_delete.vue'; +import BulkDeleteModal from './bulk_delete_modal.vue'; import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; import FeedbackBanner from './feedback_banner.vue'; @@ -67,11 +73,12 @@ export default { TimeAgo, JobCheckbox, ArtifactsBulkDelete, + BulkDeleteModal, ArtifactsTableRowDetails, FeedbackBanner, }, mixins: [glFeatureFlagsMixin()], - inject: ['projectPath', 'canDestroyArtifacts'], + inject: ['projectId', 'projectPath', 'canDestroyArtifacts'], apollo: { jobArtifacts: { query: getJobArtifactsQuery, @@ -106,6 +113,9 @@ export default { expandedJobs: [], selectedArtifacts: [], pagination: INITIAL_PAGINATION_STATE, + isBulkDeleteModalVisible: false, + jobArtifactsToDelete: [], + isBulkDeleting: false, }; }, computed: { @@ -144,6 +154,12 @@ export default { canBulkDestroyArtifacts() { return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts; }, + isDeletingArtifactsForJob() { + return this.jobArtifactsToDelete.length > 0; + }, + artifactsToDelete() { + return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts; + }, }, methods: { refetchArtifacts() { @@ -191,12 +207,70 @@ export default { this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1); } }, + onConfirmBulkDelete(e) { + // don't close modal until deletion is complete + if (e) { + e.preventDefault(); + } + this.isBulkDeleting = true; + + this.$apollo + .mutate({ + mutation: bulkDestroyJobArtifactsMutation, + variables: { + projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), + ids: this.artifactsToDelete, + }, + update: (store, { data }) => { + const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts; + if (errors?.length) { + createAlert({ + message: I18N_BULK_DELETE_PARTIAL_ERROR, + captureError: true, + error: new Error(errors.join(' ')), + }); + } + if (destroyedIds?.length) { + this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(destroyedCount)); + + // Remove deleted artifacts from the cache + destroyedIds.forEach((id) => { + removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables); + }); + store.gc(); + + if (!this.isDeletingArtifactsForJob) { + this.clearSelectedArtifacts(); + } + } + }, + }) + .catch((error) => { + this.onError(error); + }) + .finally(() => { + this.isBulkDeleting = false; + this.isBulkDeleteModalVisible = false; + this.jobArtifactsToDelete = []; + }); + }, + onError(error) { + createAlert({ + message: I18N_BULK_DELETE_ERROR, + captureError: true, + error, + }); + }, + handleBulkDeleteModalShow() { + this.isBulkDeleteModalVisible = true; + }, + handleBulkDeleteModalHidden() { + this.isBulkDeleteModalVisible = false; + this.jobArtifactsToDelete = []; + }, clearSelectedArtifacts() { this.selectedArtifacts = []; }, - showDeletedToast(deletedCount) { - this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount)); - }, downloadPath(job) { return job.archive?.downloadPath; }, @@ -206,6 +280,13 @@ export default { browseButtonDisabled(job) { return !job.browseArtifactsPath; }, + deleteButtonDisabled(job) { + return !job.hasArtifacts || !this.canBulkDestroyArtifacts; + }, + deleteArtifactsForJob(job) { + this.jobArtifactsToDelete = job.artifacts.nodes.map((node) => node.id); + this.handleBulkDeleteModalShow(); + }, }, fields: [ { @@ -257,11 +338,17 @@ export default { <div> <feedback-banner /> <artifacts-bulk-delete - v-if="canBulkDestroyArtifacts && anyArtifactsSelected" + v-if="canBulkDestroyArtifacts" :selected-artifacts="selectedArtifacts" - :query-variables="queryVariables" @clearSelectedArtifacts="clearSelectedArtifacts" - @deleted="showDeletedToast" + @showBulkDeleteModal="handleBulkDeleteModalShow" + /> + <bulk-delete-modal + :visible="isBulkDeleteModalVisible" + :artifacts-to-delete="artifactsToDelete" + :is-deleting="isBulkDeleting" + @primary="onConfirmBulkDelete" + @hidden="handleBulkDeleteModalHidden" /> <gl-table :items="jobArtifacts" @@ -382,10 +469,11 @@ export default { <gl-button v-if="canDestroyArtifacts" icon="remove" + :disabled="deleteButtonDisabled(item)" :title="$options.i18n.delete" :aria-label="$options.i18n.delete" data-testid="job-artifacts-delete-button" - disabled + @click="deleteArtifactsForJob(item)" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue index ce49b3f8678..ce49b3f8678 100644 --- a/app/assets/javascripts/artifacts/components/job_checkbox.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js index 4ac20d963d1..4ac20d963d1 100644 --- a/app/assets/javascripts/artifacts/constants.js +++ b/app/assets/javascripts/ci/artifacts/constants.js diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js index 9fa6114c7d4..9fa6114c7d4 100644 --- a/app/assets/javascripts/artifacts/graphql/cache_update.js +++ b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js diff --git a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql index 421b9258ca0..421b9258ca0 100644 --- a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql index 529224b47e6..529224b47e6 100644 --- a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql index 23da65ad0bb..23da65ad0bb 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql index 5737f9f8e8d..5737f9f8e8d 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js index 6e795fd9bd7..6e795fd9bd7 100644 --- a/app/assets/javascripts/artifacts/index.js +++ b/app/assets/javascripts/ci/artifacts/index.js diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js index ebcf0af8d2a..ebcf0af8d2a 100644 --- a/app/assets/javascripts/artifacts/utils.js +++ b/app/assets/javascripts/ci/artifacts/utils.js diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue index 7387a490177..09b02068388 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -1,16 +1,25 @@ <script> -import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { debounce, uniq } from 'lodash'; +import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, s__, sprintf } from '~/locale'; import { convertEnvironmentScope } from '../utils'; +import { ENVIRONMENT_QUERY_LIMIT } from '../constants'; export default { name: 'CiEnvironmentsDropdown', components: { + GlCollapsibleListbox, GlDropdownDivider, GlDropdownItem, - GlCollapsibleListbox, + GlSprintf, }, + mixins: [glFeatureFlagsMixin()], props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, environments: { type: Array, required: true, @@ -33,24 +42,52 @@ export default { }, filteredEnvironments() { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.environments.filter((environment) => { + return environment.toLowerCase().includes(lowerCasedSearchTerm); + }); + }, + isEnvScopeLimited() { + return this.glFeatures?.ciLimitEnvironmentScope; + }, + searchedEnvironments() { + // If FF is enabled, search query will be fired so this component will already + // receive filtered environments during the refetch. + // If FF is disabled, search the existing list of environments in the frontend + let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments; + + // If there is no search term, make sure to include * + if (this.isEnvScopeLimited && !this.searchTerm) { + filtered = uniq([...filtered, '*']); + } - return this.environments - .filter((environment) => { - return environment.toLowerCase().includes(lowerCasedSearchTerm); - }) - .map((environment) => ({ - value: environment, - text: environment, - })); + return filtered.sort().map((environment) => ({ + value: environment, + text: environment, + })); + }, + shouldShowSearchLoading() { + return this.areEnvironmentsLoading && this.isEnvScopeLimited; }, shouldRenderCreateButton() { return this.searchTerm && !this.environments.includes(this.searchTerm); }, + shouldRenderDivider() { + return ( + (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading + ); + }, environmentScopeLabel() { return convertEnvironmentScope(this.selectedEnvironmentScope); }, }, methods: { + debouncedSearch: debounce(function debouncedSearch(searchTerm) { + const newSearchTerm = searchTerm.trim(); + this.searchTerm = newSearchTerm; + if (this.isEnvScopeLimited) { + this.$emit('search-environment-scope', newSearchTerm); + } + }, 500), selectEnvironment(selected) { this.$emit('select-environment', selected); this.selectedEnvironment = selected; @@ -60,22 +97,46 @@ export default { this.selectEnvironment(this.searchTerm); }, }, + ENVIRONMENT_QUERY_LIMIT, + i18n: { + maxEnvsNote: s__( + 'CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query.', + ), + }, }; </script> <template> <gl-collapsible-listbox v-model="selectedEnvironment" + block searchable - :items="filteredEnvironments" + :items="searchedEnvironments" + :searching="shouldShowSearchLoading" :toggle-text="environmentScopeLabel" - @search="searchTerm = $event.trim()" + @search="debouncedSearch" @select="selectEnvironment" > - <template v-if="shouldRenderCreateButton" #footer> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope"> - {{ composedCreateButtonLabel }} - </gl-dropdown-item> + <template #footer> + <gl-dropdown-divider v-if="shouldRenderDivider" /> + <div v-if="isEnvScopeLimited" data-testid="max-envs-notice"> + <gl-dropdown-item class="gl-list-style-none" disabled> + <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm"> + <template #limit> + {{ $options.ENVIRONMENT_QUERY_LIMIT }} + </template> + </gl-sprintf> + </gl-dropdown-item> + </div> + <div v-if="shouldRenderCreateButton"> + <!-- TODO: Rethink create wildcard button. https://gitlab.com/gitlab-org/gitlab/-/issues/396928 --> + <gl-dropdown-item + class="gl-list-style-none" + data-testid="create-wildcard-button" + @click="createEnvironmentScope" + > + {{ composedCreateButtonLabel }} + </gl-dropdown-item> + </div> </template> </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 16034cce381..b3ecaceba69 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -74,6 +74,10 @@ export default { 'maskableRegex', ], props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, areScopedVariablesAvailable: { type: Boolean, required: false, @@ -142,7 +146,11 @@ export default { isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, - joinedEnvironments() { + environmentsList() { + if (this.glFeatures?.ciLimitEnvironmentScope) { + return this.environments; + } + return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments); }, maskedFeedback() { @@ -368,10 +376,12 @@ export default { </template> <ci-environments-dropdown v-if="areScopedVariablesAvailable" + :are-environments-loading="areEnvironmentsLoading" :selected-environment-scope="variable.environmentScope" - :environments="joinedEnvironments" + :environments="environmentsList" @select-environment="setEnvironmentScope" @create-environment-scope="createEnvironmentScope" + @search-environment-scope="$emit('search-environment-scope', $event)" /> <gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly /> @@ -450,7 +460,7 @@ export default { data-testid="aws-guidance-tip" @dismiss="dismissTip" > - <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3"> + <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3"> <div> <p> <gl-sprintf :message="$options.i18n.awsTipMessage"> @@ -505,7 +515,6 @@ export default { ref="deleteCiVariable" variant="danger" category="secondary" - data-qa-selector="ci_variable_delete_button" @click="deleteVarAndClose" >{{ __('Delete variable') }}</gl-button > diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index 257c3309e10..26e20c690bc 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -9,6 +9,10 @@ export default { CiVariableModal, }, props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, areScopedVariablesAvailable: { type: Boolean, required: false, @@ -100,6 +104,7 @@ export default { /> <ci-variable-modal v-if="showModal" + :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :environments="environments" :hide-environment-scope="hideEnvironmentScope" @@ -110,6 +115,7 @@ export default { @delete-variable="deleteVariable" @hideModal="hideModal" @update-variable="updateVariable" + @search-environment-scope="$emit('search-environment-scope', $event)" /> </div> </div> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 9db9bea63b2..ee2c0a771cf 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -6,8 +6,10 @@ import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, + ENVIRONMENT_QUERY_LIMIT, SORT_DIRECTIONS, UPDATE_MUTATION_ACTION, + mapMutationActionToToast, environmentFetchErrorText, genericMutationErrorText, variableFetchErrorText, @@ -162,6 +164,7 @@ export default { variables() { return { fullPath: this.fullPath, + ...this.environmentQueryVariables, }; }, update(data) { @@ -173,10 +176,26 @@ export default { }, }, computed: { + areEnvironmentsLoading() { + return this.$apollo.queries.environments.loading; + }, + environmentQueryVariables() { + if (this.glFeatures?.ciLimitEnvironmentScope) { + return { + first: ENVIRONMENT_QUERY_LIMIT, + search: '', + }; + } + + return {}; + }, isLoading() { + // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when + // environment query is loading and FF is enabled + // https://gitlab.com/gitlab-org/gitlab/-/issues/396990 return ( (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) || - this.$apollo.queries.environments.loading || + this.areEnvironmentsLoading || this.isLoadingMoreItems ); }, @@ -228,6 +247,11 @@ export default { updateVariable(variable) { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, + async searchEnvironmentScope(searchTerm) { + if (this.glFeatures?.ciLimitEnvironmentScope) { + this.$apollo.queries.environments.refetch({ search: searchTerm }); + } + }, async variableMutation(mutationAction, variable) { try { const currentMutation = this.mutationData[mutationAction]; @@ -245,11 +269,15 @@ export default { if (data.ciVariableMutation?.errors?.length) { const { errors } = data.ciVariableMutation; createAlert({ message: errors[0] }); - } else if (this.refetchAfterMutation) { - // The writing to cache for admin variable is not working - // because there is no ID in the cache at the top level. - // We therefore need to manually refetch. - this.$apollo.queries.ciVariables.refetch(); + } else { + this.$toast.show(mapMutationActionToToast[mutationAction](variable.key)); + + if (this.refetchAfterMutation) { + // The writing to cache for admin variable is not working + // because there is no ID in the cache at the top level. + // We therefore need to manually refetch. + this.$apollo.queries.ciVariables.refetch(); + } } } catch (e) { createAlert({ message: genericMutationErrorText }); @@ -264,6 +292,7 @@ export default { <template> <ci-variable-settings + :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :entity="entity" :environments="environments" @@ -277,6 +306,7 @@ export default { @handle-prev-page="handlePrevPage" @handle-next-page="handleNextPage" @sort-changed="handleSortChanged" + @search-environment-scope="searchEnvironmentScope" @update-variable="updateVariable" /> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue index 5e367ff33b2..6f6c55e07c7 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue @@ -175,12 +175,7 @@ export default { v-if="glFeatures.ciVariablesPages" class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3" > - <gl-button - v-if="!isTableEmpty" - data-qa-selector="reveal_ci_variable_value_button" - @click="toggleHiddenState" - >{{ valuesButtonText }}</gl-button - > + <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button> <gl-button v-gl-modal-directive="$options.modalId" class="gl-mx-3" @@ -317,12 +312,7 @@ export default { @click="setSelectedVariable()" >{{ __('Add variable') }}</gl-button > - <gl-button - v-if="!isTableEmpty" - data-qa-selector="reveal_ci_variable_value_button" - @click="toggleHiddenState" - >{{ valuesButtonText }}</gl-button - > + <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button> </div> <div v-else class="gl-display-flex gl-justify-content-center gl-mt-6"> <gl-keyset-pagination diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index c77d8c67bc8..c8f67bd3436 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -1,6 +1,7 @@ -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; +export const ENVIRONMENT_QUERY_LIMIT = 30; export const SORT_DIRECTIONS = { ASC: 'KEY_ASC', @@ -97,6 +98,19 @@ export const ADD_MUTATION_ACTION = 'add'; export const UPDATE_MUTATION_ACTION = 'update'; export const DELETE_MUTATION_ACTION = 'delete'; +export const ADD_VARIABLE_TOAST = (key) => + sprintf(s__('CiVariable|Variable %{key} has been successfully added.'), { key }); +export const UPDATE_VARIABLE_TOAST = (key) => + sprintf(s__('CiVariable|Variable %{key} has been updated.'), { key }); +export const DELETE_VARIABLE_TOAST = (key) => + sprintf(s__('CiVariable|Variable %{key} has been deleted.'), { key }); + +export const mapMutationActionToToast = { + [ADD_MUTATION_ACTION]: ADD_VARIABLE_TOAST, + [UPDATE_MUTATION_ACTION]: UPDATE_VARIABLE_TOAST, + [DELETE_MUTATION_ACTION]: DELETE_VARIABLE_TOAST, +}; + export const EXPANDED_VARIABLES_NOTE = __( '%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.', ); diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql index 921e0ca25b9..26d1b6a3aaa 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql @@ -1,7 +1,7 @@ -query getProjectEnvironments($fullPath: ID!) { +query getProjectEnvironments($fullPath: ID!, $first: Int, $search: String) { project(fullPath: $fullPath) { id - environments { + environments(first: $first, search: $search) { nodes { id name diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue index 4775836fcc6..3fe9103c2b3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue @@ -146,7 +146,7 @@ export default { </gl-sprintf> </gl-form-checkbox> </gl-form-group> - <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1"> + <div class="gl-display-flex gl-py-5"> <gl-button type="submit" class="js-no-auto-disable gl-mr-3" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue new file mode 100644 index 00000000000..25bbd6b3180 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue @@ -0,0 +1,104 @@ +<script> +import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; +import { get, toPath } from 'lodash'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlFormGroup, + GlAccordionItem, + GlFormInput, + GlButton, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + formOptions() { + return [ + { + key: 'artifacts.paths', + title: i18n.ARTIFACTS_AND_CACHE, + paths: this.job.artifacts.paths, + generateInputDataTestId: (index) => `artifacts-paths-input-${index}`, + generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`, + addButtonDataTestId: 'add-artifacts-paths-button', + }, + { + key: 'artifacts.exclude', + title: i18n.ARTIFACTS_EXCLUDE_PATHS, + paths: this.job.artifacts.exclude, + generateInputDataTestId: (index) => `artifacts-exclude-input-${index}`, + generateDeleteButtonDataTestId: (index) => `delete-artifacts-exclude-button-${index}`, + addButtonDataTestId: 'add-artifacts-exclude-button', + }, + { + key: 'cache.paths', + title: i18n.CACHE_PATHS, + paths: this.job.cache.paths, + generateInputDataTestId: (index) => `cache-paths-input-${index}`, + generateDeleteButtonDataTestId: (index) => `delete-cache-paths-button-${index}`, + addButtonDataTestId: 'add-cache-paths-button', + }, + ]; + }, + }, + methods: { + deleteStringArrayItem(path) { + const parentPath = toPath(path).slice(0, -1); + const array = get(this.job, parentPath); + if (array.length <= 1) { + return; + } + this.$emit('update-job', path); + }, + }, +}; +</script> +<template> + <gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE"> + <div v-for="entry in formOptions" :key="entry.key" class="form-group"> + <div class="gl-display-flex"> + <label class="gl-font-weight-bold gl-mb-3">{{ entry.title }}</label> + </div> + <div + v-for="(path, index) in entry.paths" + :key="index" + class="gl-display-flex gl-align-items-center gl-mb-3" + > + <div class="gl-flex-grow-1 gl-flex-basis-0 gl-mr-3"> + <gl-form-input + class="gl-w-full!" + :value="path" + :data-testid="entry.generateInputDataTestId(index)" + @input="$emit('update-job', `${entry.key}[${index}]`, $event)" + /> + </div> + <gl-button + category="tertiary" + icon="remove" + :data-testid="entry.generateDeleteButtonDataTestId(index)" + @click="deleteStringArrayItem(`${entry.key}[${index}]`)" + /> + </div> + <gl-button + category="secondary" + variant="confirm" + :data-testid="entry.addButtonDataTestId" + @click="$emit('update-job', `${entry.key}[${entry.paths.length}]`, '')" + >{{ $options.i18n.ADD_PATH }}</gl-button + > + </div> + <gl-form-group :label="$options.i18n.CACHE_KEY"> + <gl-form-input + :value="job.cache.key" + data-testid="cache-key-input" + @input="$emit('update-job', 'cache.key', $event)" + /> + </gl-form-group> + </gl-accordion-item> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue index c2ae7d7be49..c23a0b866d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue @@ -20,14 +20,20 @@ export default { <template> <gl-accordion-item :title="$options.i18n.IMAGE"> <div class="gl-display-flex"> - <gl-form-group class="gl-flex-grow-1 gl-mr-3" :label="$options.i18n.IMAGE_NAME"> + <gl-form-group + class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" + :label="$options.i18n.IMAGE_NAME" + > <gl-form-input :value="job.image.name" data-testid="image-name-input" @input="$emit('update-job', 'image.name', $event)" /> </gl-form-group> - <gl-form-group class="gl-flex-grow-1" :label="$options.i18n.IMAGE_ENTRYPOINT"> + <gl-form-group + class="gl-flex-grow-1 gl-flex-basis-half" + :label="$options.i18n.IMAGE_ENTRYPOINT" + > <gl-form-input :value="job.image.entrypoint.join(' ')" data-testid="image-entrypoint-input" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue index a25b3ca09fd..b49355d539c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue @@ -7,7 +7,6 @@ import { GlTokenSelector, GlFormCombobox, } from '@gitlab/ui'; -import { mapState } from 'vuex'; import { i18n } from '../constants'; export default { @@ -37,9 +36,10 @@ export default { type: Boolean, required: true, }, - }, - computed: { - ...mapState(['availableStages']), + availableStages: { + type: Array, + required: true, + }, }, }; </script> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue new file mode 100644 index 00000000000..d068b370852 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue @@ -0,0 +1,105 @@ +<script> +import { + GlFormGroup, + GlAccordionItem, + GlFormInput, + GlFormSelect, + GlFormCheckbox, +} from '@gitlab/ui'; +import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants'; + +export default { + i18n, + whenOptions: Object.values(JOB_RULES_WHEN), + unitOptions: Object.values(JOB_RULES_START_IN), + components: { + GlAccordionItem, + GlFormInput, + GlFormSelect, + GlFormCheckbox, + GlFormGroup, + }, + props: { + job: { + type: Object, + required: true, + }, + isStartValid: { + type: Boolean, + required: true, + }, + }, + data() { + return { + startInNumber: 1, + startInUnit: JOB_RULES_START_IN.second.value, + }; + }, + computed: { + isDelayed() { + return this.job.rules[0].when === JOB_RULES_WHEN.delayed.value; + }, + }, + methods: { + updateStartIn() { + const plural = this.startInNumber > 1 ? 's' : ''; + this.$emit( + 'update-job', + 'rules[0].start_in', + `${this.startInNumber} ${this.startInUnit}${plural}`, + ); + }, + }, +}; +</script> +<template> + <gl-accordion-item :title="$options.i18n.RULES"> + <div class="gl-display-flex"> + <gl-form-group class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" :label="$options.i18n.WHEN"> + <gl-form-select + class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" + :options="$options.whenOptions" + data-testid="rules-when-select" + :value="job.rules[0].when" + @input="$emit('update-job', 'rules[0].when', $event)" + /> + </gl-form-group> + <gl-form-group + class="gl-flex-grow-1 gl-flex-basis-half" + :invalid-feedback="$options.i18n.INVALID_START_IN" + :state="isStartValid" + > + <div class="gl-display-flex gl-mt-5"> + <gl-form-input + v-model="startInNumber" + class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" + data-testid="rules-start-in-number-input" + type="number" + :state="isStartValid" + :class="{ 'gl-visibility-hidden': !isDelayed }" + number + @input="updateStartIn" + /> + <gl-form-select + v-model="startInUnit" + class="gl-flex-grow-1 gl-flex-basis-half" + data-testid="rules-start-in-unit-select" + :state="isStartValid" + :class="{ 'gl-visibility-hidden': !isDelayed }" + :options="$options.unitOptions" + @input="updateStartIn" + /> + </div> + </gl-form-group> + </div> + <gl-form-group> + <gl-form-checkbox + :checked="job.rules[0].allow_failure" + data-testid="rules-allow-failure-checkbox" + @input="$emit('update-job', 'rules[0].allow_failure', $event)" + > + {{ $options.i18n.ALLOW_FAILURE }} + </gl-form-checkbox> + </gl-form-group> + </gl-accordion-item> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js index 994a6e719fe..df3a2c64e25 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js @@ -2,6 +2,59 @@ import { __, s__ } from '~/locale'; export const DRAWER_CONTAINER_CLASS = '.content-wrapper'; +export const JOB_RULES_WHEN = { + onSuccess: { + value: 'on_success', + text: s__('JobAssistant|on_success'), + }, + onFailure: { + value: 'on_failure', + text: s__('JobAssistant|on_failure'), + }, + manual: { + value: 'manual', + text: s__('JobAssistant|manual'), + }, + always: { + value: 'always', + text: s__('JobAssistant|always'), + }, + delayed: { + value: 'delayed', + text: s__('JobAssistant|delayed'), + }, + never: { + value: 'never', + text: s__('JobAssistant|never'), + }, +}; + +export const JOB_RULES_START_IN = { + second: { + value: 'second', + text: s__('JobAssistant|second(s)'), + }, + minute: { + value: 'minute', + text: s__('JobAssistant|minute(s)'), + }, + day: { + value: 'day', + text: s__('JobAssistant|day(s)'), + }, + week: { + value: 'week', + text: s__('JobAssistant|week(s)'), + }, +}; + +export const SECONDS_MULTIPLE_MAP = { + second: 1, + minute: 60, + day: 3600 * 24, + week: 3600 * 24 * 7, +}; + export const JOB_TEMPLATE = { name: '', stage: '', @@ -25,6 +78,13 @@ export const JOB_TEMPLATE = { paths: [''], key: '', }, + rules: [ + { + allow_failure: false, + when: 'on_success', + start_in: '', + }, + ], }; export const i18n = { @@ -38,4 +98,14 @@ export const i18n = { IMAGE_NAME: s__('JobAssistant|Image name (optional)'), IMAGE_ENTRYPOINT: s__('JobAssistant|Image entrypoint (optional)'), THIS_FIELD_IS_REQUIRED: __('This field is required'), + CACHE_PATHS: s__('JobAssistant|Cache paths (optional)'), + CACHE_KEY: s__('JobAssistant|Cache key (optional)'), + ARTIFACTS_EXCLUDE_PATHS: s__('JobAssistant|Artifacts exclude paths (optional)'), + ARTIFACTS_PATHS: s__('JobAssistant|Artifacts paths (optional)'), + ARTIFACTS_AND_CACHE: s__('JobAssistant|Artifacts and cache'), + ADD_PATH: s__('JobAssistant|Add path'), + RULES: s__('JobAssistant|Rules'), + WHEN: s__('JobAssistant|When'), + ALLOW_FAILURE: s__('JobAssistant|Allow failure'), + INVALID_START_IN: s__('JobAssistant|Error - Valid value is between 1 second and 1 week'), }; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue index 9f68b97b329..8cde20bc22e 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue @@ -1,16 +1,16 @@ <script> import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui'; -import { stringify } from 'yaml'; -import { mapMutations, mapState } from 'vuex'; -import { set, omit, trim } from 'lodash'; +import { stringify, parse } from 'yaml'; +import { get, omit, toPath } from 'lodash'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; -import { UPDATE_CI_CONFIG } from '~/ci/pipeline_editor/store/mutation_types'; -import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql'; -import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants'; -import { removeEmptyObj, trimFields } from './utils'; +import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql'; +import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants'; +import { removeEmptyObj, trimFields, validateEmptyValue, validateStartIn } from './utils'; import JobSetupItem from './accordion_items/job_setup_item.vue'; import ImageItem from './accordion_items/image_item.vue'; +import ArtifactsAndCacheItem from './accordion_items/artifacts_and_cache_item.vue'; +import RulesItem from './accordion_items/rules_item.vue'; export default { i18n, @@ -20,6 +20,8 @@ export default { GlButton, JobSetupItem, ImageItem, + ArtifactsAndCacheItem, + RulesItem, }, props: { isVisible: { @@ -32,24 +34,38 @@ export default { required: false, default: 200, }, + ciConfigData: { + type: Object, + required: true, + }, + ciFileContent: { + type: String, + required: true, + }, }, data() { return { isNameValid: true, isScriptValid: true, + isStartValid: true, job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), }; }, apollo: { runners: { - query: getAllRunners, + query: getRunnerTags, update(data) { return data?.runners?.nodes || []; }, }, }, computed: { - ...mapState(['currentCiFileContent']), + availableStages() { + if (this.ciConfigData?.mergedYaml) { + return parse(this.ciConfigData.mergedYaml).stages; + } + return []; + }, tagOptions() { const options = []; this.runners?.forEach((runner) => options.push(...runner.tagList)); @@ -63,25 +79,36 @@ export default { drawerHeightOffset() { return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); }, + isJobValid() { + return this.isNameValid && this.isScriptValid && this.isStartValid; + }, + }, + + watch: { + 'job.name': function jobNameWatch(name) { + this.isNameValid = validateEmptyValue(name); + }, + 'job.script': function jobScriptWatch(script) { + this.isScriptValid = validateEmptyValue(script); + }, + 'job.rules.0.start_in': function JobRulesStartInWatch(startIn) { + this.isStartValid = validateStartIn(this.job.rules[0].when, startIn); + }, }, methods: { - ...mapMutations({ - updateCiConfig: UPDATE_CI_CONFIG, - }), closeDrawer() { this.clearJob(); this.$emit('close-job-assistant-drawer'); }, addCiConfig() { - this.isNameValid = this.validate(this.job.name); - this.isScriptValid = this.validate(this.job.script); + this.validateJob(); - if (!this.isNameValid || !this.isScriptValid) { + if (!this.isJobValid) { return; } const newJobString = this.generateYmlString(); - this.updateCiConfig(`${this.currentCiFileContent}\n${newJobString}`); + this.$emit('updateCiConfig', `${this.ciFileContent}\n${newJobString}`); eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM); this.closeDrawer(); @@ -89,27 +116,53 @@ export default { generateYmlString() { let job = JSON.parse(JSON.stringify(this.job)); const jobName = job.name; - job = omit(job, ['name']); + job = this.removeUnnecessaryKeys(job); job.tags = job.tags.map((tag) => tag.name); // Tag item is originally an option object, we need a string here to match `.gitlab-ci.yml` rules const cleanedJob = trimFields(removeEmptyObj(job)); return stringify({ [jobName]: cleanedJob }); }, + removeUnnecessaryKeys(job) { + const keys = ['name']; + + // rules[0].allow_failure value should not be passed down + // if it equals the default value + if (this.job.rules[0].allow_failure === false) { + keys.push('rules[0].allow_failure'); + } + // rules[0].when value should not be passed down + // if it equals the default value + if (this.job.rules[0].when === JOB_RULES_WHEN.onSuccess.value) { + keys.push('rules[0].when'); + } + // rules[0].start_in value should not be passed down + // if rules[0].start_in doesn't equal 'delayed' + if (this.job.rules[0].when !== JOB_RULES_WHEN.delayed.value) { + keys.push('rules[0].start_in'); + } + return omit(job, keys); + }, clearJob() { this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE)); - this.isNameValid = true; - this.isScriptValid = true; + this.$nextTick(() => { + this.isNameValid = true; + this.isScriptValid = true; + this.isStartValid = true; + }); }, updateJob(key, value) { - set(this.job, key, value); - if (key === 'name') { - this.isNameValid = this.validate(this.job.name); - } - if (key === 'script') { - this.isScriptValid = this.validate(this.job.script); + const path = toPath(key); + const targetObj = path.length === 1 ? this.job : get(this.job, path.slice(0, -1)); + const lastKey = path[path.length - 1]; + if (value !== undefined) { + this.$set(targetObj, lastKey, value); + } else { + this.$delete(targetObj, lastKey); } }, - validate(value) { - return trim(value) !== ''; + validateJob() { + this.isNameValid = validateEmptyValue(this.job.name); + this.isScriptValid = validateEmptyValue(this.job.script); + this.isStartValid = validateStartIn(this.job.rules[0].when, this.job.rules[0].start_in); }, }, }; @@ -131,9 +184,12 @@ export default { :job="job" :is-name-valid="isNameValid" :is-script-valid="isScriptValid" + :available-stages="availableStages" @update-job="updateJob" /> <image-item :job="job" @update-job="updateJob" /> + <artifacts-and-cache-item :job="job" @update-job="updateJob" /> + <rules-item :job="job" :is-start-valid="isStartValid" @update-job="updateJob" /> </gl-accordion> <template #footer> <div class="gl-display-flex gl-justify-content-end"> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js index 83e7574c4de..a604d79259d 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js @@ -1,4 +1,8 @@ import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash'; +import { + JOB_RULES_WHEN, + SECONDS_MULTIPLE_MAP, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; const isEmptyValue = (val) => (isObject(val) || isString(val)) && isEmpty(val); const trimText = (val) => (isString(val) ? trim(val) : val); @@ -20,3 +24,30 @@ export const trimFields = (data) => { } return trimText(data); }; + +export const validateEmptyValue = (value) => { + return trim(value) !== ''; +}; + +export const validateStartIn = (when, startIn) => { + const hasNoValue = when !== JOB_RULES_WHEN.delayed.value; + if (hasNoValue) { + return true; + } + + let [startInNumber, startInUnit] = startIn.split(' '); + + startInNumber = Number(startInNumber); + if (!Number.isInteger(startInNumber)) { + return false; + } + + const isPlural = startInUnit.slice(-1) === 's'; + if (isPlural) { + startInUnit = startInUnit.slice(0, -1); + } + + const multiple = SECONDS_MULTIPLE_MAP[startInUnit]; + + return startInNumber * multiple >= 1 && startInNumber * multiple <= SECONDS_MULTIPLE_MAP.week; +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index dd25c4d433b..e775dc5147a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js @@ -86,25 +86,8 @@ export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/- export const COMMIT_SHA_POLL_INTERVAL = 1000; -export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section'; -export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked'; -export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked'; -export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked'; export const I18N = { title: s__('Pipelines|Get started with GitLab CI/CD'), - runners: { - title: s__('Pipelines|Runners are available to run your jobs now'), - subtitle: s__( - 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.', - ), - }, - noRunners: { - title: s__('Pipelines|No runners detected'), - subtitle: s__( - 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.', - ), - cta: s__('Pipelines|Install GitLab Runner'), - }, learnBasics: { title: s__('Pipelines|Learn the basics of pipelines and .yml files'), subtitle: s__( diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql new file mode 100644 index 00000000000..aab30257d13 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql @@ -0,0 +1,8 @@ +query getRunnerTags { + runners { + nodes { + id + tagList + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index d65a7c321ce..09acd805410 100644 --- a/app/assets/javascripts/ci/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js @@ -12,7 +12,6 @@ import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphq import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; -import createStore from './store'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); @@ -112,11 +111,8 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { }, }); - const store = createStore(); - return new Vue({ el, - store, apolloProvider, provide: { ciConfigPath, diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index 7b3c4d6f74f..ff848a973e3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue @@ -1,12 +1,10 @@ <script> import { GlLoadingIcon, GlModal } from '@gitlab/ui'; -import { mapState, mapMutations } from 'vuex'; -import { parse } from 'yaml'; import { fetchPolicies } from '~/lib/graphql'; import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; + import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; -import { UPDATE_CI_CONFIG, UPDATE_AVAILABLE_STAGES } from './store/mutation_types'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; @@ -46,6 +44,7 @@ export default { data() { return { ciConfigData: {}, + currentCiFileContent: '', failureType: null, failureReasons: [], hasBranchLoaded: false, @@ -95,7 +94,7 @@ export default { const fileContent = rawBlob ?? ''; this.lastCommittedContent = fileContent; - this.updateCiConfig(fileContent); + this.currentCiFileContent = fileContent; // If rawBlob is defined and returns a string, it means that there is // a CI config file with empty content. If `rawBlob` is not defined @@ -156,10 +155,6 @@ export default { this.isLintUnavailable = false; } } - - if (data?.ciConfig?.mergedYaml) { - this.updateAvailableStages(parse(data.ciConfig.mergedYaml).stages); - } }, error() { // We are not using `reportFailure` here because we don't @@ -236,7 +231,6 @@ export default { }, }, computed: { - ...mapState(['currentCiFileContent']), hasUnsavedChanges() { return this.lastCommittedContent !== this.currentCiFileContent; }, @@ -300,10 +294,6 @@ export default { this.checkShouldSkipStartScreen(); }, methods: { - ...mapMutations({ - updateCiConfig: UPDATE_CI_CONFIG, - updateAvailableStages: UPDATE_AVAILABLE_STAGES, - }), checkShouldSkipStartScreen() { const params = queryToObject(window.location.search); this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); @@ -354,7 +344,7 @@ export default { }, resetContent() { this.showResetConfirmationModal = false; - this.updateCiConfig(this.lastCommittedContent); + this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { @@ -371,6 +361,9 @@ export default { showErrorAlert({ type, reasons = [] }) { this.reportFailure(type, reasons); }, + updateCiConfig(ciFileContent) { + this.currentCiFileContent = ciFileContent; + }, updateCommitSha() { this.isFetchingCommitSha = true; this.$apollo.queries.commitSha.refetch(); diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 59863edbe0b..1329042ee4c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -195,6 +195,8 @@ export default { @close-drawer="closeDrawer" /> <job-assistant-drawer + :ci-config-data="ciConfigData" + :ci-file-content="ciFileContent" :is-visible="showJobAssistantDrawer" :z-index="jobAssistantIndex" v-on="$listeners" diff --git a/app/assets/javascripts/ci/pipeline_editor/store/index.js b/app/assets/javascripts/ci/pipeline_editor/store/index.js deleted file mode 100644 index d7d5aed79e2..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export default () => - new Vuex.Store({ - mutations, - state: state(), - }); diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js deleted file mode 100644 index 035d3c90c14..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js +++ /dev/null @@ -1,2 +0,0 @@ -export const UPDATE_CI_CONFIG = 'UPDATE_CI_CONFIG'; -export const UPDATE_AVAILABLE_STAGES = 'UPDATE_AVAILABLE_STAGES'; diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js deleted file mode 100644 index 552c1df9a2c..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.UPDATE_CI_CONFIG](state, content) { - state.currentCiFileContent = content; - }, - [types.UPDATE_AVAILABLE_STAGES](state, stages) { - state.availableStages = stages || []; - }, -}; diff --git a/app/assets/javascripts/ci/pipeline_editor/store/state.js b/app/assets/javascripts/ci/pipeline_editor/store/state.js deleted file mode 100644 index 34146cd54c4..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/state.js +++ /dev/null @@ -1,4 +0,0 @@ -export default () => ({ - currentCiFileContent: '', - availableStages: [], -}); diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql index 648cd8b66b5..f93f5ad4f11 100644 --- a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql +++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql @@ -1,7 +1,7 @@ query ciConfigVariables($fullPath: ID!, $ref: String!) { project(fullPath: $fullPath) { id - ciConfigVariables(sha: $ref) { + ciConfigVariables(ref: $ref) { description key value diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index 56461165588..92f461c72d7 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -23,7 +23,7 @@ export default { </script> <template> - <div> + <div data-testid="last-pipeline-status"> <ci-badge-link v-if="hasPipeline" :status="lastPipelineStatus" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue index 48d59bf6e7c..9c0fc148dac 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue @@ -23,7 +23,7 @@ export default { </script> <template> - <div> + <div data-testid="next-run-cell"> <time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" /> <span v-else data-testid="pipeline-schedule-inactive"> {{ s__('PipelineSchedules|Inactive') }} diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue index 0b95e2037e8..b97914f8c26 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -68,7 +68,12 @@ export default { </script> <template> - <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md"> + <gl-table-lite + :fields="$options.fields" + :items="schedules" + :tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }" + stacked="md" + > <template #table-colgroup="{ fields }"> <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> </template> diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue index 79600012838..43d0dae6e78 100644 --- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue @@ -6,7 +6,7 @@ import { s__ } from '~/locale'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; -import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM } from '../constants'; +import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, INSTANCE_TYPE } from '../constants'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; export default { @@ -34,21 +34,21 @@ export default { }, methods: { onSaved(runner) { - const registerUrl = setUrlParams( - { [PARAM_KEY_PLATFORM]: this.platform }, - runner.registerAdminUrl, - ); + const params = { [PARAM_KEY_PLATFORM]: this.platform }; + const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl); + saveAlertToLocalStorage({ message: s__('Runners|Runner created.'), variant: VARIANT_SUCCESS, }); - redirectTo(registerUrl); + redirectTo(ephemeralRegisterUrl); }, onError(error) { createAlert({ message: error.message }); }, }, modalId: 'runners-legacy-registration-instructions-modal', + INSTANCE_TYPE, }; </script> @@ -84,6 +84,6 @@ export default { <hr aria-hidden="true" /> - <runner-create-form @saved="onSaved" @error="onError" /> + <runner-create-form :runner-type="$options.INSTANCE_TYPE" @saved="onSaved" @error="onError" /> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 97dfbe1a051..24c1b4f5c3b 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -1,6 +1,8 @@ <script> import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; @@ -14,6 +16,7 @@ import { I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, I18N_CREATED_AT_LABEL, + I18N_CREATED_AT_BY_LABEL, } from '../../constants'; import RunnerSummaryField from './runner_summary_field.vue'; @@ -28,6 +31,7 @@ export default { RunnerTypeBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), + UserAvatarLink, TooltipOnTruncate, }, directives: { @@ -43,6 +47,16 @@ export default { jobCount() { return formatJobCount(this.runner.jobCount); }, + createdBy() { + return this.runner?.createdBy; + }, + createdByImgAlt() { + const name = this.createdBy?.name; + if (name) { + return sprintf(__("%{name}'s avatar"), { name }); + } + return null; + }, }, i18n: { I18N_NO_DESCRIPTION, @@ -50,6 +64,7 @@ export default { I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, I18N_CREATED_AT_LABEL, + I18N_CREATED_AT_BY_LABEL, }, }; </script> @@ -106,11 +121,30 @@ export default { </runner-summary-field> <runner-summary-field icon="calendar"> - <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> - <template #timeAgo> - <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> - </template> - </gl-sprintf> + <template v-if="createdBy"> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_BY_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + <template #avatar> + <user-avatar-link + :link-href="createdBy.webUrl" + :img-src="createdBy.avatarUrl" + img-css-classes="gl-vertical-align-top" + :img-size="16" + :img-alt="createdByImgAlt" + :tooltip-text="createdBy.username" + /> + </template> + </gl-sprintf> + </template> + <template v-else> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> </runner-summary-field> </div> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue index 1bbbd55089a..20681873436 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -24,7 +24,7 @@ export default { </script> <template> - <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2"> + <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-4"> <gl-icon v-if="icon" :name="icon" /> <!-- display tooltip as a label for screen readers --> <span class="gl-sr-only">{{ tooltip }}</span> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue index 2f3c172666d..69021dde0e9 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue @@ -70,7 +70,7 @@ export default { captureException({ error, component: this.$options.name }); }, pollInterval() { - if (this.runner?.status === STATUS_ONLINE) { + if (this.isRunnerOnline) { // stop polling return 0; } @@ -97,9 +97,6 @@ export default { } return s__('Runners|Register runner'); }, - status() { - return this.runner?.status; - }, tokenMessage() { if (this.token) { return s__( @@ -116,22 +113,40 @@ export default { registerCommand() { return registerCommand({ platform: this.platform, - registrationToken: this.token, - description: this.description, + token: this.token, }); }, runCommand() { return runCommand({ platform: this.platform }); }, + isRunnerOnline() { + return this.runner?.status === STATUS_ONLINE; + }, + }, + created() { + window.addEventListener('beforeunload', this.onBeforeunload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onBeforeunload); }, methods: { toggleDrawer() { this.$emit('toggleDrawer'); }, + onBeforeunload(event) { + if (this.isRunnerOnline) { + return undefined; + } + + const str = s__('Runners|You may lose access to the runner token if you leave this page.'); + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.returnValue = str; // Chrome requires returnValue to be set + return str; + }, }, EXECUTORS_HELP_URL, SERVICE_COMMANDS_HELP_URL, - STATUS_ONLINE, I18N_REGISTRATION_SUCCESS, }; </script> @@ -226,7 +241,7 @@ export default { </gl-sprintf> </p> </section> - <section v-if="status == $options.STATUS_ONLINE"> + <section v-if="isRunnerOnline"> <h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2> <p class="gl-pl-6"> diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js index 94d75bc4562..c8a75506c9c 100644 --- a/app/assets/javascripts/ci/runner/components/registration/utils.js +++ b/app/assets/javascripts/ci/runner/components/registration/utils.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { DEFAULT_PLATFORM, LINUX_PLATFORM, @@ -28,20 +27,6 @@ const OS = { }, }; -const escapedParam = (param, shell = 'bash') => { - let escaped; - if (shell === 'bash') { - // replace single-quotes by the sequence '\'' - escaped = param.replaceAll("'", "'\\''"); - } else if (shell === 'powershell') { - // replace single-quotes by the sequence '' - // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.3 - escaped = param.replaceAll("'", "''"); - } - // surround with single quotes. - return `'${escaped}'`; -}; - export const commandPrompt = ({ platform }) => { return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt; }; @@ -50,32 +35,19 @@ export const executable = ({ platform }) => { return (OS[platform] || OS[DEFAULT_PLATFORM]).executable; }; -const shell = ({ platform }) => { - return (OS[platform] || OS[DEFAULT_PLATFORM]).shell; -}; - -export const registerCommand = ({ - platform, - url = gon.gitlab_url, - registrationToken, - description, -}) => { - const lines = [`${executable({ platform })} register`]; +export const registerCommand = ({ platform, url = gon.gitlab_url, token }) => { + const lines = [`${executable({ platform })} register`]; // eslint-disable-line @gitlab/require-i18n-strings if (url) { lines.push(` --url ${url}`); } - if (registrationToken) { - lines.push(` --registration-token ${registrationToken}`); - } - if (description) { - const escapedDescription = escapedParam(description, shell({ platform })); - lines.push(` --description ${escapedDescription}`); + if (token) { + lines.push(` --token ${token}`); } return lines; }; export const runCommand = ({ platform }) => { - return `${executable({ platform })} run`; + return `${executable({ platform })} run`; // eslint-disable-line @gitlab/require-i18n-strings }; const importInstallScript = ({ platform = DEFAULT_PLATFORM }) => { diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue index 5d2a3c53842..d3e02f5cd6e 100644 --- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue @@ -4,7 +4,7 @@ import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql'; import { modelToUpdateMutationVariables } from 'ee_else_ce/ci/runner/runner_update_form_utils'; import { captureException } from '../sentry_utils'; -import { DEFAULT_ACCESS_LEVEL } from '../constants'; +import { RUNNER_TYPES, DEFAULT_ACCESS_LEVEL, GROUP_TYPE, INSTANCE_TYPE } from '../constants'; export default { name: 'RunnerCreateForm', @@ -13,6 +13,18 @@ export default { GlButton, RunnerFormFields, }, + props: { + runnerType: { + type: String, + required: true, + validator: (t) => RUNNER_TYPES.includes(t), + }, + groupId: { + type: String, + required: false, + default: null, + }, + }, data() { return { saving: false, @@ -27,6 +39,23 @@ export default { }, }; }, + computed: { + mutationInput() { + const { input } = modelToUpdateMutationVariables(this.runner); + + if (this.runnerType === GROUP_TYPE) { + return { + ...input, + runnerType: GROUP_TYPE, + groupId: this.groupId, + }; + } + return { + ...input, + runnerType: INSTANCE_TYPE, + }; + }, + }, methods: { async onSubmit() { this.saving = true; @@ -37,7 +66,9 @@ export default { }, } = await this.$apollo.mutate({ mutation: runnerCreateMutation, - variables: modelToUpdateMutationVariables(this.runner), + variables: { + input: this.mutationInput, + }, }); if (errors?.length) { diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 6237dcd0c03..1cae9df713b 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -93,6 +93,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( export const I18N_VERSION_LABEL = s__('Runners|Version %{version}'); export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}'); export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}'); +export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{avatar}'); export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited'); export const I18N_ADMIN = s__('Runners|Administrator'); @@ -141,6 +142,7 @@ export const PARAM_KEY_PLATFORM = 'platform'; export const INSTANCE_TYPE = 'INSTANCE_TYPE'; export const GROUP_TYPE = 'GROUP_TYPE'; export const PROJECT_TYPE = 'PROJECT_TYPE'; +export const RUNNER_TYPES = [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE]; // CiRunnerStatus diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index 6f72509f599..0a449ef0435 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment ListItemShared on CiRunner { id description @@ -10,6 +12,9 @@ fragment ListItemShared on CiRunner { jobCount tagList createdAt + createdBy { + ...User + } contactedAt status(legacyMode: null) jobExecutionStatus diff --git a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql index d14a594e378..07236808dca 100644 --- a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql +++ b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql @@ -2,7 +2,7 @@ mutation runnerCreate($input: RunnerCreateInput!) { runnerCreate(input: $input) { runner { id - registerAdminUrl + ephemeralRegisterUrl } errors } diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue new file mode 100644 index 00000000000..35c75a917c7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue @@ -0,0 +1,98 @@ +<script> +import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import { DEFAULT_PLATFORM, GROUP_TYPE, PARAM_KEY_PLATFORM } from '../constants'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; + +export default { + name: 'GroupNewRunnerApp', + components: { + GlLink, + GlSprintf, + RunnerInstructionsModal, + RunnerPlatformsRadioGroup, + RunnerCreateForm, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + groupId: { + type: String, + required: true, + }, + legacyRegistrationToken: { + type: String, + required: true, + }, + }, + data() { + return { + platform: DEFAULT_PLATFORM, + }; + }, + methods: { + onSaved(runner) { + const params = { [PARAM_KEY_PLATFORM]: this.platform }; + const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl); + + saveAlertToLocalStorage({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + redirectTo(ephemeralRegisterUrl); + }, + onError(error) { + createAlert({ message: error.message }); + }, + }, + modalId: 'runners-legacy-registration-instructions-modal', + GROUP_TYPE, +}; +</script> + +<template> + <div> + <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1> + <p> + <gl-sprintf + :message=" + s__( + 'Runners|Create a group runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{ + content + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="legacyRegistrationToken" + /> + </template> + </gl-sprintf> + </p> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Platform') }} + </h2> + <runner-platforms-radio-group v-model="platform" /> + + <hr aria-hidden="true" /> + + <runner-create-form + :runner-type="$options.GROUP_TYPE" + :group-id="groupId" + @saved="onSaved" + @error="onError" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/group_new_runner/index.js b/app/assets/javascripts/ci/runner/group_new_runner/index.js new file mode 100644 index 00000000000..b314c3aa1e7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_new_runner/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import GroupNewRunnerApp from './group_new_runner_app.vue'; + +Vue.use(VueApollo); + +export const initGroupNewRunner = (selector = '#js-group-new-runner') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { legacyRegistrationToken, groupId } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(GroupNewRunnerApp, { + props: { + groupId, + legacyRegistrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue new file mode 100644 index 00000000000..533d31b70a3 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue @@ -0,0 +1,69 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { getParameterByName, updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; +import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM } from '../constants'; +import RegistrationInstructions from '../components/registration/registration_instructions.vue'; +import PlatformsDrawer from '../components/registration/platforms_drawer.vue'; + +export default { + name: 'GroupRegisterRunnerApp', + components: { + GlButton, + RegistrationInstructions, + PlatformsDrawer, + }, + props: { + runnerId: { + type: String, + required: true, + }, + runnersPath: { + type: String, + required: true, + }, + }, + data() { + return { + platform: getParameterByName(PARAM_KEY_PLATFORM) || DEFAULT_PLATFORM, + isDrawerOpen: false, + }; + }, + watch: { + platform(platform) { + updateHistory({ + url: mergeUrlParams({ [PARAM_KEY_PLATFORM]: platform }, window.location.href), + }); + }, + }, + methods: { + onSelectPlatform(platform) { + this.platform = platform; + }, + onToggleDrawer(val = !this.isDrawerOpen) { + this.isDrawerOpen = val; + }, + }, +}; +</script> +<template> + <div> + <registration-instructions + :runner-id="runnerId" + :platform="platform" + @toggleDrawer="onToggleDrawer" + > + <template #runner-list-name>{{ s__('Runners|Group area › Runners') }}</template> + </registration-instructions> + + <platforms-drawer + :platform="platform" + :open="isDrawerOpen" + @selectPlatform="onSelectPlatform" + @close="onToggleDrawer(false)" + /> + + <gl-button :href="runnersPath" variant="confirm">{{ + s__('Runners|Go to runners page') + }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/group_register_runner/index.js b/app/assets/javascripts/ci/runner/group_register_runner/index.js new file mode 100644 index 00000000000..a00db8853a2 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_register_runner/index.js @@ -0,0 +1,36 @@ +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 GroupRegisterRunnerApp from './group_register_runner_app.vue'; + +Vue.use(VueApollo); + +export const initGroupRegisterRunner = (selector = '#js-group-register-runner') => { + showAlertFromLocalStorage(); + + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId, runnersPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(GroupRegisterRunnerApp, { + props: { + runnerId, + runnersPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 294d06a66e7..f8386214698 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -1,5 +1,5 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { updateHistory } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; @@ -42,6 +42,7 @@ import { captureException } from '../sentry_utils'; export default { name: 'GroupRunnersApp', components: { + GlButton, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -58,6 +59,11 @@ export default { mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { + newRunnerPath: { + type: String, + required: false, + default: null, + }, registrationToken: { type: String, required: false, @@ -150,6 +156,10 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, + shouldShowCreateRunnerWorkflow() { + // create_runner_workflow_for_namespace feature flag + return this.glFeatures.createRunnerWorkflowForNamespace; + }, }, watch: { search: { @@ -219,8 +229,13 @@ export default { nav-class="gl-border-none!" /> + <template v-if="shouldShowCreateRunnerWorkflow"> + <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> + {{ s__('Runners|New group runner') }} + </gl-button> + </template> <registration-dropdown - v-if="registrationToken" + v-else-if="registrationToken" class="gl-ml-auto" :registration-token="registrationToken" :type="$options.GROUP_TYPE" diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js index 46514d5afe8..4fcf484317d 100644 --- a/app/assets/javascripts/ci/runner/group_runners/index.js +++ b/app/assets/javascripts/ci/runner/group_runners/index.js @@ -18,6 +18,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { const { registrationToken, runnerInstallHelpPage, + newRunnerPath, groupId, groupFullPath, onlineContactTimeoutSecs, @@ -49,6 +50,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { props: { registrationToken, groupFullPath, + newRunnerPath, }, }); }, diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue index ca65665b9ed..24a776e1a29 100644 --- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue +++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue @@ -164,7 +164,7 @@ export default { :href="$options.emptyHelpLink" :title="$options.i18n.emptyTooltip" :aria-label="$options.i18n.emptyTooltip" - ><gl-icon name="question" :size="14" + ><gl-icon name="question-o" :size="14" /></gl-link> </template> </gl-empty-state> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index e0e3b961c51..d7e98638a11 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -8,9 +8,13 @@ import { GlTooltipDirective, GlPopover, } from '@gitlab/ui'; +import semverLt from 'semver/functions/lt'; +import semverInc from 'semver/functions/inc'; +import semverPrerelease from 'semver/functions/prerelease'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants'; import { getAgentConfigPath } from '../clusters_util'; import DeleteAgentButton from './delete_agent_button.vue'; @@ -81,6 +85,11 @@ export default { tdClass, }, { + key: 'agentID', + label: this.$options.i18n.agentIdLabel, + tdClass, + }, + { key: 'configuration', label: this.$options.i18n.configurationLabel, tdClass, @@ -116,6 +125,9 @@ export default { getPopoverTestId(item) { return `popover-${item.name}`; }, + getAgentId(item) { + return getIdFromGraphQLId(item.id); + }, getAgentConfigPath, getAgentVersions(agent) { const agentConnections = agent.connections?.nodes || []; @@ -134,18 +146,26 @@ export default { isVersionMismatch(agent) { return agent.versions.length > 1; }, + // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version + // using the following heuristics: + // - KAS Version is used as *server* version if available, otherwise the GitLab version is used. + // - returns `outdated` if the agent has a different major version than the server + // - returns `outdated` if the agents minor version is at least two proper versions older than the server + // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version + // + // Note that it does NOT support if the agent is newer than the server version. isVersionOutdated(agent) { if (!agent.versions.length) return false; - const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.'); - const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.'); - - const majorVersionMismatch = agentMajorVersion !== serverMajorVersion; + const agentVersion = this.getAgentVersionString(agent); + let allowableAgentVersion = semverInc(agentVersion, 'minor'); - // We should warn user if their current GitLab and agent versions are more than 1 minor version apart: - const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1; + const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion)); + if (isServerPrerelease) { + allowableAgentVersion = semverInc(allowableAgentVersion, 'minor'); + } - return majorVersionMismatch || minorVersionMismatch; + return semverLt(allowableAgentVersion, this.serverVersion); }, getVersionPopoverTitle(agent) { @@ -265,6 +285,12 @@ export default { </gl-popover> </template> + <template #cell(agentID)="{ item }"> + <span data-testid="cluster-agent-id"> + {{ getAgentId(item) }} + </span> + </template> + <template #cell(configuration)="{ item }"> <span data-testid="cluster-agent-configuration-link"> <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> @@ -279,7 +305,7 @@ export default { :title="$options.i18n.defaultConfigTooltip" :aria-label="$options.i18n.defaultConfigTooltip" class="gl-vertical-align-middle" - ><gl-icon name="question" :size="14" /></gl-link + ><gl-icon name="question-o" :size="14" /></gl-link ></span> </span> </template> diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue index e4b481f0908..9e0d2cc73ec 100644 --- a/app/assets/javascripts/saved_replies/components/app.vue +++ b/app/assets/javascripts/comment_templates/components/app.vue @@ -6,12 +6,12 @@ export default {}; <div class="row gl-mt-5"> <div class="col-lg-4"> <h4 class="gl-mt-0"> - {{ __('Saved Replies') }} + {{ __('Comment templates') }} </h4> <p> {{ __( - 'Saved replies can be used when creating comments inside issues, merge requests, and epics.', + 'Comment templates can be used when creating comments inside issues, merge requests, and epics.', ) }} </p> diff --git a/app/assets/javascripts/saved_replies/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue index efec9b96764..47efccc3d0c 100644 --- a/app/assets/javascripts/saved_replies/components/form.vue +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -38,7 +38,7 @@ export default { errors: [], saving: false, showValidation: false, - updateSavedReply: { + updateCommentTemplate: { name: this.name, content: this.content, }, @@ -46,12 +46,12 @@ export default { }, computed: { isNameValid() { - if (this.showValidation) return Boolean(this.updateSavedReply.name); + if (this.showValidation) return Boolean(this.updateCommentTemplate.name); return true; }, isContentValid() { - if (this.showValidation) return Boolean(this.updateSavedReply.content); + if (this.showValidation) return Boolean(this.updateCommentTemplate.content); return true; }, @@ -73,15 +73,15 @@ export default { mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation, variables: { id: this.id, - name: this.updateSavedReply.name, - content: this.updateSavedReply.content, + name: this.updateCommentTemplate.name, + content: this.updateCommentTemplate.content, }, update: (store, { data: { savedReplyMutation } }) => { if (savedReplyMutation.errors.length) { this.errors = savedReplyMutation.errors.map((e) => e); } else { this.$emit('saved'); - this.updateSavedReply = { name: '', content: '' }; + this.updateCommentTemplate = { name: '', content: '' }; this.showValidation = false; } }, @@ -112,8 +112,8 @@ export default { <template> <gl-form - class="new-note common-note-form" - data-testid="saved-reply-form" + class="new-note common-note-form gl-mb-6" + data-testid="comment-template-form" @submit.prevent="onSubmit" > <gl-alert @@ -128,26 +128,26 @@ export default { <gl-form-group :label="__('Name')" :state="isNameValid" - :invalid-feedback="__('Please enter a name for the saved reply.')" - data-testid="saved-reply-name-form-group" + :invalid-feedback="__('Please enter a name for the comment template.')" + data-testid="comment-template-name-form-group" > <gl-form-input - v-model="updateSavedReply.name" - :placeholder="__('Enter a name for your saved reply')" - data-testid="saved-reply-name-input" + v-model="updateCommentTemplate.name" + :placeholder="__('Enter a name for your comment template')" + data-testid="comment-template-name-input" /> </gl-form-group> <gl-form-group :label="__('Content')" :state="isContentValid" - :invalid-feedback="__('Please enter the saved reply content.')" - data-testid="saved-reply-content-form-group" + :invalid-feedback="__('Please enter the comment template content.')" + data-testid="comment-template-content-form-group" > <markdown-field :enable-preview="false" :is-submitting="saving" :add-spacing-classes="false" - :textarea-value="updateSavedReply.content" + :textarea-value="updateCommentTemplate.content" :markdown-docs-path="$options.markdownDocsPath" :restricted-tool-bar-items="$options.restrictedToolbarItems" :force-autosize="false" @@ -155,13 +155,13 @@ export default { > <template #textarea> <textarea - v-model="updateSavedReply.content" + v-model="updateCommentTemplate.content" dir="auto" class="note-textarea js-gfm-input js-autosize markdown-area" data-supports-quick-actions="false" :aria-label="__('Content')" - :placeholder="__('Write saved reply content here…')" - data-testid="saved-reply-content-input" + :placeholder="__('Write comment template content here…')" + data-testid="comment-template-content-input" @keydown.meta.enter="onSubmit" @keydown.ctrl.enter="onSubmit" ></textarea> @@ -173,7 +173,7 @@ export default { class="gl-mr-3 js-no-auto-disable" type="submit" :loading="saving" - data-testid="saved-reply-form-submit-btn" + data-testid="comment-template-form-submit-btn" > {{ __('Save') }} </gl-button> diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue index dbe326d429a..52bebfd050c 100644 --- a/app/assets/javascripts/saved_replies/components/list.vue +++ b/app/assets/javascripts/comment_templates/components/list.vue @@ -44,16 +44,16 @@ export default { </script> <template> - <div> + <div class="gl-border-t gl-pt-4"> <gl-loading-icon v-if="loading" size="lg" /> <template v-else> <h5 class="gl-font-lg" data-testid="title"> - <gl-sprintf :message="__('My saved replies (%{count})')"> + <gl-sprintf :message="__('My comment templates (%{count})')"> <template #count>{{ count }}</template> </gl-sprintf> </h5> <ul class="gl-list-style-none gl-p-0 gl-m-0"> - <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" /> + <list-item v-for="template in savedReplies" :key="template.id" :template="template" /> </ul> <gl-keyset-pagination v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue new file mode 100644 index 00000000000..d763700db42 --- /dev/null +++ b/app/assets/javascripts/comment_templates/components/list_item.vue @@ -0,0 +1,116 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlDisclosureDropdown, GlTooltip, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql'; + +export default { + components: { + GlDisclosureDropdown, + GlTooltip, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + template: { + type: Object, + required: true, + }, + }, + data() { + return { + isDeleting: false, + modalId: uniqueId('delete-comment-template-'), + toggleId: uniqueId('actions-toggle-'), + }; + }, + computed: { + id() { + return getIdFromGraphQLId(this.template.id); + }, + dropdownItems() { + return [ + { + text: __('Edit'), + action: () => this.$router.push({ name: 'edit', params: { id: this.id } }), + extraAttrs: { + 'data-testid': 'comment-template-edit-btn', + }, + }, + { + text: __('Delete'), + action: () => this.$refs['delete-modal'].show(), + extraAttrs: { + 'data-testid': 'comment-template-delete-btn', + class: 'gl-text-red-500!', + }, + }, + ]; + }, + }, + methods: { + onDelete() { + this.isDeleting = true; + + this.$apollo.mutate({ + mutation: deleteSavedReplyMutation, + variables: { + id: this.template.id, + }, + update: (cache) => { + const cacheId = cache.identify(this.template); + cache.evict({ id: cacheId }); + }, + }); + }, + }, + actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } }, + actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } }, +}; +</script> + +<template> + <li class="gl-pt-4 gl-pb-5 gl-border-b"> + <div class="gl-display-flex gl-align-items-center"> + <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6> + <div class="gl-ml-auto"> + <gl-disclosure-dropdown + :items="dropdownItems" + :toggle-id="toggleId" + icon="ellipsis_v" + no-caret + text-sr-only + placement="right" + :toggle-text="__('Comment template actions')" + :loading="isDeleting" + category="tertiary" + /> + <gl-tooltip :target="toggleId"> + {{ __('Comment template actions') }} + </gl-tooltip> + </div> + </div> + <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div> + <gl-modal + ref="delete-modal" + :title="__('Delete comment template')" + :action-primary="$options.actionPrimary" + :action-secondary="$options.actionSecondary" + :modal-id="modalId" + size="sm" + @primary="onDelete" + > + <gl-sprintf + :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')" + > + <template #name + ><strong>{{ template.name }}</strong></template + > + </gl-sprintf> + </gl-modal> + </li> +</template> diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/comment_templates/index.js index 5022ff62b10..8cd763e7a9e 100644 --- a/app/assets/javascripts/saved_replies/index.js +++ b/app/assets/javascripts/comment_templates/index.js @@ -5,11 +5,11 @@ import createDefaultClient from '~/lib/graphql'; import routes from './routes'; import App from './components/app.vue'; -export const initSavedReplies = () => { +export const initCommentTemplates = () => { Vue.use(VueApollo); Vue.use(VueRouter); - const el = document.getElementById('js-saved-replies-root'); + const el = document.getElementById('js-comment-templates-root'); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/saved_replies/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue index 94215389844..343efdccefa 100644 --- a/app/assets/javascripts/saved_replies/pages/edit.vue +++ b/app/assets/javascripts/comment_templates/pages/edit.vue @@ -32,7 +32,7 @@ export default { }, }) { if (!savedReply) { - createAlert({ message: __('Unable to find saved reply') }); + createAlert({ message: __('Unable to find comment template') }); this.redirectToRoot(); } }, @@ -54,7 +54,7 @@ export default { <template> <div> <h5 class="gl-mt-0 gl-font-lg"> - {{ __('Edit saved reply') }} + {{ __('Edit comment template') }} </h5> <gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" /> <create-form diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue index 3e96fc0714e..72a94dafc58 100644 --- a/app/assets/javascripts/saved_replies/pages/index.vue +++ b/app/assets/javascripts/comment_templates/pages/index.vue @@ -53,7 +53,7 @@ export default { <template> <div> <h5 class="gl-mt-0 gl-font-lg"> - {{ __('Add new saved reply') }} + {{ __('Add new comment template') }} </h5> <create-form @saved="refetchSavedReplies" /> <list diff --git a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql index c4e632d0f16..c4e632d0f16 100644 --- a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql +++ b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql diff --git a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql index 76571ba628c..76571ba628c 100644 --- a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql +++ b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql diff --git a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql index 66f5f43af49..66f5f43af49 100644 --- a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql +++ b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql index d8e76b5e2a8..d8e76b5e2a8 100644 --- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql +++ b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql diff --git a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql index 14a47d7bc9c..14a47d7bc9c 100644 --- a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql +++ b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/comment_templates/routes.js index 7687c6f335a..7687c6f335a 100644 --- a/app/assets/javascripts/saved_replies/routes.js +++ b/app/assets/javascripts/comment_templates/routes.js diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index e5e23f2fb5e..17c9f55a8a0 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -1,10 +1,6 @@ import $ from 'jquery'; // bootstrap jQuery plugins -import 'bootstrap/js/dist/alert'; -import 'bootstrap/js/dist/button'; -import 'bootstrap/js/dist/collapse'; -import 'bootstrap/js/dist/modal'; import 'bootstrap/js/dist/dropdown'; import 'bootstrap/js/dist/tab'; diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js index defc2cbe276..f43a2d5d8ff 100644 --- a/app/assets/javascripts/constants.js +++ b/app/assets/javascripts/constants.js @@ -1,6 +1,5 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - export const getModifierKey = (removeSuffix = false) => { + // eslint-disable-next-line @gitlab/require-i18n-strings const winKey = `Ctrl${removeSuffix ? '' : '+'}`; return window.gl?.client?.isMac ? '⌘' : winKey; }; diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue index 06b80a65528..cef446c4cf8 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue @@ -35,9 +35,6 @@ export default { ); }, }, - toggleLinkCommandParams: { - href: '', - }, }; </script> <template> @@ -122,8 +119,7 @@ export default { data-testid="link" content-type="link" icon-name="link" - editor-command="toggleLink" - :editor-command-params="$options.toggleLinkCommandParams" + editor-command="editLink" category="tertiary" size="medium" :label="__('Insert link')" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue index a4713eb3275..a3065be3772 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue @@ -8,6 +8,7 @@ import { GlButtonGroup, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; +import { getMarkType, getMarkRange } from '@tiptap/core'; import Link from '../../extensions/link'; import EditorStateObserver from '../editor_state_observer.vue'; import BubbleMenu from './bubble_menu.vue'; @@ -31,12 +32,36 @@ export default { return { linkHref: undefined, linkCanonicalSrc: undefined, - linkTitle: undefined, + linkText: undefined, isEditing: false, }; }, methods: { + linkIsEmpty() { + return ( + !this.linkCanonicalSrc && + !this.linkHref && + (!this.linkText || this.linkText === this.linkTextInDoc()) + ); + }, + + linkTextInDoc() { + const { state } = this.tiptapEditor; + const type = getMarkType(Link.name, state.schema); + let { selection: range } = state; + if (range.from === range.to) { + range = + getMarkRange(state.selection.$from, type) || + getMarkRange(state.selection.$to, type) || + {}; + } + + if (!range.from || !range.to) return ''; + + return state.doc.textBetween(range.from, range.to, ' '); + }, + shouldShow() { return this.tiptapEditor.isActive(Link.name); }, @@ -52,31 +77,51 @@ export default { this.isEditing = false; this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc); - - if (!this.linkCanonicalSrc && !this.linkHref) { - this.removeLink(); - } }, cancelEditingLink() { this.endEditingLink(); - this.updateLinkToState(); + + if (this.linkIsEmpty()) { + this.removeLink(); + } else { + this.updateLinkToState(); + } }, async saveEditedLink() { - if (!this.linkCanonicalSrc) { + const chain = this.tiptapEditor.chain().focus(); + + const attrs = { + href: this.linkCanonicalSrc, + canonicalSrc: this.linkCanonicalSrc, + }; + + // if nothing was entered by the user and the link is empty, remove it + // since we don't want to insert an empty link + if (this.linkIsEmpty()) { this.removeLink(); - } else { - this.tiptapEditor - .chain() - .focus() + return; + } + + if (!this.linkText) { + this.linkText = this.linkCanonicalSrc; + } + + // if link text was updated, insert a new link in the doc with the new text + if (this.linkTextInDoc() !== this.linkText) { + chain .extendMarkRange(Link.name) - .updateAttributes(Link.name, { - href: this.linkCanonicalSrc, - canonicalSrc: this.linkCanonicalSrc, - title: this.linkTitle, + .setMeta('preventAutolink', true) + .insertContent({ + marks: [{ type: Link.name, attrs }], + type: 'text', + text: this.linkText, }) .run(); + } else { + // if link text was not updated, just update the attributes + chain.updateAttributes(Link.name, attrs).run(); } this.endEditingLink(); @@ -84,22 +129,27 @@ export default { updateLinkToState() { const editor = this.tiptapEditor; - - const { href, title, canonicalSrc } = editor.getAttributes(Link.name); + const { href, canonicalSrc } = editor.getAttributes(Link.name); + const text = this.linkTextInDoc(); if ( canonicalSrc === this.linkCanonicalSrc && href === this.linkHref && - title === this.linkTitle + text === this.linkText ) { return; } - this.linkTitle = title; + this.linkText = text; this.linkHref = href; this.linkCanonicalSrc = canonicalSrc || href; + }, - this.isEditing = !this.linkCanonicalSrc; + onTransaction({ transaction }) { + this.linkText = this.linkTextInDoc(); + if (transaction.getMeta('creatingLink')) { + this.isEditing = true; + } }, copyLinkHref() { @@ -107,31 +157,49 @@ export default { }, removeLink() { - this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run(); + const chain = this.tiptapEditor.chain().focus(); + if (this.linkTextInDoc()) { + chain.unsetLink().run(); + } else { + chain + .insertContent({ + type: 'text', + text: ' ', + }) + .extendMarkRange(Link.name) + .unsetLink() + .deleteSelection() + .run(); + } }, resetBubbleMenuState() { - this.linkTitle = undefined; + this.linkText = undefined; this.linkHref = undefined; this.linkCanonicalSrc = undefined; }, }, tippyOptions: { placement: 'bottom', + appendTo: () => document.body, }, }; </script> <template> - <bubble-menu - data-testid="link-bubble-menu" - class="gl-shadow gl-rounded-base gl-bg-white" - plugin-key="bubbleMenuLink" - :should-show="shouldShow" - :tippy-options="$options.tippyOptions" - @show="updateLinkToState" - @hidden="resetBubbleMenuState" + <editor-state-observer + :debounce="0" + @transaction="onTransaction" + @selectionUpdate="updateLinkToState" > - <editor-state-observer @selectionUpdate="updateLinkToState"> + <bubble-menu + data-testid="link-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + plugin-key="bubbleMenuLink" + :should-show="shouldShow" + :tippy-options="$options.tippyOptions" + @show="updateLinkToState" + @hidden="resetBubbleMenuState" + > <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> <gl-link v-gl-tooltip @@ -178,12 +246,12 @@ export default { /> </gl-button-group> <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink"> + <gl-form-group :label="__('Text')" label-for="link-text"> + <gl-form-input id="link-text" v-model="linkText" data-testid="link-text" /> + </gl-form-group> <gl-form-group :label="__('URL')" label-for="link-href"> <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" /> </gl-form-group> - <gl-form-group :label="__('Title')" label-for="link-title"> - <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" /> - </gl-form-group> <div class="gl-display-flex gl-justify-content-end"> <gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink"> {{ __('Cancel') }} @@ -193,6 +261,6 @@ export default { </gl-button> </div> </gl-form> - </editor-state-observer> - </bubble-menu> + </bubble-menu> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 9e08a257abf..f9d48708473 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -82,6 +82,16 @@ export default { required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -109,6 +119,8 @@ export default { autofocus, drawioEnabled, editable, + enableAutocomplete, + autocompleteDataSources, } = this; // This is a non-reactive attribute intentionally since this is a complex object. @@ -118,6 +130,8 @@ export default { extensions, serializerConfig, drawioEnabled, + enableAutocomplete, + autocompleteDataSources, tiptapOptions: { autofocus, editable, 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 ccb46e3b593..62f2113a8f4 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -16,14 +16,21 @@ const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEv export default { inject: ['tiptapEditor', 'eventHub'], + props: { + debounce: { + type: Number, + required: false, + default: 100, + }, + }, created() { this.disposables = []; Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => { - const eventHandler = debounce( - (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params), - 100, - ); + let eventHandler = (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params); + if (this.debounce) { + eventHandler = debounce(eventHandler, this.debounce); + } this.tiptapEditor?.on(tiptapEvent, eventHandler); diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index a5be63fa89f..cd9fdeeca46 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -1,22 +1,17 @@ <script> -import { GlTabs, GlTab } from '@gitlab/ui'; import trackUIControl from '../services/track_ui_control'; import ToolbarButton from './toolbar_button.vue'; -import ToolbarImageButton from './toolbar_image_button.vue'; -import ToolbarLinkButton from './toolbar_link_button.vue'; +import ToolbarAttachmentButton from './toolbar_attachment_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; import ToolbarMoreDropdown from './toolbar_more_dropdown.vue'; export default { components: { - GlTabs, - GlTab, ToolbarButton, ToolbarTextStyleDropdown, - ToolbarLinkButton, ToolbarTableButton, - ToolbarImageButton, + ToolbarAttachmentButton, ToolbarMoreDropdown, }, methods: { @@ -27,84 +22,87 @@ export default { }; </script> <template> - <gl-tabs content-class="gl-display-none"> - <gl-tab title-link-class="gl-py-4 gl-px-3" :title="__('Write')" /> - <template #tabs-end> - <div class="gl-ml-auto gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end"> - <toolbar-text-style-dropdown - data-testid="text-styles" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bold" - content-type="bold" - icon-name="bold" - editor-command="toggleBold" - :label="__('Bold text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="italic" - content-type="italic" - icon-name="italic" - editor-command="toggleItalic" - :label="__('Italic text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="blockquote" - content-type="blockquote" - icon-name="quote" - editor-command="toggleBlockquote" - :label="__('Insert a quote')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="code" - content-type="code" - icon-name="code" - editor-command="toggleCode" - :label="__('Code')" - @execute="trackToolbarControlExecution" - /> - <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" /> - <toolbar-button - data-testid="bullet-list" - content-type="bulletList" - icon-name="list-bulleted" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleBulletList" - :label="__('Add a bullet list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="ordered-list" - content-type="orderedList" - icon-name="list-numbered" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleOrderedList" - :label="__('Add a numbered list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="task-list" - content-type="taskList" - icon-name="list-task" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleTaskList" - :label="__('Add a checklist')" - @execute="trackToolbarControlExecution" - /> - <toolbar-image-button - ref="imageButton" - data-testid="image" - @execute="trackToolbarControlExecution" - /> - <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> - <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> - </div> - </template> - </gl-tabs> + <div + class="gl-w-full gl-border-b gl-display-flex gl-justify-content-end" + data-testid="formatting-toolbar" + > + <div class="gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end"> + <toolbar-text-style-dropdown + data-testid="text-styles" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + :label="__('Bold text')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + :label="__('Italic text')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" + editor-command="toggleBlockquote" + :label="__('Insert a quote')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + :label="__('Code')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="editLink" + :label="__('Insert link')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bullet-list" + content-type="bulletList" + icon-name="list-bulleted" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleBulletList" + :label="__('Add a bullet list')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="ordered-list" + content-type="orderedList" + icon-name="list-numbered" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleOrderedList" + :label="__('Add a numbered list')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="task-list" + content-type="taskList" + icon-name="list-task" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleTaskList" + :label="__('Add a checklist')" + @execute="trackToolbarControlExecution" + /> + <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <toolbar-attachment-button data-testid="attachment" @execute="trackToolbarControlExecution" /> + <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> + </div> + </div> </template> <style> .gl-spinner-container { diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 37e6ef61d50..4074e50a706 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -1,10 +1,11 @@ <script> -import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; export default { components: { GlDropdownItem, GlAvatarLabeled, + GlLoadingIcon, }, props: { @@ -32,6 +33,12 @@ export default { type: Function, required: true, }, + + loading: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -208,65 +215,75 @@ export default { </script> <template> - <ul - :class="{ show: items.length > 0 }" - class="gl-dropdown dropdown-menu gl-relative" - data-testid="content-editor-suggestions-dropdown" - > - <div class="gl-dropdown-inner gl-overflow-y-auto"> - <gl-dropdown-item - v-for="(item, index) in items" - ref="dropdownItems" - :key="index" - :class="{ 'gl-bg-gray-50': index === selectedIndex }" - @click="selectItem(index)" - > - <gl-avatar-labeled - v-if="isUser" - :label="item.username" - :sub-label="avatarSubLabel(item)" - :src="item.avatar_url" - :entity-name="item.username" - :shape="item.type === 'Group' ? 'rect' : 'circle'" - :size="32" - /> - <span v-if="isIssue || isMergeRequest"> - <small>{{ item.iid }}</small> - {{ item.title }} - </span> - <span v-if="isVulnerability || isSnippet"> - <small>{{ item.id }}</small> - {{ item.title }} - </span> - <span v-if="isEpic"> - <small>{{ item.reference }}</small> - {{ item.title }} - </span> - <span v-if="isMilestone"> - {{ item.title }} - </span> - <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> - <span - data-testid="label-color-box" - class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" - :style="{ backgroundColor: item.color }" - ></span> - {{ item.title }} - </span> - <span v-if="isCommand"> - /{{ item.name }} <small> {{ item.params[0] }} </small><br /> - <em> - <small> {{ item.description }} </small> - </em> - </span> - <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> - <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> - <div class="gl-flex-grow-1"> - {{ item.name }}<br /> - <small>{{ item.d }}</small> + <div> + <ul + v-if="!loading" + :class="{ show: items.length > 0 }" + class="gl-dropdown dropdown-menu gl-relative gl-m-0!" + data-testid="content-editor-suggestions-dropdown" + > + <div class="gl-dropdown-inner gl-overflow-y-auto"> + <gl-dropdown-item + v-for="(item, index) in items" + ref="dropdownItems" + :key="index" + :class="{ 'gl-bg-gray-50': index === selectedIndex }" + @click="selectItem(index)" + > + <gl-avatar-labeled + v-if="isUser" + :label="item.username" + :sub-label="avatarSubLabel(item)" + :src="item.avatar_url" + :entity-name="item.username" + :shape="item.type === 'Group' ? 'rect' : 'circle'" + :size="32" + /> + <span v-if="isIssue || isMergeRequest"> + <small>{{ item.iid }}</small> + {{ item.title }} + </span> + <span v-if="isVulnerability || isSnippet"> + <small>{{ item.id }}</small> + {{ item.title }} + </span> + <span v-if="isEpic"> + <small>{{ item.reference }}</small> + {{ item.title }} + </span> + <span v-if="isMilestone"> + {{ item.title }} + </span> + <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> + <span + data-testid="label-color-box" + class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" + :style="{ backgroundColor: item.color }" + ></span> + {{ item.title }} + </span> + <span v-if="isCommand"> + /{{ item.name }} <small> {{ item.params[0] }} </small><br /> + <em> + <small> {{ item.description }} </small> + </em> + </span> + <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> + <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> + <div class="gl-flex-grow-1"> + {{ item.name }}<br /> + <small>{{ item.d }}</small> + </div> </div> + </gl-dropdown-item> + </div> + </ul> + <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!"> + <div class="gl-dropdown-inner gl-overflow-y-auto"> + <div class="gl-px-5"> + <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }} </div> - </gl-dropdown-item> + </div> </div> - </ul> + </div> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue new file mode 100644 index 00000000000..efb9a5b07b5 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -0,0 +1,61 @@ +<script> +import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import Link from '../extensions/link'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor'], + data() { + return { + linkHref: '', + }; + }, + methods: { + emitExecute(source = 'url') { + this.$emit('execute', { contentType: Link.name, value: source }); + }, + openFileUpload() { + this.$refs.fileSelector.click(); + }, + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .uploadAttachment({ + file: e.target.files[0], + }) + .run(); + + // Reset the file input so that the same file can be uploaded again + this.$refs.fileSelector.value = ''; + this.emitExecute('upload'); + }, + }, +}; +</script> +<template> + <span class="gl-display-inline-flex"> + <gl-button + v-gl-tooltip + :text="__('Attach a file or image')" + :title="__('Attach a file or image')" + category="tertiary" + icon="paperclip" + lazy + @click="openFileUpload" + /> + <input + ref="fileSelector" + type="file" + name="content_editor_image" + class="gl-display-none" + data-qa-selector="file_upload_field" + @change="onFileSelect" + /> + </span> +</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue deleted file mode 100644 index 8ed4dfce6de..00000000000 --- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue +++ /dev/null @@ -1,109 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownForm, - GlButton, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; -import { acceptedMimes } from '../services/upload_helpers'; -import { extractFilename } from '../services/utils'; - -export default { - components: { - GlDropdown, - GlDropdownForm, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlButton, - }, - directives: { - GlTooltip, - }, - inject: ['tiptapEditor'], - data() { - return { - imgSrc: '', - }; - }, - methods: { - resetFields() { - this.imgSrc = ''; - this.$refs.fileSelector.value = ''; - }, - insertImage() { - this.tiptapEditor - .chain() - .focus() - .setImage({ - src: this.imgSrc, - canonicalSrc: this.imgSrc, - alt: extractFilename(this.imgSrc), - }) - .run(); - - this.resetFields(); - this.emitExecute(); - }, - emitExecute(source = 'url') { - this.$emit('execute', { contentType: 'image', value: source }); - }, - openFileUpload() { - this.$refs.fileSelector.click(); - }, - onFileSelect(e) { - this.tiptapEditor - .chain() - .focus() - .uploadAttachment({ - file: e.target.files[0], - }) - .run(); - - this.resetFields(); - this.emitExecute('upload'); - }, - }, - acceptedMimes: acceptedMimes.image, -}; -</script> -<template> - <span class="gl-display-inline-flex"> - <gl-dropdown - v-gl-tooltip - :text="__('Insert image')" - :title="__('Insert image')" - size="small" - category="tertiary" - icon="media" - lazy - text-sr-only - data-testid="insert-image-toolbar-button" - @hidden="resetFields()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')"> - <template #append> - <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item @click="openFileUpload"> - {{ __('Upload image') }} - </gl-dropdown-item> - </gl-dropdown> - <input - ref="fileSelector" - type="file" - name="content_editor_image" - :accept="$options.acceptedMimes" - class="gl-display-none" - data-qa-selector="file_upload_field" - @change="onFileSelect" - /> - </span> -</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue deleted file mode 100644 index 4fb1e8ce16f..00000000000 --- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue +++ /dev/null @@ -1,129 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownForm, - GlButton, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; -import Link from '../extensions/link'; -import { hasSelection } from '../services/utils'; -import EditorStateObserver from './editor_state_observer.vue'; - -export default { - components: { - GlDropdown, - GlDropdownForm, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlButton, - EditorStateObserver, - }, - directives: { - GlTooltip, - }, - inject: ['tiptapEditor'], - data() { - return { - linkHref: '', - isActive: false, - }; - }, - methods: { - resetFields() { - this.imgSrc = ''; - this.$refs.fileSelector.value = ''; - }, - openFileUpload() { - this.$refs.fileSelector.click(); - }, - updateLinkState({ editor }) { - const { canonicalSrc, href } = editor.getAttributes(Link.name); - - this.isActive = editor.isActive(Link.name); - this.linkHref = canonicalSrc || href; - }, - updateLink() { - this.tiptapEditor - .chain() - .focus() - .unsetLink() - .setLink({ - href: this.linkHref, - canonicalSrc: this.linkHref, - }) - .run(); - - this.$emit('execute', { contentType: Link.name }); - }, - selectLink() { - const { tiptapEditor } = this; - - // a selection has already been made by the user, so do nothing - if (!hasSelection(tiptapEditor)) { - tiptapEditor.chain().focus().extendMarkRange(Link.name).run(); - } - }, - removeLink() { - this.tiptapEditor.chain().focus().unsetLink().run(); - - this.$emit('execute', { contentType: Link.name }); - }, - onFileSelect(e) { - this.tiptapEditor - .chain() - .focus() - .uploadAttachment({ - file: e.target.files[0], - }) - .run(); - - this.resetFields(); - this.$emit('execute', { contentType: Link.name }); - }, - }, -}; -</script> -<template> - <editor-state-observer @transaction="updateLinkState"> - <span class="gl-display-inline-flex"> - <gl-dropdown - v-gl-tooltip - :title="__('Insert link')" - :text="__('Insert link')" - :toggle-class="{ active: isActive }" - size="small" - category="tertiary" - icon="link" - text-sr-only - lazy - @show="selectLink()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> - <template #append> - <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item v-if="isActive" @click="removeLink"> - {{ __('Remove link') }} - </gl-dropdown-item> - <gl-dropdown-item v-else @click="openFileUpload"> - {{ __('Upload file') }} - </gl-dropdown-item> - </gl-dropdown> - <input - ref="fileSelector" - type="file" - name="content_editor_attachment" - class="gl-display-none" - @change="onFileSelect" - /> - </span> - </editor-state-observer> -</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 4b1929e1a20..bf2740f9864 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -83,7 +83,7 @@ export default { text-sr-only lazy > - <gl-dropdown-form class="gl-px-3!"> + <gl-dropdown-form class="gl-px-3! gl-pb-2!"> <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> <gl-button v-for="c of list(maxCols)" diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index 81f9b1f0af5..55cf38dfcbb 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -80,7 +80,7 @@ export default { <template> <editor-state-observer @transaction="updateDiagramPreview"> <node-view-wrapper - :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`" + :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`" as="pre" > <div diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue new file mode 100644 index 00000000000..4126c65d87f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue @@ -0,0 +1,45 @@ +<script> +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { GlLink } from '@gitlab/ui'; + +export default { + name: 'DetailsWrapper', + components: { + NodeViewWrapper, + GlLink, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + computed: { + text() { + return this.node.attrs.text; + }, + isCommand() { + return this.node.attrs.referenceType === 'command'; + }, + isMember() { + return this.node.attrs.referenceType === 'user'; + }, + isCurrentUser() { + return gon.current_username === this.text.substring(1); + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <span v-if="isCommand">{{ text }}</span> + <gl-link + v-else + href="#" + class="gfm" + :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }" + @click.prevent.stop + >{{ text }}</gl-link + > + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue index 4206c866032..4206c866032 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/label.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index 6a3740a5952..490025a9ac6 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -12,6 +12,11 @@ export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule'; export const TEXT_STYLE_DROPDOWN_ITEMS = [ { + contentType: 'paragraph', + editorCommand: 'setParagraph', + label: __('Normal text'), + }, + { contentType: 'heading', commandParams: { level: 1 }, editorCommand: 'setHeading', @@ -35,11 +40,6 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [ commandParams: { level: 4 }, label: __('Heading 4'), }, - { - contentType: 'paragraph', - editorCommand: 'setParagraph', - label: __('Normal text'), - }, ]; export const ALERT_EVENT = 'alert'; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index e985e561fda..314d5230b01 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -18,6 +18,8 @@ export const extractHrefFromMarkdownLink = (match) => { }; export default Link.extend({ + inclusive: false, + addOptions() { return { ...this.parent?.(), @@ -64,4 +66,18 @@ export default Link.extend({ }, }; }, + addCommands() { + return { + ...this.parent?.(), + editLink: (attrs) => ({ chain }) => { + chain().setMeta('creatingLink', true).setLink(attrs).run(); + }, + }; + }, + + addKeyboardShortcuts() { + return { + 'Mod-k': () => this.editor.commands.editLink(), + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index 0a9a0d8d4c1..82fa5ce6c1d 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -37,8 +37,18 @@ export default Extension.create({ const { state, view } = editor; const { tr, selection } = state; + const { firstChild } = document.content; + const content = + document.content.childCount === 1 && firstChild.type.name === 'paragraph' + ? firstChild.content + : document.content; + + if (selection.to - selection.from > 0) { + tr.replaceWith(selection.from, selection.to, content); + } else { + tr.insert(selection.from, content); + } - tr.replaceWith(selection.from - 1, selection.to, document.content); view.dispatch(tr); }) .catch(() => { @@ -53,13 +63,29 @@ export default Extension.create({ }; }, addProseMirrorPlugins() { + let pasteRaw = false; + return [ new Plugin({ key: new PluginKey('pasteMarkdown'), props: { - handlePaste: (_, event) => { + handleKeyDown: (_, event) => { + pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey; + }, + + handlePaste: (view, event) => { const { clipboardData } = event; const content = clipboardData.getData(TEXT_FORMAT); + const { state } = view; + const { tr, selection } = state; + const { from, to } = selection; + + if (pasteRaw) { + tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to); + view.dispatch(tr); + return true; + } + const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT); const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT); const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index ed343d8acf8..01ffc217894 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,5 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import { Node } from '@tiptap/core'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -44,7 +42,7 @@ export default Node.create({ parseHTML() { return [ { - tag: `.${this.options.mediaType}-container`, + tag: `.${this.options.mediaType}-container`, // eslint-disable-line @gitlab/require-i18n-strings }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 707beaf1231..b56aa8596a0 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,6 @@ import { Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import ReferenceWrapper from '../components/wrappers/reference.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const getAnchor = (element) => { @@ -49,7 +51,7 @@ export default Node.create({ ]; }, - renderHTML({ node }) { - return ['a', { href: '#' }, node.attrs.text]; + addNodeView() { + return new VueNodeViewRenderer(ReferenceWrapper); }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js index 9dff0b7a689..0441f8ef8d2 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -1,6 +1,6 @@ import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; -import LabelWrapper from '../components/wrappers/label.vue'; +import LabelWrapper from '../components/wrappers/reference_label.vue'; import Reference from './reference'; export default Reference.extend({ diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index eb53a3a61b3..e72b5c7365c 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -57,14 +57,25 @@ function createSuggestionPlugin({ let component; let popup; + const onUpdate = (props) => { + component?.updateProps({ ...props, loading: false }); + + if (!props.clientRect) return; + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }; + return { - onStart: (props) => { + onBeforeStart: (props) => { component = new VueRenderer(SuggestionsDropdown, { propsData: { ...props, char, nodeType, nodeProps, + loading: true, }, editor: props.editor, }); @@ -84,17 +95,8 @@ function createSuggestionPlugin({ }); }, - onUpdate(props) { - component?.updateProps(props); - - if (!props.clientRect) { - return; - } - - popup?.[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, + onStart: onUpdate, + onUpdate, onKeyDown(props) { if (props.event.key === 'Escape') { @@ -118,12 +120,18 @@ function createSuggestionPlugin({ export default Node.create({ name: 'suggestions', + addOptions() { + return { + autocompleteDataSources: {}, + }; + }, + addProseMirrorPlugins() { return [ createSuggestionPlugin({ editor: this.editor, char: '@', - dataSource: gl.GfmAutoComplete?.dataSources.members, + dataSource: this.options.autocompleteDataSources.members, nodeType: 'reference', nodeProps: { referenceType: 'user', @@ -133,7 +141,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '#', - dataSource: gl.GfmAutoComplete?.dataSources.issues, + dataSource: this.options.autocompleteDataSources.issues, nodeType: 'reference', nodeProps: { referenceType: 'issue', @@ -143,7 +151,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '$', - dataSource: gl.GfmAutoComplete?.dataSources.snippets, + dataSource: this.options.autocompleteDataSources.snippets, nodeType: 'reference', nodeProps: { referenceType: 'snippet', @@ -153,7 +161,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '~', - dataSource: gl.GfmAutoComplete?.dataSources.labels, + dataSource: this.options.autocompleteDataSources.labels, nodeType: 'reference_label', nodeProps: { referenceType: 'label', @@ -163,7 +171,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '&', - dataSource: gl.GfmAutoComplete?.dataSources.epics, + dataSource: this.options.autocompleteDataSources.epics, nodeType: 'reference', nodeProps: { referenceType: 'epic', @@ -173,7 +181,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '[vulnerability:', - dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities, + dataSource: this.options.autocompleteDataSources.vulnerabilities, nodeType: 'reference', nodeProps: { referenceType: 'vulnerability', @@ -183,7 +191,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '!', - dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + dataSource: this.options.autocompleteDataSources.mergeRequests, nodeType: 'reference', nodeProps: { referenceType: 'merge_request', @@ -193,7 +201,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '%', - dataSource: gl.GfmAutoComplete?.dataSources.milestones, + dataSource: this.options.autocompleteDataSources.milestones, nodeType: 'reference', nodeProps: { referenceType: 'milestone', @@ -203,7 +211,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '/', - dataSource: gl.GfmAutoComplete?.dataSources.commands, + dataSource: this.options.autocompleteDataSources.commands, nodeType: 'reference', nodeProps: { referenceType: 'command', 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 9d536793287..f1d4f85dcb0 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -88,6 +88,8 @@ export const createContentEditor = ({ serializerConfig = { marks: {}, nodes: {} }, tiptapOptions, drawioEnabled = false, + enableAutocomplete, + autocompleteDataSources = {}, } = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); @@ -144,7 +146,6 @@ export const createContentEditor = ({ Sourcemap, Strike, Subscript, - Suggestions, Superscript, TableCell, TableHeader, @@ -160,6 +161,7 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; + if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources })); if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown })); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index e27a427372c..9ff50b45088 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; +import Loading from '../extensions/loading'; import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; @@ -194,6 +195,7 @@ const defaultSerializerConfig = { inline: true, }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [Loading.name]: () => {}, [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: renderReference, @@ -227,6 +229,7 @@ const defaultSerializerConfig = { [TableRow.name]: renderTableRow, [TaskItem.name]: preserveUnchanged((state, node) => { state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + if (!node.textContent) state.write(' '); state.renderContent(node); }), [TaskList.name]: preserveUnchanged((state, node) => { diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index fe1b32c5b0a..11a11ed43bd 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -28,6 +28,8 @@ export const getMarkdownSource = (element) => { const range = getRangeFromSourcePos(element.dataset.sourcepos); let elSource = ''; + if (!source.length) return undefined; + for (let i = range.start.row; i <= range.end.row; i += 1) { if (i === range.start.row) { elSource += source[i].substring(range.start.col); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 540815f57c9..664473fccfe 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -309,12 +309,15 @@ export function renderHardBreak(state, node, parent, index) { export function renderImage(state, node) { const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; + let realSrc = canonicalSrc || src || ''; + // eslint-disable-next-line @gitlab/require-i18n-strings + if (realSrc.startsWith('data:')) realSrc = ''; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; const sourceExpression = isReference ? `[${canonicalSrc}]` - : `(${state.esc(canonicalSrc || src)}${quotedTitle})`; + : `(${state.esc(realSrc)}${quotedTitle})`; const sizeAttributes = []; if (width) { @@ -604,7 +607,7 @@ export const link = { return '['; } - const attrs = { href: state.esc(href || canonicalSrc) }; + const attrs = { href: state.esc(href || canonicalSrc || '') }; if (title) { attrs.title = title; @@ -620,14 +623,14 @@ export const link = { const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; if (isReference) { - return `][${state.esc(canonicalSrc || href)}]`; + return `][${state.esc(canonicalSrc || href || '')}]`; } if (linkType(sourceMarkdown) === LINK_HTML) { return closeTag('a'); } - return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`; + return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`; }, }; @@ -638,9 +641,8 @@ const generateStrikeTag = (wrapTagName = openTag) => { switch (type) { case '~~': return type; - /* eslint-disable @gitlab/require-i18n-strings */ - case '<del': - case '<strike': + case '<del': // eslint-disable-line @gitlab/require-i18n-strings + case '<strike': // eslint-disable-line @gitlab/require-i18n-strings case '<s': return wrapTagName(type.substring(1)); default: diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index c9097b9384f..94f27dbf048 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -131,7 +131,7 @@ export default { </dl> </div> </div> - <div class="table-section section-30 section-wrap"> + <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div> <div class="table-mobile-content deploy-project-list"> <template v-if="projects.length > 0"> @@ -168,7 +168,7 @@ export default { <span v-else class="text-secondary">{{ __('None') }}</span> </div> </div> - <div class="table-section section-15 text-right"> + <div class="table-section section-15"> <div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div> <div class="table-mobile-content text-secondary key-created-at"> <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)"> @@ -176,7 +176,23 @@ export default { </span> </div> </div> - <div class="table-section section-15 table-button-footer deploy-key-actions"> + <div class="table-section section-15"> + <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div> + <div class="table-mobile-content text-secondary key-expires-at"> + <span + v-if="deployKey.expires_at" + v-gl-tooltip + :title="tooltipTitle(deployKey.expires_at)" + data-testid="expires-at-tooltip" + > + <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span> + </span> + <span v-else> + <span data-testid="expires-never">{{ __('Never') }}</span> + </span> + </div> + </div> + <div class="table-section section-10 table-button-footer deploy-key-actions"> <div class="btn-group table-action-buttons"> <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary"> {{ __('Enable') }} diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 77ec1ef590f..e04cbbe72b9 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -34,10 +34,12 @@ export default { <div role="rowheader" class="table-section section-40"> {{ s__('DeployKeys|Deploy key') }} </div> - <div role="rowheader" class="table-section section-30"> + <div role="rowheader" class="table-section section-20"> {{ s__('DeployKeys|Project usage') }} </div> - <div role="rowheader" class="table-section section-15 text-right">{{ __('Created') }}</div> + <div role="rowheader" class="table-section section-15">{{ __('Created') }}</div> + <div role="rowheader" class="table-section section-15">{{ __('Expires') }}</div> + <!-- leave 10% space for actions ---> </div> <deploy-key v-for="deployKey in keys" diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js index 8ca4dc587a8..2cb9e9a56a3 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - import fuzzaldrinPlus from 'fuzzaldrin-plus'; import $ from 'jquery'; import { debounce } from 'lodash'; @@ -59,6 +57,7 @@ export class GitLabDropdownFilter { return BLUR_KEYCODES.indexOf(keyCode) !== -1; } + // eslint-disable-next-line consistent-return filter(searchText) { let group; let results; @@ -114,9 +113,10 @@ export class GitLabDropdownFilter { const matches = fuzzaldrinPlus.match($el.text().trim(), searchText); if (!$el.is('.dropdown-header')) { if (matches.length) { - return $el.show().removeClass('option-hidden'); + $el.show().removeClass('option-hidden'); + } else { + $el.hide().addClass('option-hidden'); } - return $el.hide().addClass('option-hidden'); } }); } else { diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 0008c3504ce..537c810bcff 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -1179,9 +1179,11 @@ export default class Notes { const form = textarea.parents('form'); const reopenbtn = form.find('.js-note-target-reopen'); const closebtn = form.find('.js-note-target-close'); + const savebtn = form.find('.js-comment-save-button'); const commentTypeComponent = form.get(0)?.commentTypeComponent; if (textarea.val().trim().length > 0) { + savebtn.enable(); reopentext = reopenbtn.attr('data-alternative-text'); closetext = closebtn.attr('data-alternative-text'); if (reopenbtn.text() !== reopentext) { @@ -1200,6 +1202,7 @@ export default class Notes { commentTypeComponent.disabled = false; } } else { + savebtn.disable(); reopentext = reopenbtn.data('originalText'); closetext = closebtn.data('originalText'); if (reopenbtn.text() !== reopentext) { @@ -1395,7 +1398,7 @@ export default class Notes { */ static isNewNote(noteEntity, note_ids) { if (note_ids.length === 0) { - Notes.loadNotesIds(note_ids); + note_ids = Notes.getNotesIds(); } const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1; if (isNewEntry) { @@ -1405,16 +1408,17 @@ export default class Notes { } /** - * Load notes ids + * Get notes ids */ - static loadNotesIds(note_ids) { - const $notesList = $('.main-notes-list li[id^=note_]'); - for (const $noteItem of $notesList) { - if (Notes.isNodeTypeElement($noteItem)) { - const noteId = parseInt($noteItem.id.split('_')[1], 10); - note_ids.push(noteId); - } - } + static getNotesIds() { + /** + * The selector covers following notes + * - notes and thread below the snippets and commit page + * - notes on the file of commit page + * - notes on an image file of commit page + */ + const notesList = [...document.querySelectorAll('.notes:not(.notes-form) li[id]')]; + return notesList.map((noteItem) => parseInt(noteItem.dataset.noteId, 10)); } /** diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 80b146c9209..12bb4b830f8 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -14,7 +14,7 @@ export default () => { issuePath, registerPath, signInPath, - savedRepliesNewPath, + newCommentTemplatePath, } = el.dataset; const router = createRouter(issuePath); @@ -39,7 +39,7 @@ export default () => { issueIid, registerPath, signInPath, - newSavedRepliesPath: savedRepliesNewPath, + newCommentTemplatePath, }, mounted() { performanceMarkAndMeasure({ diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 0251ffe28f9..2f2b2ed1a90 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -332,7 +332,7 @@ export default { <template> <div - class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <div class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index ab003fb2879..e270613e4eb 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -104,7 +104,7 @@ export default { return this.permissions.createDesign; }, showToolbar() { - return this.canCreateDesign && this.allVersions.length > 0; + return this.allVersions.length > 0; }, hasDesigns() { return this.designs.length > 0; @@ -375,6 +375,7 @@ export default { <design-version-dropdown /> </div> <div + v-if="canCreateDesign" v-show="hasDesigns" class="gl-display-flex gl-align-items-center" data-testid="design-selector-toolbar" @@ -489,7 +490,11 @@ export default { /> </li> <template #header> - <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> + <li + v-if="canCreateDesign" + :class="designDropzoneWrapperClass" + data-testid="design-dropzone-wrapper" + > <design-dropzone :enable-drag-behavior="isDraggingDesign" :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 9ef0f336d43..1ae7b6a2110 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -1,8 +1,7 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import produce from 'immer'; import { differenceBy } from 'lodash'; import { createAlert } from '~/alert'; +import { TYPENAME_DISCUSSION, TYPENAME_TODO, TYPENAME_USER } from '~/graphql_shared/constants'; import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, @@ -60,7 +59,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = }); const newDiscussion = { - __typename: 'Discussion', + __typename: TYPENAME_DISCUSSION, id: createImageDiffNote.note.discussion.id, replyId: createImageDiffNote.note.discussion.replyId, resolvable: true, @@ -86,7 +85,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = design.issue.participants.nodes = [ ...design.issue.participants.nodes, { - __typename: 'User', + __typename: TYPENAME_USER, ...createImageDiffNote.note.author, }, ]; @@ -199,7 +198,7 @@ export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables) const data = produce(sourceData, (draftData) => { const design = extractDesign(draftData); const existingTodos = design.currentUserTodos?.nodes || []; - const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }]; + const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: TYPENAME_TODO }]; if (!design.currentUserTodos) { design.currentUserTodos = { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 9ccba88f7e6..9b3db78724d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -45,7 +45,9 @@ import { import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; +import { updateChangesTabCount } from '../utils/merge_request'; import { queueRedisHllEvents } from '../utils/queue_events'; +import FindingsDrawer from './shared/findings_drawer.vue'; import CollapsedFilesWarning from './collapsed_files_warning.vue'; import CommitWidget from './commit_widget.vue'; import CompareVersions from './compare_versions.vue'; @@ -59,6 +61,7 @@ import PreRenderer from './pre_renderer.vue'; export default { name: 'DiffsApp', components: { + FindingsDrawer, DynamicScroller, DynamicScrollerItem, PreRenderer, @@ -199,6 +202,7 @@ export default { numTotalFiles: 'realSize', numVisibleFiles: 'size', }), + ...mapState('findingsDrawer', ['activeDrawer']), ...mapState('diffs', [ 'showTreeList', 'isLoading', @@ -233,6 +237,7 @@ export default { 'flatBlobsList', ]), ...mapGetters(['isNotesFetched', 'getNoteableData']), + ...mapGetters('findingsDrawer', ['activeDrawer']), diffs() { if (!this.viewDiffsFileByFile) { return this.diffFiles; @@ -248,6 +253,9 @@ export default { renderDiffFiles() { return this.flatBlobsList.length > 0; }, + diffsIncomplete() { + return this.flatBlobsList.length !== this.diffFiles.length; + }, renderFileTree() { return this.renderDiffFiles && this.showTreeList; }, @@ -308,6 +316,11 @@ export default { diffViewType() { this.adjustView(); }, + viewDiffsFileByFile(newViewFileByFile) { + if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) { + this.refetchDiffData({ refetchMeta: false }); + } + }, shouldShow() { // When the shouldShow property changed to true, the route is rendered for the first time // and if we have the isLoading as true this means we didn't fetch the data @@ -337,8 +350,6 @@ export default { mrReviews: this.rehydratedMrReviews, }); - this.interfaceWithDOM(); - if (this.endpointCodequality) { this.setCodequalityEndpoint(this.endpointCodequality); } @@ -426,41 +437,48 @@ export default { 'setCodequalityEndpoint', 'fetchDiffFilesMeta', 'fetchDiffFilesBatch', + 'fetchFileByFile', 'fetchCoverageFiles', 'fetchCodequality', + 'rereadNoteHash', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', 'cacheTreeListWidth', - 'scrollToFile', + 'goToFile', 'setShowTreeList', 'navigateToDiffFileIndex', 'setFileByFile', 'disableVirtualScroller', ]), + ...mapActions('findingsDrawer', ['setDrawer']), + closeDrawer() { + this.setDrawer({}); + }, subscribeToEvents() { notesEventHub.$once('fetchDiffData', this.fetchData); notesEventHub.$on('refetchDiffData', this.refetchDiffData); + if (this.glFeatures.singleFileFileByFile) { + diffsEventHub.$on('diffFilesModified', this.setDiscussions); + notesEventHub.$on('fetchedNotesData', this.rereadNoteHash); + } }, unsubscribeFromEvents() { + if (this.glFeatures.singleFileFileByFile) { + notesEventHub.$off('fetchedNotesData', this.rereadNoteHash); + diffsEventHub.$off('diffFilesModified', this.setDiscussions); + } notesEventHub.$off('refetchDiffData', this.refetchDiffData); notesEventHub.$off('fetchDiffData', this.fetchData); }, - interfaceWithDOM() { - this.diffsTab = document.querySelector('.js-diffs-tab'); - }, - updateChangesTabCount() { - const badge = this.diffsTab.querySelector('.gl-badge'); - - if (this.diffsTab && badge) { - badge.textContent = this.diffFilesLength; - } - }, navigateToDiffFileNumber(number) { - this.navigateToDiffFileIndex(number - 1); + this.navigateToDiffFileIndex({ + index: number - 1, + singleFile: this.glFeatures.singleFileFileByFile, + }); }, - refetchDiffData() { - this.fetchData(false); + refetchDiffData({ refetchMeta = true } = {}) { + this.fetchData({ toggleTree: false, fetchMeta: refetchMeta }); }, needsReload() { return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]); @@ -468,42 +486,52 @@ export default { needsFirstLoad() { return !this.diffFiles.length; }, - fetchData(toggleTree = true) { - this.fetchDiffFilesMeta() - .then((data) => { - let realSize = 0; - - if (data) { - realSize = data.real_size; - } - - this.diffFilesLength = parseInt(realSize, 10) || 0; - if (toggleTree) { - this.setTreeDisplay(); - } - - this.updateChangesTabCount(); - }) - .catch(() => { - createAlert({ - message: __('Something went wrong on our end. Please try again!'), + fetchData({ toggleTree = true, fetchMeta = true } = {}) { + if (fetchMeta) { + this.fetchDiffFilesMeta() + .then((data) => { + let realSize = 0; + + if (data) { + realSize = data.real_size; + + if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) { + this.fetchFileByFile(); + } + } + + this.diffFilesLength = parseInt(realSize, 10) || 0; + if (toggleTree) { + this.setTreeDisplay(); + } + + updateChangesTabCount({ + count: this.diffFilesLength, + }); + }) + .catch(() => { + createAlert({ + message: __('Something went wrong on our end. Please try again!'), + }); }); - }); + } - this.fetchDiffFilesBatch() - .then(() => { - if (toggleTree) this.setTreeDisplay(); - // Guarantee the discussions are assigned after the batch finishes. - // Just watching the length of the discussions or the diff files - // isn't enough, because with split diff loading, neither will - // change when loading the other half of the diff files. - this.setDiscussions(); - }) - .catch(() => { - createAlert({ - message: __('Something went wrong on our end. Please try again!'), + if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) { + this.fetchDiffFilesBatch() + .then(() => { + if (toggleTree) this.setTreeDisplay(); + // Guarantee the discussions are assigned after the batch finishes. + // Just watching the length of the discussions or the diff files + // isn't enough, because with split diff loading, neither will + // change when loading the other half of the diff files. + this.setDiscussions(); + }) + .catch(() => { + createAlert({ + message: __('Something went wrong on our end. Please try again!'), + }); }); - }); + } if (this.endpointCoverage) { this.fetchCoverageFiles(); @@ -579,7 +607,10 @@ export default { jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) { - this.scrollToFile({ path: this.flatBlobsList[targetIndex].path }); + this.goToFile({ + path: this.flatBlobsList[targetIndex].path, + singleFile: this.glFeatures.singleFileFileByFile, + }); } }, setTreeDisplay() { @@ -640,6 +671,11 @@ export default { <template> <div v-show="shouldShow"> + <findings-drawer + v-if="glFeatures.codeQualityInlineDrawer" + :drawer="activeDrawer" + @close="closeDrawer" + /> <div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> <compare-versions :diff-files-count-text="numTotalFiles" /> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 1857ff557e6..d050f2fb9ae 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -30,6 +30,7 @@ export default { CommitPipelineStatus, GlButtonGroup, GlButton, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -117,12 +118,11 @@ export default { </div> <div> <div class="d-flex float-left align-items-center align-self-start"> - <input + <gl-form-checkbox v-if="isSelectable" - class="gl-mr-3" - type="checkbox" :checked="checked" - @change="$emit('handleCheckboxChange', $event.target.checked)" + class="gl-mt-3" + @change="$emit('handleCheckboxChange', !checked)" /> <user-avatar-link :link-href="authorUrl" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue index 5392c631c14..f3f05e3d9d9 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -1,27 +1,19 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { GlButton } from '@gitlab/ui'; import { NEW_CODE_QUALITY_FINDINGS } from '../i18n'; +import DiffCodeQualityItem from './diff_code_quality_item.vue'; export default { i18n: { newFindings: NEW_CODE_QUALITY_FINDINGS, }, - components: { GlButton, GlIcon }, + components: { GlButton, DiffCodeQualityItem }, props: { 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> @@ -37,23 +29,11 @@ export default { {{ $options.i18n.newFindings }} </h4> <ul class="gl-list-style-none gl-mb-0 gl-p-0"> - <li + <diff-code-quality-item v-for="finding in codeQuality" :key="finding.description" - class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex" - > - <span class="gl-mr-3"> - <gl-icon - :size="12" - :name="severityIcon(finding.severity)" - :class="severityClass(finding.severity)" - class="codequality-severity-icon" - /> - </span> - <span> - <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }} - </span> - </li> + :finding="finding" + /> </ul> <gl-button data-testid="diff-codequality-close" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue new file mode 100644 index 00000000000..eede110f46c --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue @@ -0,0 +1,54 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + components: { GlLink, GlIcon }, + mixins: [glFeatureFlagsMixin()], + props: { + finding: { + type: Object, + required: true, + }, + }, + methods: { + severityClass(severity) { + return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; + }, + severityIcon(severity) { + return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + }, + toggleDrawer() { + this.setDrawer(this.finding); + }, + ...mapActions('findingsDrawer', ['setDrawer']), + }, +}; +</script> + +<template> + <li class="gl-py-1 gl-font-regular gl-display-flex"> + <span class="gl-mr-3"> + <gl-icon + :size="12" + :name="severityIcon(finding.severity)" + :class="severityClass(finding.severity)" + class="codequality-severity-icon" + /> + </span> + <span + v-if="glFeatures.codeQualityInlineDrawer" + data-testid="description-button-section" + class="gl-display-flex" + > + <gl-link category="primary" variant="link" @click="toggleDrawer"> + {{ finding.severity }} - {{ finding.description }}</gl-link + > + </span> + <span v-else data-testid="description-plain-text" class="gl-display-flex"> + {{ finding.severity }} - {{ finding.description }} + </span> + </li> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index c19174dda8a..a58178eaef7 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -209,7 +209,11 @@ export default { if (this.hasDiff) { this.postRender(); - } else if (this.viewDiffsFileByFile && !this.isCollapsed) { + } else if ( + this.viewDiffsFileByFile && + !this.isCollapsed && + !this.glFeatures.singleFileFileByFile + ) { this.requestDiff(); } diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 16f45c3ad6a..c3a4897ce78 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -50,6 +50,7 @@ export default { i18n: { ...DIFF_FILE_HEADER, compareButtonLabel: __('Compare submodule commit revisions'), + fileModeTooltip: __('File permissions'), }, props: { discussionPath: { @@ -201,6 +202,9 @@ export default { externalUrlLabel() { return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url }); }, + labelToggleFile() { + return this.expanded ? __('Hide file contents') : __('Show file contents'); + }, }, watch: { 'idState.moreActionsShown': { @@ -287,12 +291,14 @@ export default { @click.self="handleToggleFile" > <div class="file-header-content"> - <gl-icon + <gl-button v-if="collapsible" - ref="collapseIcon" - :name="collapseIcon" - :size="16" - class="diff-toggle-caret gl-mr-2" + ref="collapseButton" + class="gl-mr-2" + category="tertiary" + size="small" + :icon="collapseIcon" + :aria-label="labelToggleFile" @click.stop="handleToggleFile" /> <a @@ -342,7 +348,13 @@ export default { data-track-property="diff_copy_file" /> - <small v-if="isModeChanged" ref="fileMode" class="mr-1"> + <small + v-if="isModeChanged" + ref="fileMode" + v-gl-tooltip.hover + class="mr-1" + :title="$options.i18n.fileModeTooltip" + > {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -364,7 +376,7 @@ export default { v-if="isReviewable && showLocalFileReviews" v-gl-tooltip.hover data-testid="fileReviewCheckbox" - class="gl-mr-5 gl-display-flex gl-align-items-center" + class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center" :title="$options.i18n.fileReviewTooltip" :checked="reviewed" @change="toggleReview" 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 f63ab1bb067..43ba527dad8 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -8,7 +8,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils'; import NoteForm from '~/notes/components/note_form.vue'; -import autosave from '~/notes/mixins/autosave'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, @@ -21,7 +21,7 @@ export default { NoteForm, MultilineCommentForm, }, - mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()], + mixins: [diffLineNoteFormMixin, glFeatureFlagsMixin()], props: { diffFileHash: { type: String, @@ -146,6 +146,27 @@ export default { return lines; }, + autosaveKey() { + if (!this.isLoggedIn) return ''; + + const { + id, + noteable_type: noteableTypeUnderscored, + noteableType, + diff_head_sha: diffHeadSha, + source_project_id: sourceProjectId, + } = this.noteableData; + + return [ + s__('Autosave|Note'), + capitalizeFirstCharacter(noteableTypeUnderscored || noteableType), + id, + diffHeadSha, + DIFF_NOTE_TYPE, + sourceProjectId, + this.line.line_code, + ].join('/'); + }, }, created() { if (this.range) { @@ -155,17 +176,6 @@ export default { } }, mounted() { - if (this.isLoggedIn) { - const keys = [ - this.noteableData.diff_head_sha, - DIFF_NOTE_TYPE, - this.noteableData.source_project_id, - this.line.line_code, - ]; - - this.initAutoSave(this.noteableData, keys); - } - if (this.selectedCommentPosition) { this.commentLineStart = this.selectedCommentPosition.start; } @@ -196,9 +206,6 @@ export default { lineCode: this.line.line_code, fileHash: this.diffFileHash, }); - this.$nextTick(() => { - this.resetAutoSave(); - }); }), handleSaveNote(note) { return this.saveDiffDiscussion({ note, formData: this.formData }).then(() => @@ -232,6 +239,7 @@ export default { :diff-file="diffFile" :show-suggest-popover="showSuggestPopover" :save-button-title="__('Comment')" + :autosave-key="autosaveKey" class="diff-comment-form gl-mt-3" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index e8b4ff16aec..7de8eff7863 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -76,7 +76,7 @@ export default { class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center" :class="{ bold: isCompareVersionsHeader }" > - <span>-</span> + <span>−</span> <span data-testid="js-file-deletion-line">{{ removedLines }}</span> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index a2e052e0f93..348d6d1d78d 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,7 +1,6 @@ <script> 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'; @@ -21,11 +20,7 @@ export default { DiffCommentCell, DraftNote, }, - mixins: [ - draftCommentsMixin, - IdState({ idProp: (vm) => vm.diffFile.file_hash }), - glFeatureFlagsMixin(), - ], + mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })], props: { diffFile: { type: Object, @@ -265,10 +260,7 @@ export default { @stopdragging="onStopDragging" /> <diff-line - v-if=" - glFeatures.refactorCodeQualityInlineFindings && - codeQualityExpandedLines.includes(getCodeQualityLine(line)) - " + v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))" :key="line.line_code" :line="line" @hideCodeQualityFindings="hideCodeQualityFindings" diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue index f6a8c679f3b..26d37484541 100644 --- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -1,17 +1,18 @@ <script> -import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; export const i18n = { - title: __('Too many changes to show.'), + title: __('Some changes are not shown.'), plainDiff: __('Plain diff'), - emailPatch: __('Email patch'), + emailPatch: __('Patches'), }; export default { i18n, components: { GlAlert, + GlButton, GlSprintf, }, props: { @@ -38,18 +39,15 @@ export default { <template> <gl-alert variant="warning" + class="gl-mx-5 gl-mb-4 gl-mt-3" :title="$options.i18n.title" - :primary-button-text="$options.i18n.plainDiff" - :primary-button-link="plainDiffPath" - :secondary-button-text="$options.i18n.emailPatch" - :secondary-button-link="emailPatchPath" :dismissible="false" > <gl-sprintf :message=" sprintf( __( - 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.', + 'For a faster browsing experience, only %{strongStart}%{visible} of %{total}%{strongEnd} files are shown. Download one of the files below to see all changes.', ), { visible, total } /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */, ) @@ -59,5 +57,13 @@ export default { <strong>{{ content }}</strong> </template> </gl-sprintf> + <template #actions> + <gl-button :href="plainDiffPath" class="gl-mr-3 gl-alert-action"> + {{ $options.i18n.plainDiff }} + </gl-button> + <gl-button :href="emailPatchPath" class="gl-alert-action"> + {{ $options.i18n.emailPatch }} + </gl-button> + </template> </gl-alert> </template> diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue new file mode 100644 index 00000000000..da880c6f3ca --- /dev/null +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -0,0 +1,110 @@ +<script> +import { GlDrawer, GlIcon, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { s__ } from '~/locale'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; + +export const i18n = { + severity: s__('FindingsDrawer|Severity:'), + engine: s__('FindingsDrawer|Engine:'), + category: s__('FindingsDrawer|Category:'), + otherLocations: s__('FindingsDrawer|Other locations:'), +}; + +export default { + i18n, + components: { GlDrawer, GlIcon, GlLink }, + directives: { + SafeHtml, + }, + props: { + drawer: { + type: Object, + required: true, + }, + }, + safeHtmlConfig: { + ALLOWED_TAGS: ['a', 'h1', 'h2', 'p'], + ALLOWED_ATTR: ['href', 'rel'], + }, + computed: { + drawerOffsetTop() { + return getContentWrapperHeight('.content-wrapper'); + }, + }, + DRAWER_Z_INDEX, + methods: { + severityClass(severity) { + return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; + }, + severityIcon(severity) { + return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + }, + }, +}; +</script> +<template> + <gl-drawer + :header-height="drawerOffsetTop" + :z-index="$options.DRAWER_Z_INDEX" + class="findings-drawer" + :open="Object.keys(drawer).length !== 0" + @close="$emit('close')" + > + <template #title> + <h2 data-testid="findings-drawer-heading" class="gl-font-size-h2 gl-mt-0 gl-mb-0"> + {{ drawer.description }} + </h2> + </template> + + <template #default> + <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!"> + <li data-testid="findings-drawer-severity" class="gl-mb-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.severity }}</span> + <gl-icon + data-testid="findings-drawer-severity-icon" + :size="12" + :name="severityIcon(drawer.severity)" + :class="severityClass(drawer.severity)" + class="codequality-severity-icon" + /> + + {{ drawer.severity }} + </li> + <li data-testid="findings-drawer-engine" class="gl-mb-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.engine }}</span> + {{ drawer.engineName }} + </li> + <li data-testid="findings-drawer-category" class="gl-mb-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.category }}</span> + {{ drawer.categories ? drawer.categories[0] : '' }} + </li> + <li data-testid="findings-drawer-other-locations" class="gl-mb-4"> + <span class="gl-font-weight-bold gl-mb-3 gl-display-block">{{ + $options.i18n.otherLocations + }}</span> + <ul class="gl-pl-6"> + <li + v-for="otherLocation in drawer.otherLocations" + :key="otherLocation.path" + class="gl-mb-1" + > + <gl-link + data-testid="findings-drawer-other-locations-link" + :href="otherLocation.href" + >{{ otherLocation.path }}</gl-link + > + </li> + </ul> + </li> + </ul> + <span + v-safe-html:[$options.safeHtmlConfig]="drawer.content ? drawer.content.body : ''" + data-testid="findings-drawer-body" + class="drawer-body gl-display-block gl-px-3 gl-py-0!" + ></span> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index ab08c72b08f..4f1875e9175 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -5,6 +5,7 @@ import micromatch from 'micromatch'; import { debounce } from 'lodash'; import { getModifierKey } from '~/constants'; import { s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { RecycleScroller } from 'vendor/vue-virtual-scroller'; import DiffFileRow from './diff_file_row.vue'; @@ -19,6 +20,7 @@ export default { DiffFileRow, RecycleScroller, }, + mixins: [glFeatureFlagsMixin()], props: { hideFileStats: { type: Boolean, @@ -105,7 +107,7 @@ export default { this.resizeObserver.disconnect(); }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), + ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), clearSearch() { this.search = ''; }, @@ -128,7 +130,7 @@ export default { > <div class="gl-pb-3 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> - <gl-icon name="search" class="position-absolute tree-list-icon" /> + <gl-icon name="search" class="gl-absolute gl-top-5 tree-list-icon" /> <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label> <input id="diff-tree-search" @@ -154,7 +156,7 @@ export default { <div ref="scrollRoot" :class="{ 'tree-list-blobs': !renderTreeList || search }" - class="gl-flex-grow-1" + class="gl-flex-grow-1 mr-tree-list" > <recycle-scroller v-if="flatFilteredTreeList.length" @@ -172,10 +174,10 @@ export default { :hide-file-stats="hideFileStats" :current-diff-file-id="currentDiffFileId" :style="{ '--level': item.level }" - :class="{ 'tree-list-parent': item.tree.length }" + :class="{ 'tree-list-parent': item.level > 0 }" class="gl-relative" @toggleTreeOpen="toggleTreeOpen" - @clickFile="(path) => scrollToFile({ path })" + @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })" /> </template> <template #after> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 873c4819669..a459def6b4b 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -112,3 +112,6 @@ export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace'; export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting'; export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file'; export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files'; + +// UI +export const ZERO_CHANGES_ALT_DISPLAY = '-'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index 0f44eb06cb3..e233a0cef0a 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -1,6 +1,12 @@ import { __, s__ } from '~/locale'; export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!'); +export const LOAD_SINGLE_DIFF_FAILED = s__( + "MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.", +); +export const DISCUSSION_SINGLE_DIFF_FAILED = s__( + "MergeRequest|Can't fetch the single file diff for the discussion. Please reload this page.", +); export const DIFF_FILE_HEADER = { optionsDropdownTitle: __('Options'), diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 00a08434dac..ad7182024da 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -27,7 +27,7 @@ export default function initDiffsApp(store = notesStore) { store, apolloProvider, provide: { - newSavedRepliesPath: dataset.savedRepliesNewPath, + newCommentTemplatePath: dataset.newCommentTemplatePath, }, data() { return { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 9236e14beb1..a70c907314b 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -16,6 +16,7 @@ import { __, s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; import { generateTreeList } from '~/diffs/utils/tree_worker_utils'; import { sortTree } from '~/ide/stores/utils'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, @@ -49,6 +50,7 @@ import { TRACKING_SINGLE_FILE_MODE, TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; +import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n'; import eventHub from '../event_hub'; import { isCollapsed } from '../utils/diff_file'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; @@ -62,6 +64,8 @@ import { idleCallback, allDiscussionWrappersExpanded, prepareLineForRenamedFile, + parseUrlHashAsFileHash, + isUrlHashNoteLink, } from './utils'; export const setBaseConfig = ({ commit }, options) => { @@ -101,6 +105,47 @@ export const setBaseConfig = ({ commit }, options) => { }); }; +export const fetchFileByFile = async ({ state, getters, commit }) => { + const isNoteLink = isUrlHashNoteLink(window?.location?.hash); + const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId); + const treeEntry = id + ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id) + : getters.flatBlobsList[0]; + + if (treeEntry && !treeEntry.diffLoaded && !getters.getDiffFileByHash(id)) { + // Overloading "batch" loading indicators so the UI stays mostly the same + commit(types.SET_BATCH_LOADING_STATE, 'loading'); + commit(types.SET_RETRIEVING_BATCHES, true); + + const urlParams = { + old_path: treeEntry.filePaths.old, + new_path: treeEntry.filePaths.new, + w: state.showWhitespace ? '0' : '1', + view: 'inline', + }; + + axios + .get(mergeUrlParams({ ...urlParams }, state.endpointDiffForPath)) + .then(({ data: diffData }) => { + commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files }); + + if (!isNoteLink && !state.currentDiffFileId) { + commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0]?.file_hash || ''); + } + + commit(types.SET_BATCH_LOADING_STATE, 'loaded'); + + eventHub.$emit('diffFilesModified'); + }) + .catch(() => { + commit(types.SET_BATCH_LOADING_STATE, 'error'); + }) + .finally(() => { + commit(types.SET_RETRIEVING_BATCHES, false); + }); + } +}; + export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { let perPage = state.viewDiffsFileByFile ? 1 : 5; let increaseAmount = 1.4; @@ -512,13 +557,20 @@ export const toggleFileDiscussionWrappers = ({ commit }, diff) => { } }; -export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { +export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ commit: state.commit, note, ...formData, }); + if (containsSensitiveToken(note)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + return null; + } + } + return dispatch('saveNote', postData, { root: true }) .then((result) => dispatch('updateDiscussion', result.discussion, { root: true })) .then((discussion) => dispatch('assignDiscussionsToDiff', [discussion])) @@ -539,6 +591,31 @@ export const setCurrentFileHash = ({ commit }, hash) => { commit(types.SET_CURRENT_DIFF_FILE, hash); }; +export const goToFile = ({ state, commit, dispatch, getters }, { path, singleFile }) => { + if (!state.viewDiffsFileByFile || !singleFile) { + dispatch('scrollToFile', { path }); + } else { + if (!state.treeEntries[path]) return; + + const { fileHash } = state.treeEntries[path]; + + commit(types.SET_CURRENT_DIFF_FILE, fileHash); + document.location.hash = fileHash; + + if (!getters.isTreePathLoaded(path)) { + dispatch('fetchFileByFile') + .then(() => { + dispatch('scrollToFile', { path }); + }) + .catch(() => { + createAlert({ + message: LOAD_SINGLE_DIFF_FAILED, + }); + }); + } + } +}; + export const scrollToFile = ({ state, commit, getters }, { path }) => { if (!state.treeEntries[path]) return; @@ -779,13 +856,11 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => }); export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) { - /* eslint-disable @gitlab/require-i18n-strings */ if (!commitId) { return Promise.reject(new Error('`commitId` is a required argument')); } else if (!state.commit) { - return Promise.reject(new Error('`state` must already contain a valid `commit`')); + return Promise.reject(new Error('`state` must already contain a valid `commit`')); // eslint-disable-line @gitlab/require-i18n-strings } - /* eslint-enable @gitlab/require-i18n-strings */ // this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421 const commitRE = new RegExp(state.commit.id, 'g'); @@ -821,6 +896,24 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) { } } +export const rereadNoteHash = ({ state, dispatch }) => { + const urlHash = window?.location?.hash; + + if (isUrlHashNoteLink(urlHash)) { + dispatch('setCurrentDiffFileIdFromNote', urlHash.split('_').pop()) + .then(() => { + if (state.viewDiffsFileByFile) { + dispatch('fetchFileByFile'); + } + }) + .catch(() => { + createAlert({ + message: DISCUSSION_SINGLE_DIFF_FAILED, + }); + }); + } +}; + export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, noteId) => { const note = rootGetters.notesById[noteId]; @@ -833,11 +926,18 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n } }; -export const navigateToDiffFileIndex = ({ commit, getters }, index) => { +export const navigateToDiffFileIndex = ( + { state, getters, commit, dispatch }, + { index, singleFile }, +) => { const { fileHash } = getters.flatBlobsList[index]; document.location.hash = fileHash; commit(types.SET_CURRENT_DIFF_FILE, fileHash); + + if (state.viewDiffsFileByFile && singleFile) { + dispatch('fetchFileByFile'); + } }; export const setFileByFile = ({ state, commit }, { fileByFile }) => { diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 3739ef0cd55..4ca353333b7 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -559,19 +559,19 @@ export const allDiscussionWrappersExpanded = (diff) => { return discussionsExpanded; }; -export function isUrlHashNoteLink(urlHash) { +export function isUrlHashNoteLink(urlHash = '') { const id = urlHash.replace(/^#/, ''); return id.startsWith('note'); } -export function isUrlHashFileHeader(urlHash) { +export function isUrlHashFileHeader(urlHash = '') { const id = urlHash.replace(/^#/, ''); return id.startsWith('diff-content'); } -export function parseUrlHashAsFileHash(urlHash, currentDiffFileId = '') { +export function parseUrlHashAsFileHash(urlHash = '', currentDiffFileId = '') { const isNoteLink = isUrlHashNoteLink(urlHash); let id = urlHash.replace(/^#/, ''); diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js index 43e04a814c5..6847b8900d2 100644 --- a/app/assets/javascripts/diffs/utils/merge_request.js +++ b/app/assets/javascripts/diffs/utils/merge_request.js @@ -1,3 +1,5 @@ +import { ZERO_CHANGES_ALT_DISPLAY } from '../constants'; + const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i; function getVersionInfo({ endpoint } = {}) { @@ -13,6 +15,17 @@ function getVersionInfo({ endpoint } = {}) { }; } +export function updateChangesTabCount({ + count, + badge = document.querySelector('.js-diffs-tab .gl-badge'), +} = {}) { + if (badge) { + // The purpose of this function is to assign to this parameter + /* eslint-disable-next-line no-param-reassign */ + badge.textContent = count || ZERO_CHANGES_ALT_DISPLAY; + } +} + export function getDerivedMergeRequestInformation({ endpoint } = {}) { let mrPath; let userOrGroup; diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue index 67b909d37c3..0afee7bebe0 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -52,7 +52,7 @@ export default { <section v-if="isVisible" id="se-toolbar" - class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-align-items-center" + class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center" > <gl-button-group v-if="hasGroupItems($options.groups.file)"> <source-editor-toolbar-button diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js index f8ff533f53f..9ec1a97ba1a 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -37,8 +37,6 @@ const setupDomElement = ({ injectToEl = null } = {}) => { return previewEl; }; -let dimResize = false; - export class EditorMarkdownPreviewExtension { static get extensionName() { return 'EditorMarkdownPreview'; @@ -53,7 +51,6 @@ export class EditorMarkdownPreviewExtension { }, shown: false, modelChangeListener: undefined, - layoutChangeListener: undefined, path: setupOptions.previewMarkdownPath, actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true), eventEmitter: new Emitter(), @@ -65,13 +62,17 @@ export class EditorMarkdownPreviewExtension { this.setupToolbar(instance); } - this.preview.layoutChangeListener = instance.onDidLayoutChange(() => { - if (instance.markdownPreview?.shown && !dimResize) { - const { width } = instance.getLayoutInfo(); - const newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; - EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth); + const debouncedResizeHandler = debounce((entries) => { + for (const entry of entries) { + const { width: newInstanceWidth } = entry.contentRect; + if (instance.markdownPreview?.shown) { + const newWidth = newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth); + } } - }); + }, 50); + + this.resizeObserver = new ResizeObserver(debouncedResizeHandler); this.preview.eventEmitter.event(this.togglePreview.bind(this, instance)); } @@ -85,9 +86,7 @@ export class EditorMarkdownPreviewExtension { } cleanup(instance) { - if (this.preview.layoutChangeListener) { - this.preview.layoutChangeListener.dispose(); - } + this.resizeObserver.disconnect(); if (this.preview.modelChangeListener) { this.preview.modelChangeListener.dispose(); } @@ -102,11 +101,7 @@ export class EditorMarkdownPreviewExtension { static resizePreviewLayout(instance, width) { const { height } = instance.getLayoutInfo(); - dimResize = true; instance.layout({ width, height }); - window.requestAnimationFrame(() => { - dimResize = false; - }); } setupToolbar(instance) { @@ -130,9 +125,16 @@ export class EditorMarkdownPreviewExtension { togglePreviewLayout(instance) { const { width } = instance.getLayoutInfo(); - const newWidth = this.preview.shown - ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH - : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + let newWidth; + if (this.preview.shown) { + // This means the preview is to be closed at the next step + newWidth = width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + this.resizeObserver.disconnect(); + } else { + // The preview is hidden, but is in the process to be opened + newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + this.resizeObserver.observe(instance.getContainerDomNode()); + } EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth); } diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index a5080332b78..44944a4a205 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -1894,6 +1894,10 @@ } }, "additionalProperties": false + }, + "publish": { + "description": "A path to a directory that contains the files to be published with Pages", + "type": "string" } }, "oneOf": [ diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index b9392fabcbd..4484bc03737 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -232,8 +232,8 @@ export function emojiImageTag(name, src) { title: `:${name}:`, alt: `:${name}:`, src, - width: '20', - height: '20', + width: '16', + height: '16', align: 'absmiddle', }); diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js index 308077f98b1..6e88a998096 100644 --- a/app/assets/javascripts/entrypoints/super_sidebar.js +++ b/app/assets/javascripts/entrypoints/super_sidebar.js @@ -1,5 +1,6 @@ import '~/webpack'; import '~/commons'; -import { initSuperSidebar } from '~/super_sidebar/super_sidebar_bundle'; +import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle'; initSuperSidebar(); +initSuperSidebarToggle(); diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 53a93bbce30..9db3011ba5d 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -75,7 +75,12 @@ export default { if (this.hasMultipleCommits) { if (this.graphql) { const { lastDeployment } = this.environment; - return this.commitData(lastDeployment, 'commitPath'); + return ( + // data shape comming from REST and GraphQL is unfortunately different + // once we fully migrate to GraphQL it could be streamlined + this.commitData(lastDeployment, 'commitPath') || + this.commitData(lastDeployment, 'webUrl') + ); } const { last_deployment } = this.environment; @@ -135,7 +140,6 @@ export default { csrf, cancelProps: { text: __('Cancel'), - attributes: { variant: 'danger' }, }, docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }), }; @@ -157,7 +161,7 @@ export default { }}</gl-link> </template> <template #docs="{ content }"> - <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.docsPath" 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 31bc462f0b9..b2843b79ba6 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -158,7 +158,7 @@ export default { >{{ instanceTitle }} ({{ instanceCount }})</span > <span ref="legend-icon" data-testid="legend-tooltip-target"> - <gl-icon class="gl-text-blue-500 gl-ml-2" name="question" /> + <gl-icon class="gl-text-blue-500 gl-ml-2" name="question-o" /> </span> <gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body"> <div class="deploy-board-legend gl-display-flex gl-flex-direction-column"> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 74eef50ebaf..d49598d2f21 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { formatTime } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; @@ -7,12 +7,9 @@ import eventHub from '../event_hub'; import actionMutation from '../graphql/mutations/action.mutation.graphql'; export default { - directives: { - GlTooltip: GlTooltipDirective, - }, components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdownItem, + GlDisclosureDropdown, GlIcon, }, props: { @@ -36,6 +33,16 @@ export default { title() { return __('Deploy to...'); }, + actionItems() { + return this.actions.map((actionItem) => ({ + text: actionItem.name, + action: () => this.onClickAction(actionItem), + extraAttrs: { + disabled: this.isActionDisabled(actionItem), + }, + ...actionItem, + })); + }, }, methods: { async onClickAction(action) { @@ -48,7 +55,6 @@ export default { ); const confirmed = await confirmAction(confirmationMessage); - if (!confirmed) { return; } @@ -80,30 +86,31 @@ export default { }; </script> <template> - <gl-dropdown - v-gl-tooltip + <gl-disclosure-dropdown :text="title" :title="title" :loading="isLoading" :aria-label="title" + :items="actionItems" icon="play" text-sr-only right data-container="body" data-testid="environment-actions-button" > - <gl-dropdown-item - v-for="(action, i) in actions" - :key="i" - :disabled="isActionDisabled(action)" + <gl-disclosure-dropdown-item + v-for="item in actionItems" + :key="item.name" + :item="item" data-testid="manual-action-link" - @click="onClickAction(action)" > - <span class="gl-flex-grow-1">{{ action.name }}</span> - <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right"> - <gl-icon name="clock" /> - {{ remainingTime(action) }} - </span> - </gl-dropdown-item> - </gl-dropdown> + <template #list-item> + <span class="gl-flex-grow-1">{{ item.text }}</span> + <span v-if="item.scheduledAt" class="gl-text-gray-500 float-right"> + <gl-icon name="clock" /> + {{ remainingTime(item) }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index cfb18cc4f82..736eaa7062d 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -1,14 +1,20 @@ <script> -import { GlCollapse, GlButton } from '@gitlab/ui'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import KubernetesAgentInfo from './kubernetes_agent_info.vue'; +import KubernetesPods from './kubernetes_pods.vue'; export default { components: { GlCollapse, GlButton, + GlAlert, KubernetesAgentInfo, + KubernetesPods, }, + inject: ['kasTunnelUrl'], props: { agentName: { required: true, @@ -22,10 +28,16 @@ export default { required: true, type: String, }, + namespace: { + required: false, + type: String, + default: '', + }, }, data() { return { isVisible: false, + error: '', }; }, computed: { @@ -35,11 +47,26 @@ export default { label() { return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand; }, + gitlabAgentId() { + const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId; + return id.toString(); + }, + k8sAccessConfiguration() { + return { + basePath: this.kasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers }, + }, + }; + }, }, methods: { toggleCollapse() { this.isVisible = !this.isVisible; }, + onClusterError(message) { + this.error = message; + }, }, i18n: { collapse: __('Collapse'), @@ -66,7 +93,17 @@ export default { :agent-name="agentName" :agent-id="agentId" :agent-project-path="agentProjectPath" + class="gl-mb-5" /> + + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ error }} + </gl-alert> + + <kubernetes-pods + :configuration="k8sAccessConfiguration" + :namespace="namespace" class="gl-mb-5" + @cluster-error="onClusterError" /></template> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue new file mode 100644 index 00000000000..a153331ee58 --- /dev/null +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -0,0 +1,111 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { s__ } from '~/locale'; +import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlSingleStat, + }, + apollo: { + k8sPods: { + query: k8sPodsQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + update(data) { + return data?.k8sPods || []; + }, + error(error) { + this.error = error; + this.$emit('cluster-error', this.error); + }, + }, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: true, + type: String, + }, + }, + data() { + return { + error: '', + }; + }, + + computed: { + podStats() { + if (!this.k8sPods) return null; + + return [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Running'), + title: this.$options.i18n.runningPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Pending'), + title: this.$options.i18n.pendingPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Succeeded'), + title: this.$options.i18n.succeededPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Failed'), + title: this.$options.i18n.failedPods, + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sPods.loading; + }, + }, + methods: { + getPodsByPhase(phase) { + const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase); + return filteredPods.length; + }, + }, + i18n: { + podsTitle: s__('Environment|Pods'), + runningPods: s__('Environment|Running'), + pendingPods: s__('Environment|Pending'), + succeededPods: s__('Environment|Succeeded'), + failedPods: s__('Environment|Failed'), + }, +}; +</script> +<template> + <div> + <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p> + + <gl-loading-icon v-if="loading" /> + + <div + v-else-if="podStats && !error" + class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3" + > + <gl-single-stat + v-for="(stat, index) in podStats" + :key="index" + class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3" + :value="stat.value" + :title="stat.title" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 2ec6e12b8b3..ee197bbcd45 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -148,6 +148,9 @@ export default { return now < autoStopDate; }, + upcomingDeploymentIid() { + return this.environment.upcomingDeployment?.iid.toString() || ''; + }, autoStopPath() { return this.environment?.cancelAutoStopPath ?? ''; }, @@ -173,7 +176,8 @@ export default { return this.glFeatures?.kasUserAccessProject; }, hasRequiredAgentData() { - return this.agent.project && this.agent.id && this.agent.name; + const { project, id, name } = this.agent || {}; + return project && id && name; }, showKubernetesOverview() { return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData; @@ -223,7 +227,7 @@ export default { :icon="icon" :aria-label="label" size="small" - category="tertiary" + category="secondary" @click="toggleCollapse" /> <gl-link @@ -270,7 +274,6 @@ export default { <stop-component v-if="canStop" :environment="environment" - class="gl-z-index-2" data-track-action="click_button" data-track-label="environment_stop" graphql @@ -351,7 +354,11 @@ export default { class="gl-pl-4" > <template #approval> - <environment-approval :environment="environment" @change="$emit('change')" /> + <environment-approval + :deployment-iid="upcomingDeploymentIid" + :environment="environment" + @change="$emit('change')" + /> </template> </deployment> </div> @@ -368,6 +375,7 @@ export default { :agent-project-path="agent.project" :agent-name="agent.name" :agent-id="agent.id" + :namespace="agent.kubernetesNamespace" /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue index 77d9311743c..92a0b0e550e 100644 --- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue +++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue @@ -1,9 +1,22 @@ <script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { translations } from '~/environments/environment_details/constants'; import ActionsComponent from '~/environments/components/environment_actions.vue'; +import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql'; + +const EnvironmentApprovalComponent = import( + 'ee_component/environments/components/environment_approval.vue' +); export default { components: { + GlButton, ActionsComponent, + EnvironmentApproval: () => EnvironmentApprovalComponent, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, props: { actions: { @@ -18,14 +31,92 @@ export default { type: Array, required: true, }, + rollback: { + // rollback shape: + /* + { + id: string, + name: string, + lastDeployment: { + commit: Commit, + isLast: boolean, + }, + retryUrl: url, + }; + */ + type: Object, + required: false, + default: null, + }, + // approvalEnvironment shape: + /* { + isApprovalActionAvailable: boolean, + deploymentIid: string, + environment: { + name: string, + tier: string, + requiredApprovalCount: number, + }, + */ + approvalEnvironment: { + type: Object, + required: false, + default: () => ({ + isApprovalActionAvailable: false, + }), + }, }, computed: { + isRollbackAvailable() { + return Boolean(this.rollback?.lastDeployment); + }, + rollbackIcon() { + return this.rollback.lastDeployment.isLast ? 'repeat' : 'redo'; + }, isActionsShown() { return this.actions.length > 0; }, + deploymentIid() { + return this.approvalEnvironment.deploymentIid; + }, + environment() { + return this.approvalEnvironment.environment; + }, + rollbackButtonTitle() { + return this.rollback.lastDeployment?.isLast + ? translations.redeployButtonTitle + : translations.rollbackButtonTitle; + }, + }, + methods: { + onRollbackClick() { + this.$apollo.mutate({ + mutation: setEnvironmentToRollback, + variables: { + environment: this.rollback, + }, + }); + }, }, }; </script> <template> - <actions-component v-if="isActionsShown" :actions="actions" graphql /> + <div> + <actions-component v-if="isActionsShown" :actions="actions" graphql /> + <gl-button + v-if="isRollbackAvailable" + v-gl-modal.confirm-rollback-modal + v-gl-tooltip + data-testid="rollback-button" + :title="rollbackButtonTitle" + :icon="rollbackIcon" + @click="onRollbackClick" + /> + <environment-approval + v-if="approvalEnvironment.isApprovalActionAvailable" + :environment="environment" + :deployment-iid="deploymentIid" + :show-text="false" + /> + </div> </template> diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js index 3b33d6a676e..07579092e23 100644 --- a/app/assets/javascripts/environments/environment_details/constants.js +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -30,7 +30,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ { key: 'job', label: __('Job'), - columnClass: 'gl-w-20p', + columnClass: 'gl-w-15p', tdClass: 'gl-vertical-align-middle!', }, { @@ -48,7 +48,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ { key: 'actions', label: __('Actions'), - columnClass: 'gl-w-10p', + columnClass: 'gl-w-15p', tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', }, ]; @@ -61,6 +61,8 @@ export const translations = { ), nextPageButtonLabel: __('Next'), previousPageButtonLabel: __('Prev'), + redeployButtonTitle: s__('Environments|Re-deploy to environment'), + rollbackButtonTitle: s__('Environments|Rollback environment'), }; export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] }; diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue index 10f8c06e581..128b1aae4d8 100644 --- a/app/assets/javascripts/environments/environment_details/deployments_table.vue +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -54,7 +54,11 @@ export default { <time-ago-tooltip :time="item.deployed" /> </template> <template #cell(actions)="{ item }"> - <deployment-actions :actions="item.actions" /> + <deployment-actions + :actions="item.actions" + :rollback="item.rollback" + :approval-environment="item.deploymentApproval" + /> </template> </gl-table-lite> </template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index f4657c5100a..f91e68e793f 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -1,7 +1,9 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { logError } from '~/lib/logger'; +import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql'; +import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper'; import EmptyState from './empty_state.vue'; import DeploymentsTable from './deployments_table.vue'; @@ -10,6 +12,7 @@ import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants'; export default { components: { + ConfirmRollbackModal, Pagination, DeploymentsTable, EmptyState, @@ -49,10 +52,14 @@ export default { }; }, }, + environmentToRollback: { + query: environmentToRollbackQuery, + }, }, data() { return { project: {}, + environmentToRollback: {}, isInitialPageDataReceived: false, isPrefetchingPages: false, }; @@ -143,5 +150,6 @@ export default { <pagination :page-info="pageInfo" :disabled="isPaginationDisabled" /> </div> <empty-state v-if="!isDeploymentTableShown && !isLoading" /> + <confirm-rollback-modal :environment="environmentToRollback" graphql /> </div> </template> diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 26514b59995..0482741979b 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -5,6 +5,7 @@ import pageInfoQuery from './queries/page_info.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; +import k8sPodsQuery from './queries/k8s_pods.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -82,6 +83,14 @@ export const apolloProvider = (endpoint) => { }, }, }); + cache.writeQuery({ + query: k8sPodsQuery, + data: { + status: { + phase: '', + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql new file mode 100644 index 00000000000..e799623f9bb --- /dev/null +++ b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql @@ -0,0 +1,6 @@ +fragment DeploymentJob on CiJob { + name + id + webPath + playable +} diff --git a/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql new file mode 100644 index 00000000000..1ff68c56362 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql @@ -0,0 +1,3 @@ +fragment ProtectedEnvironment on Environment { + id +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql index 0182b3a7234..65d36242afe 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -1,3 +1,7 @@ +#import "ee_else_ce/environments/graphql/fragments/environment_protected_data.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/environments/graphql/fragments/deployment_job.fragment.graphql" + query getEnvironmentDetails( $projectFullPath: ID! $environmentName: String @@ -11,8 +15,9 @@ query getEnvironmentDetails( name fullPath environment(name: $environmentName) { - id + ...ProtectedEnvironment name + tier lastDeployment(status: SUCCESS) { id job { @@ -40,19 +45,13 @@ query getEnvironmentDetails( ref tag job { - name - id - webPath - playable + ...DeploymentJob deploymentPipeline: pipeline { id jobs(whenExecuted: ["manual"], retried: false) { nodes { - id - name - playable + ...DeploymentJob scheduledAt - webPath } } } @@ -66,17 +65,11 @@ query getEnvironmentDetails( authorName authorEmail author { - id - name - avatarUrl - webUrl + ...User } } triggerer { - id - webUrl - name - avatarUrl + ...User } createdAt finishedAt diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql new file mode 100644 index 00000000000..818bca24d51 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql @@ -0,0 +1,7 @@ +query getK8sPods($configuration: Object, $namespace: String) { + k8sPods(configuration: $configuration, namespace: $namespace) @client { + status { + phase + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index e21670870b8..39e05825cf0 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,3 +1,4 @@ +import { CoreV1Api, Configuration } from '@gitlab/cluster-client'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { @@ -71,6 +72,19 @@ export const resolvers = (endpoint) => ({ isLastDeployment(_, { environment }) { return environment?.lastDeployment?.isLast; }, + k8sPods(_, { configuration, namespace }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const podsApi = namespace + ? coreV1Api.listCoreV1NamespacedPod(namespace) + : coreV1Api.listCoreV1PodForAllNamespaces(); + + return podsApi + .then((res) => res?.data?.items || []) + .catch((err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; + }); + }, }, Mutation: { stopEnvironment(_, { environment }, { client }) { diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index b4d1f7326f6..7c102fd04d8 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -62,6 +62,19 @@ type LocalPageInfo { previousPage: Int! } +type k8sPodStatus { + phase: String +} + +type LocalK8sPods { + status: k8sPodStatus +} + +input LocalConfiguration { + basePath: String + baseOptions: JSON +} + extend type Query { environmentApp(page: Int, scope: String): LocalEnvironmentApp folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder @@ -71,6 +84,7 @@ extend type Query { environmentToStop: LocalEnvironment isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean isLastDeployment(environment: LocalEnvironmentInput): Boolean + k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods] } extend type Mutation { diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js index 9802dcbcf78..92efd46df64 100644 --- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js +++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js @@ -62,6 +62,60 @@ export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName) ); }; +export const getRollbackActionFromDeploymentNode = (deploymentNode, environment) => { + const { job, id } = deploymentNode; + + if (!job) { + return null; + } + const isLastDeployment = id === environment.lastDeployment?.id; + const { webPath } = job; + return { + id, + name: environment.name, + lastDeployment: { + commit: deploymentNode.commit, + isLast: isLastDeployment, + }, + retryUrl: `${webPath}/retry`, + }; +}; + +const getDeploymentApprovalFromDeploymentNode = (deploymentNode, environment) => { + if (!environment.protectedEnvironments || environment.protectedEnvironments.nodes.length === 0) { + return { + isApprovalActionAvailable: false, + }; + } + + const protectedEnvironmentInfo = environment.protectedEnvironments.nodes[0]; + + const hasApprovalRules = protectedEnvironmentInfo.approvalRules.nodes?.length > 0; + const hasRequiredApprovals = protectedEnvironmentInfo.requiredApprovalCount > 0; + + const isApprovalActionAvailable = hasRequiredApprovals || hasApprovalRules; + const requiredMultipleApprovalRulesApprovals = protectedEnvironmentInfo.approvalRules.nodes.reduce( + (requiredApprovals, rule) => { + return requiredApprovals + rule.requiredApprovals; + }, + 0, + ); + + const requiredApprovalCount = hasRequiredApprovals + ? protectedEnvironmentInfo.requiredApprovalCount + : requiredMultipleApprovalRulesApprovals; + + return { + isApprovalActionAvailable, + deploymentIid: deploymentNode.iid, + environment: { + name: environment.name, + tier: environment.tier, + requiredApprovalCount, + }, + }; +}; + /** * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table * @param {Object} deploymentNode @@ -82,5 +136,7 @@ export const convertToDeploymentTableRow = (deploymentNode, environment) => { created: deploymentNode.createdAt || '', deployed: deploymentNode.finishedAt || '', actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name), + rollback: getRollbackActionFromDeploymentNode(deploymentNode, environment), + deploymentApproval: getDeploymentApprovalFromDeploymentNode(deploymentNode, environment), }; }; diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d9a523fd806..3f746bc5383 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import { parseBoolean } from '../lib/utils/common_utils'; import { apolloProvider } from './graphql/client'; import EnvironmentsApp from './components/environments_app.vue'; @@ -16,6 +17,7 @@ export default (el) => { projectPath, defaultBranchName, projectId, + kasTunnelUrl, } = el.dataset; return new Vue({ @@ -28,6 +30,7 @@ export default (el) => { newEnvironmentPath, helpPagePath, projectId, + kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl), canCreateEnvironment: parseBoolean(canCreateEnvironment), }, render(h) { diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index afce2b7f237..5e812c85c96 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -92,7 +92,9 @@ export const initPage = async () => { el, apolloProvider: apolloProvider(), router, - provide: {}, + provide: { + projectPath: dataSet.projectFullPath, + }, render(createElement) { return createElement('router-view'); }, diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js index aeed5450022..afb91d3db51 100644 --- a/app/assets/javascripts/error_tracking/utils.js +++ b/app/assets/javascripts/error_tracking/utils.js @@ -1,13 +1,13 @@ -/* eslint-disable @gitlab/require-i18n-strings */ +const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings /** * Tracks snowplow event when User clicks on error link to Sentry * @param {String} externalUrl that will be send as a property for the event */ export const trackClickErrorLinkToSentryOptions = (url) => ({ - category: 'Error Tracking', + category, action: 'click_error_link_to_sentry', - label: 'Error Link', + label: 'Error Link', // eslint-disable-line @gitlab/require-i18n-strings property: url, }); @@ -15,7 +15,7 @@ export const trackClickErrorLinkToSentryOptions = (url) => ({ * Tracks snowplow event when user views error list */ export const trackErrorListViewsOptions = { - category: 'Error Tracking', + category, action: 'view_errors_list', }; @@ -23,7 +23,7 @@ export const trackErrorListViewsOptions = { * Tracks snowplow event when user views error details */ export const trackErrorDetailsViewsOptions = { - category: 'Error Tracking', + category, action: 'view_error_details', }; @@ -31,6 +31,6 @@ export const trackErrorDetailsViewsOptions = { * Tracks snowplow event when error status is updated */ export const trackErrorStatusUpdateOptions = (status) => ({ - category: 'Error Tracking', + category, action: `update_${status}_status`, }); diff --git a/app/assets/javascripts/featurable/constants.js b/app/assets/javascripts/featurable/constants.js new file mode 100644 index 00000000000..23f1c5e415d --- /dev/null +++ b/app/assets/javascripts/featurable/constants.js @@ -0,0 +1,6 @@ +// Matches `app/models/concerns/featurable.rb` + +export const FEATURABLE_DISABLED = 'disabled'; +export const FEATURABLE_PRIVATE = 'private'; +export const FEATURABLE_ENABLED = 'enabled'; +export const FEATURABLE_PUBLIC = 'public'; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 93510870915..34e0b94af3b 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -187,7 +187,7 @@ export default { data-testid="feature-flags-tab-title" class="page-title gl-font-size-h-display gl-my-0" > - {{ s__('FeatureFlags|Feature Flags') }} + {{ s__('FeatureFlags|Feature flags') }} </h2> <gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge> </div> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index 286b214b511..dee1d239c9f 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -108,7 +108,7 @@ export default { {{ s__('FeatureFlags|Status') }} </div> <div class="table-section section-20" role="columnheader"> - {{ s__('FeatureFlags|Feature Flag') }} + {{ s__('FeatureFlags|Feature flag') }} </div> <div class="table-section section-40" role="columnheader"> {{ s__('FeatureFlags|Environment Specs') }} @@ -148,7 +148,7 @@ export default { <div class="table-section section-20" role="gridcell"> <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Feature Flag') }} + {{ s__('FeatureFlags|Feature flag') }} </div> <div class="table-mobile-content d-flex flex-column js-feature-flag-title"> <div class="gl-display-flex gl-align-items-center"> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index 1d7a79f926a..6c8a2d90209 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -138,7 +138,7 @@ export default { <template #description> {{ $options.i18n.strategyTypeDescription }} <gl-link :href="strategyTypeDocsPagePath" target="_blank"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </template> <gl-form-select @@ -202,7 +202,7 @@ export default { {{ $options.i18n.environmentsSelectDescription }} </span> <gl-link :href="environmentsScopeDocsPath" target="_blank"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </div> </div> diff --git a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js index 148d9a35b81..c2c46e4265a 100644 --- a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - const InputSetter = { init(hook) { this.hook = hook; @@ -33,11 +31,15 @@ const InputSetter = { setInput(config, selectedItem) { const input = config.input || this.hook.trigger; const newValue = selectedItem.getAttribute(config.valueAttribute); - const inputAttribute = config.inputAttribute; - - if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue); - if (input.tagName === 'INPUT') return (input.value = newValue); - return (input.textContent = newValue); + const { inputAttribute } = config; + + if (input.hasAttribute(inputAttribute)) { + input.setAttribute(inputAttribute, newValue); + } else if (input.tagName === 'INPUT') { + input.value = newValue; + } else { + input.textContent = newValue; + } }, destroy() { diff --git a/app/assets/javascripts/filtered_search/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js index d7f49bf19d8..3d3470a16d0 100644 --- a/app/assets/javascripts/filtered_search/droplab/utils.js +++ b/app/assets/javascripts/filtered_search/droplab/utils.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - import { template as _template } from 'lodash'; import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; @@ -26,7 +24,7 @@ const utils = { closest(thisTag, stopTag) { while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') { - thisTag = thisTag.parentNode; + thisTag = thisTag.parentNode; // eslint-disable-line no-param-reassign } return thisTag; }, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index d865354881a..684375177bb 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -2,7 +2,13 @@ import { last } from 'lodash'; import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { createAlert } from '~/alert'; -import { WORKSPACE_PROJECT } from '~/issues/constants'; +import { + STATUS_ALL, + STATUS_CLOSED, + STATUS_MERGED, + STATUS_OPEN, + WORKSPACE_PROJECT, +} from '~/issues/constants'; import { ENTER_KEY_CODE, BACKSPACE_KEY_CODE, @@ -43,7 +49,7 @@ export default class FilteredSearchManager { this.isGroupAncestor = isGroupAncestor; this.isGroupDecendent = isGroupDecendent; this.useDefaultState = useDefaultState; - this.states = ['opened', 'closed', 'merged', 'all']; + this.states = [STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED, STATUS_ALL]; this.page = page; this.container = FilteredSearchContainer.container; @@ -743,7 +749,7 @@ export default class FilteredSearchManager { const { tokens, searchToken } = this.getSearchTokens(); let currentState = state || getParameterByName('state'); if (!currentState && this.useDefaultState) { - currentState = 'opened'; + currentState = STATUS_OPEN; } if (this.states.includes(currentState)) { paths.push(`state=${currentState}`); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 81da8409873..b778e05c7b1 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -343,7 +343,9 @@ class GfmAutoComplete { icon, availabilityStatus: availability && isUserBusy(availability) - ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>` + ? `<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2"> ${s__( + 'UserProfile|Busy', + )}</span>` : '', }); } diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 60f1b7f5aa4..09ee7de3b6e 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -54,6 +54,7 @@ import { __ } from '~/locale'; const errorMessageClass = 'gl-field-error'; const inputErrorClass = 'gl-field-error-outline'; +const validInputHintClass = '.gl-field-hint-valid'; const errorAnchorSelector = '.gl-field-error-anchor'; const ignoreInputSelector = '.gl-field-error-ignore'; @@ -151,6 +152,7 @@ export default class GlFieldError { renderInvalid() { this.inputElement.addClass(inputErrorClass); this.scopedSiblings.addClass('hidden'); + this.inputElement.parents('.form-group').find(validInputHintClass).addClass('hidden'); return this.fieldErrorElement.removeClass('hidden'); } diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 77fca45c949..65aa38cfb99 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -22,6 +22,7 @@ export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package'; export const TYPENAME_PROJECT = 'Project'; export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPENAME_SITE_PROFILE = 'DastSiteProfile'; +export const TYPENAME_TODO = 'Todo'; export const TYPENAME_USER = 'User'; export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner'; export const TYPENAME_VULNERABILITY = 'Vulnerability'; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 316bc746051..740eb722629 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -8,6 +8,7 @@ import typeDefs from '~/work_items/graphql/typedefs.graphql'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; import { findHierarchyWidgetChildren } from '~/work_items/utils'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export const config = { typeDefs, @@ -81,6 +82,14 @@ export const config = { }); }, }, + userPermissions: { + read(permission = {}) { + return { + ...permission, + setWorkItemMetadata: false, + }; + }, + }, }, }, MemberInterfaceConnection: { @@ -126,6 +135,33 @@ export const config = { }; }, }, + Group: { + fields: { + projects: { + keyArgs: ['includeSubgroups', 'search'], + }, + descendantGroups: { + keyArgs: ['includeSubgroups', 'search'], + }, + }, + }, + ProjectConnection: { + fields: { + nodes: concatPagination(), + }, + }, + GroupConnection: { + fields: { + nodes: concatPagination(), + }, + }, + Board: { + fields: { + epics: { + keyArgs: ['boardId'], + }, + }, + }, BoardEpicConnection: { merge(existing = { nodes: [] }, incoming, { args }) { if (!args.after) { @@ -174,6 +210,13 @@ export const resolvers = { }); cache.writeQuery({ query: getIssueStateQuery, data }); }, + setActiveBoardItem(_, { boardItem }, { cache }) { + cache.writeQuery({ + query: activeBoardItemQuery, + data: { activeBoardItem: boardItem }, + }); + return boardItem; + }, }, }; diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 22629dfb7d8..f7d1efc4d1f 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -21,7 +21,8 @@ "Epic", "EpicIssue", "Issue", - "MergeRequest" + "MergeRequest", + "WorkItemWidgetCurrentUserTodos" ], "DependencyLinkMetadata": [ "NugetDependencyLinkMetadata" @@ -145,6 +146,8 @@ ], "WorkItemWidget": [ "WorkItemWidgetAssignees", + "WorkItemWidgetAwardEmoji", + "WorkItemWidgetCurrentUserTodos", "WorkItemWidgetDescription", "WorkItemWidgetHealthStatus", "WorkItemWidgetHierarchy", diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql index d8760f147e1..28405d9dc9b 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql +++ b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql @@ -10,5 +10,9 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) { } } } + ... on Issue { + id + dueDate + } } } diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue index 535758750f9..cba13c11c5d 100644 --- a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue @@ -8,14 +8,14 @@ export default { i18n: { title: s__('GroupsEmptyState|No archived projects.'), }, - inject: ['newProjectIllustration'], + inject: ['emptyProjectsIllustration'], }; </script> <template> <gl-empty-state :title="$options.i18n.title" - :svg-path="newProjectIllustration" + :svg-path="emptyProjectsIllustration" :svg-height="100" /> </template> diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue index 7223321bf3e..7c691b56a43 100644 --- a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue @@ -8,14 +8,14 @@ export default { i18n: { title: s__('GroupsEmptyState|No shared projects.'), }, - inject: ['newProjectIllustration'], + inject: ['emptyProjectsIllustration'], }; </script> <template> <gl-empty-state :title="$options.i18n.title" - :svg-path="newProjectIllustration" + :svg-path="emptyProjectsIllustration" :svg-height="100" /> </template> diff --git a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue index 955cb1ca63e..0bd95d59022 100644 --- a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue @@ -43,6 +43,7 @@ export default { 'newProjectPath', 'newSubgroupIllustration', 'newProjectIllustration', + 'emptyProjectsIllustration', 'emptySubgroupIllustration', 'canCreateSubgroups', 'canCreateProjects', diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index d9781ef9c84..8d202194de7 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -16,8 +16,12 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; -import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants'; -import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants'; +import { + VISIBILITY_LEVELS_STRING_TO_INTEGER, + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, +} from '~/visibility_level/constants'; +import { ITEM_TYPE } from '../constants'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index a4c163b0a81..5674e28f5da 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -2,12 +2,7 @@ import { GlBadge } from '@gitlab/ui'; import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { - ITEM_TYPE, - VISIBILITY_TYPE_ICON, - GROUP_VISIBILITY_TYPE, - PROJECT_VISIBILITY_TYPE, -} from '../constants'; +import { ITEM_TYPE } from '../constants'; import ItemStatsValue from './item_stats_value.vue'; export default { @@ -24,15 +19,6 @@ export default { }, }, computed: { - visibilityIcon() { - return VISIBILITY_TYPE_ICON[this.item.visibility]; - }, - visibilityTooltip() { - if (this.item.type === ITEM_TYPE.GROUP) { - return GROUP_VISIBILITY_TYPE[this.item.visibility]; - } - return PROJECT_VISIBILITY_TYPE[this.item.visibility]; - }, isProject() { return this.item.type === ITEM_TYPE.PROJECT; }, diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 6f5b03788a8..a5854632040 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -1,9 +1,4 @@ import { __, s__ } from '~/locale'; -import { - VISIBILITY_LEVEL_PRIVATE_STRING, - VISIBILITY_LEVEL_INTERNAL_STRING, - VISIBILITY_LEVEL_PUBLIC_STRING, -} from '~/visibility_level/constants'; export const MAX_CHILDREN_COUNT = 20; @@ -30,36 +25,6 @@ export const ITEM_TYPE = { GROUP: 'group', }; -export const GROUP_VISIBILITY_TYPE = { - [VISIBILITY_LEVEL_PUBLIC_STRING]: __( - 'Public - The group and any public projects can be viewed without any authentication.', - ), - [VISIBILITY_LEVEL_INTERNAL_STRING]: __( - 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', - ), - [VISIBILITY_LEVEL_PRIVATE_STRING]: __( - 'Private - The group and its projects can only be viewed by members.', - ), -}; - -export const PROJECT_VISIBILITY_TYPE = { - [VISIBILITY_LEVEL_PUBLIC_STRING]: __( - 'Public - The project can be accessed without any authentication.', - ), - [VISIBILITY_LEVEL_INTERNAL_STRING]: __( - 'Internal - The project can be accessed by any logged in user except external users.', - ), - [VISIBILITY_LEVEL_PRIVATE_STRING]: __( - '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 = { - [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth', - [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', - [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock', -}; - export const OVERVIEW_TABS_SORTING_ITEMS = [ { label: __('Name'), diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index c3bf3f28509..f6711bde7d0 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -51,6 +51,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups, canCreateProjects, @@ -63,6 +64,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), diff --git a/app/assets/javascripts/groups/init_group_readme.js b/app/assets/javascripts/groups/init_group_readme.js new file mode 100644 index 00000000000..7cde64fed4d --- /dev/null +++ b/app/assets/javascripts/groups/init_group_readme.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import apolloProvider from '~/repository/graphql'; +import FilePreview from '~/repository/components/preview/index.vue'; + +Vue.use(VueApollo); + +export const initGroupReadme = () => { + const el = document.getElementById('js-group-readme'); + + if (!el) return false; + + const { webPath, name } = el.dataset; + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(FilePreview, { + props: { + blob: { webPath, name }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index 664d07ca13d..4064520d1ca 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -44,6 +44,7 @@ export const initGroupOverviewTabs = () => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups, canCreateProjects, @@ -62,6 +63,7 @@ export const initGroupOverviewTabs = () => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), diff --git a/app/assets/javascripts/groups/settings/components/group_settings_readme.vue b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue new file mode 100644 index 00000000000..123c7fc58f5 --- /dev/null +++ b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue @@ -0,0 +1,147 @@ +<script> +import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { createProject } from '~/rest_api'; +import { createAlert } from '~/alert'; +import { openWebIDE } from '~/lib/utils/web_ide_navigator'; +import { README_MODAL_ID, GITLAB_README_PROJECT, README_FILE } from '../constants'; + +export default { + name: 'GroupSettingsReadme', + i18n: { + readme: __('README'), + addReadme: __('Add README'), + cancel: __('Cancel'), + createProjectAndReadme: s__('Groups|Create and add README'), + creatingReadme: s__('Groups|Creating README'), + existingProjectNewReadme: s__('Groups|This will create a README.md for project %{path}.'), + newProjectAndReadme: s__('Groups|This will create a project %{path} and add a README.md.'), + errorCreatingProject: s__('Groups|There was an error creating the Group README.'), + }, + components: { + GlButton, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + groupReadmePath: { + type: String, + required: false, + default: '', + }, + readmeProjectPath: { + type: String, + required: false, + default: '', + }, + groupPath: { + type: String, + required: true, + }, + groupId: { + type: String, + required: true, + }, + }, + data() { + return { + creatingReadme: false, + }; + }, + computed: { + hasReadme() { + return this.groupReadmePath.length > 0; + }, + hasReadmeProject() { + return this.readmeProjectPath.length > 0; + }, + pathToReadmeProject() { + return this.hasReadmeProject + ? this.readmeProjectPath + : `${this.groupPath}/${GITLAB_README_PROJECT}`; + }, + modalBody() { + return this.hasReadmeProject + ? this.$options.i18n.existingProjectNewReadme + : this.$options.i18n.newProjectAndReadme; + }, + modalSubmitButtonText() { + return this.hasReadmeProject + ? this.$options.i18n.addReadme + : this.$options.i18n.createProjectAndReadme; + }, + }, + methods: { + hideModal() { + this.$refs.modal.hide(); + }, + createReadme() { + if (this.hasReadmeProject) { + openWebIDE(this.readmeProjectPath, README_FILE); + } else { + this.createProjectWithReadme(); + } + }, + createProjectWithReadme() { + this.creatingReadme = true; + + const projectData = { + name: GITLAB_README_PROJECT, + namespace_id: this.groupId, + }; + + createProject(projectData) + .then(({ path_with_namespace: pathWithNamespace }) => { + openWebIDE(pathWithNamespace, README_FILE); + }) + .catch(() => { + this.hideModal(); + this.creatingReadme = false; + createAlert({ message: this.$options.i18n.errorCreatingProject }); + }); + }, + }, + README_MODAL_ID, +}; +</script> + +<template> + <div> + <gl-button v-if="hasReadme" icon="doc-text" :href="groupReadmePath">{{ + $options.i18n.readme + }}</gl-button> + <gl-button + v-else + v-gl-modal="$options.README_MODAL_ID" + variant="dashed" + icon="file-addition" + data-testid="group-settings-add-readme-button" + >{{ $options.i18n.addReadme }}</gl-button + > + <gl-modal ref="modal" :modal-id="$options.README_MODAL_ID" :title="$options.i18n.addReadme"> + <div data-testid="group-settings-modal-readme-body"> + <gl-sprintf :message="modalBody"> + <template #path> + <code>{{ pathToReadmeProject }}</code> + </template> + </gl-sprintf> + </div> + <template #modal-footer> + <gl-button variant="default" @click="hideModal">{{ $options.i18n.cancel }}</gl-button> + <gl-button v-if="creatingReadme" variant="default" loading disabled>{{ + $options.i18n.creatingReadme + }}</gl-button> + <gl-button + v-else + variant="confirm" + data-testid="group-settings-modal-create-readme-button" + @click="createReadme" + >{{ modalSubmitButtonText }}</gl-button + > + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js index c91c2a20529..023ddf29b36 100644 --- a/app/assets/javascripts/groups/settings/constants.js +++ b/app/assets/javascripts/groups/settings/constants.js @@ -1,3 +1,7 @@ export const LEVEL_TYPES = { GROUP: 'group', }; + +export const README_MODAL_ID = 'add_group_readme_modal'; +export const GITLAB_README_PROJECT = 'gitlab-profile'; +export const README_FILE = 'README.md'; diff --git a/app/assets/javascripts/groups/settings/init_group_settings_readme.js b/app/assets/javascripts/groups/settings/init_group_settings_readme.js new file mode 100644 index 00000000000..d126228d854 --- /dev/null +++ b/app/assets/javascripts/groups/settings/init_group_settings_readme.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import GroupSettingsReadme from './components/group_settings_readme.vue'; + +export const initGroupSettingsReadme = () => { + const el = document.getElementById('js-group-settings-readme'); + + if (!el) return false; + + const { groupReadmePath, readmeProjectPath, groupPath, groupId } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(GroupSettingsReadme, { + props: { + groupReadmePath, + readmeProjectPath, + groupPath, + groupId, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 9cb96283689..25a84d17379 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -37,7 +37,7 @@ export function initStatusTriggers() { const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl); Tracking.event(undefined, 'click_button', { label: 'user_edit_status', - property: buttonWithinTopNav ? 'navigation_top' : undefined, + property: buttonWithinTopNav ? 'navigation_top' : 'nav_user_menu', }); import( @@ -135,6 +135,8 @@ function initNewNavToggle() { }); } -requestIdleCallback(initStatusTriggers); +if (!gon?.use_new_navigation) { + requestIdleCallback(initStatusTriggers); +} requestIdleCallback(initNavUserDropdownTracking); requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index c0a06706fc6..aa349186014 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,7 +1,6 @@ <script> import { GlSearchBoxByType, - GlOutsideDirective as Outside, GlIcon, GlToken, GlTooltipDirective, @@ -36,6 +35,7 @@ import { IS_SEARCHING, IS_FOCUSED, IS_NOT_FOCUSED, + DROPDOWN_CLOSE_TIMEOUT, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -53,7 +53,7 @@ export default { SEARCH_RESULTS_SCOPE, KBD_HELP, }, - directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, + directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, HeaderSearchDefaultItems, @@ -65,7 +65,6 @@ export default { }, data() { return { - showDropdown: false, isFocused: false, currentFocusIndex: SEARCH_BOX_INDEX, }; @@ -91,7 +90,7 @@ export default { return Boolean(gon?.current_username); }, showSearchDropdown() { - if (!this.showDropdown || !this.isLoggedIn) { + if (!this.isFocused || !this.isLoggedIn) { return false; } return this.searchOptions?.length > 0; @@ -108,7 +107,6 @@ export default { } return FIRST_DROPDOWN_INDEX; }, - searchInputDescribeBy() { if (this.isLoggedIn) { return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; @@ -160,29 +158,18 @@ export default { methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { - this.showDropdown = true; - - // check isFocused state to avoid firing duplicate events - if (!this.isFocused) { - this.isFocused = true; - this.$emit('expandSearchBar', true); + this.isFocused = true; + this.$emit('expandSearchBar'); - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - } - }, - closeDropdown() { - this.showDropdown = false; + Tracking.event(undefined, 'focus_input', { + label: 'global_search', + property: 'navigation_top', + }); }, collapseAndCloseSearchBar() { - // we need a delay on this method - // for the search bar not to remove - // the clear button from dom - // and register clicks on dropdown items + // without timeout dropdown closes + // before click event is dispatched setTimeout(() => { - this.showDropdown = false; this.isFocused = false; this.$emit('collapseSearchBar'); @@ -190,7 +177,7 @@ export default { label: 'global_search', property: 'navigation_top', }); - }, 200); + }, DROPDOWN_CLOSE_TIMEOUT); }, submitSearch() { if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { @@ -226,7 +213,6 @@ export default { <template> <form - v-outside="closeDropdown" role="search" :aria-label="$options.i18n.SEARCH_GITLAB" class="header-search gl-relative gl-rounded-base gl-w-full" @@ -244,12 +230,11 @@ export default { :placeholder="$options.i18n.SEARCH_GITLAB" :aria-activedescendant="currentFocusedId" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focus="openDropdown" - @click="openDropdown" - @blur="collapseAndCloseSearchBar" + @focusin="openDropdown" + @focusout="collapseAndCloseSearchBar" @input="getAutocompleteOptions" @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="closeDropdown" + @keydown.esc.stop.prevent="collapseAndCloseSearchBar" /> <gl-token v-if="showScopeHelp" @@ -301,7 +286,7 @@ export default { :max="searchOptions.length - 1" :min="$options.FIRST_DROPDOWN_INDEX" :default-index="defaultIndex" - @tab="closeDropdown" + :enable-cycle="true" /> <header-search-default-items v-if="showDefaultItems" diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index b9bb4e573fd..47aeb2f9caa 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -31,3 +31,5 @@ export const IS_NOT_FOCUSED = 'is-not-focused'; export const FETCH_TYPES = ['generic', 'search']; export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; + +export const DROPDOWN_CLOSE_TIMEOUT = 200; diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js index 4e9404007ec..64502d13ee2 100644 --- a/app/assets/javascripts/header_search/init.js +++ b/app/assets/javascripts/header_search/init.js @@ -2,29 +2,18 @@ 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' + const { initHeaderSearchApp } = await import( + /* webpackChunkName: 'globalSearch' */ '~/header_search' ).catch((error) => Sentry.captureException(error)); - const searchDropdown = initSearchAutocomplete(); - searchDropdown.onSearchInputFocus(); + // 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(); } @@ -40,10 +29,7 @@ function initHeaderSearch() { HEADER_INIT_EVENTS.forEach((eventType) => { searchInputBox?.addEventListener( eventType, - eventHandler.bind( - { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch }, - cleanEventListeners, - ), + eventHandler.bind({ searchInputBox }, cleanEventListeners), { once: true }, ); }); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 92dacf8c94a..d788104edc8 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -43,6 +43,7 @@ export default { data-container="body" data-placement="right" data-qa-selector="edit_mode_tab" + data-testid="edit-mode-button" type="button" class="ide-sidebar-link js-ide-edit-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)" @@ -60,6 +61,7 @@ export default { :aria-label="s__('IDE|Review')" data-container="body" data-placement="right" + data-testid="review-mode-button" type="button" class="ide-sidebar-link js-ide-review-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)" @@ -78,6 +80,7 @@ export default { data-container="body" data-placement="right" data-qa-selector="commit_mode_tab" + data-testid="commit-mode-button" type="button" class="ide-sidebar-link js-ide-commit-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 2799ea1378e..d05aa960f01 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -82,7 +82,7 @@ export default { {{ __('Commit Message') }} <div id="ide-commit-message-popover-container"> <span id="ide-commit-message-question" class="form-text text-muted gl-ml-3"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </span> <gl-popover target="ide-commit-message-question" diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index ea1dbee4669..9f83de840b9 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -69,7 +69,7 @@ export default { > <gl-icon name="ellipsis_v" /> </button> - <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right"> + <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right" data-testid="dropdown-menu"> <template v-if="type === 'tree'"> <li> <item-button diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue index 7fca7429ad7..428cf7f55ac 100644 --- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue +++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue @@ -82,7 +82,7 @@ export default { <div>{{ __('Commit Message') }}</div> <div id="commit-message-popover-container"> <span id="commit-message-question" class="gl-gray-700 gl-ml-3"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </span> <gl-popover target="commit-message-question" diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 967c83b320f..29c44d2f596 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -72,7 +72,6 @@ export const initLegacyWebIDE = (el, options = {}) => { environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, userPreferencesPath: el.dataset.userPreferencesPath, - learnGitlabSource: parseBoolean(el.dataset.learnGitlabSource), }); }, beforeDestroy() { diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 4d3cefcb107..51af73decad 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -67,6 +67,7 @@ export const initGitlabWebIDE = async (el) => { links: { feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, userPreferences: el.dataset.userPreferencesPath, + signIn: el.dataset.signInPath, }, editorFont: { srcUrl: editorFontSrcUrl, diff --git a/app/assets/javascripts/ide/lib/languages/codeowners.js b/app/assets/javascripts/ide/lib/languages/codeowners.js new file mode 100644 index 00000000000..e2eed713801 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/codeowners.js @@ -0,0 +1,39 @@ +const conf = { + comments: { + lineComment: '#', + }, + autoClosingPairs: [{ open: '[', close: ']' }], + surroundingPairs: [{ open: '[', close: ']' }], +}; + +const language = { + tokenizer: { + root: [ + // comment + [/^#.*$/, 'comment'], + + // optional approval + [/^\^/, 'constant.numeric'], + + // number of approvers + [/\[\d+\]$/, 'constant.numeric'], + + // section + [/\[(?!\d+\])[^\]]+\]/, 'namespace'], + + // pattern + [/^\s*(\S+)/, 'regexp'], + + // owner + [/\S*@.*$/, 'variable.value'], + ], + }, +}; + +export default { + id: 'codeowners', + extensions: ['codeowners'], + aliases: ['CODEOWNERS'], + conf, + language, +}; diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js index f758cb7dd86..c2ab954eb73 100644 --- a/app/assets/javascripts/ide/lib/languages/index.js +++ b/app/assets/javascripts/ide/lib/languages/index.js @@ -1,6 +1,7 @@ import hcl from './hcl'; import vue from './vue'; +import codeowners from './codeowners'; -const languages = [vue, hcl]; +const languages = [vue, hcl, codeowners]; export default languages; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 9f1eae03685..06751b926b5 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,4 +1,5 @@ import { createAlert } from '~/alert'; +import { STATUS_OPEN } from '~/issues/constants'; import { __ } from '~/locale'; import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants'; import service from '../../services'; @@ -16,7 +17,7 @@ export const getMergeRequestsForBranch = ( .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, source_project_id: state.projects[projectId].id, - state: 'opened', + state: STATUS_OPEN, order_by: 'created_at', per_page: 1, }) diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 572465f7587..79a8ccf2285 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,7 +1,6 @@ import { createAlert } from '~/alert'; import { addNumericSuffix } from '~/ide/utils'; import { sprintf, __ } from '~/locale'; -import Tracking from '~/tracking'; import { leftSidebarViews } from '../../../constants'; import eventHub from '../../../eventhub'; import { parseCommitError } from '../../../lib/errors'; @@ -163,10 +162,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ); } - if (rootState.learnGitlabSource) { - Tracking.event(undefined, 'commit', { label: 'web_ide_learn_gitlab_source' }); - } - dispatch('setLastCommitMessage', data); dispatch('updateCommitMessage', ''); return dispatch('updateFilesAfterCommit', { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 013a0c3ce8f..356bbf28a48 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -32,5 +32,4 @@ export default () => ({ environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', userPreferencesPath: '', - learnGitlabSource: false, }); diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js new file mode 100644 index 00000000000..b9814b5ca60 --- /dev/null +++ b/app/assets/javascripts/import/constants.js @@ -0,0 +1,28 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { __, s__ } from '~/locale'; + +const STATISTIC_ITEMS = { + diff_note: __('Diff notes'), + issue: __('Issues'), + issue_attachment: s__('GithubImporter|Issue links'), + issue_event: __('Issue events'), + label: __('Labels'), + lfs_object: __('LFS objects'), + merge_request_attachment: s__('GithubImporter|Merge request links'), + milestone: __('Milestones'), + note: __('Notes'), + note_attachment: s__('GithubImporter|Note links'), + protected_branch: __('Protected branches'), + collaborator: s__('GithubImporter|Collaborators'), + pull_request: s__('GithubImporter|Pull requests'), + pull_request_merged_by: s__('GithubImporter|PR mergers'), + pull_request_review: s__('GithubImporter|PR reviews'), + pull_request_review_request: s__('GithubImporter|PR reviews'), + release: __('Releases'), + release_attachment: s__('GithubImporter|Release links'), +}; + +// support both camel case and snake case versions +Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS)); + +export { STATISTIC_ITEMS }; diff --git a/app/assets/javascripts/import/details/components/import_details_app.vue b/app/assets/javascripts/import/details/components/import_details_app.vue new file mode 100644 index 00000000000..86820663025 --- /dev/null +++ b/app/assets/javascripts/import/details/components/import_details_app.vue @@ -0,0 +1,25 @@ +<script> +import { s__ } from '~/locale'; +import ImportDetailsTable from './import_details_table.vue'; + +export default { + components: { ImportDetailsTable }, + props: { + project: { + type: Object, + required: false, + default: () => ({}), + }, + }, + i18n: { + pageTitle: s__('Import|GitHub import details'), + }, +}; +</script> + +<template> + <div> + <h1>{{ $options.i18n.pageTitle }}</h1> + <import-details-table /> + </div> +</template> diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue new file mode 100644 index 00000000000..9ce58e8a9bc --- /dev/null +++ b/app/assets/javascripts/import/details/components/import_details_table.vue @@ -0,0 +1,106 @@ +<script> +import { GlEmptyState, GlIcon, GlLink, GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import { STATISTIC_ITEMS } from '../../constants'; + +const DEFAULT_PAGE_SIZE = 20; + +export default { + components: { + GlEmptyState, + GlIcon, + GlLink, + GlTable, + PaginationBar, + }, + STATISTIC_ITEMS, + LOCAL_STORAGE_KEY: 'gl-import-details-page-size', + fields: [ + { + key: 'type', + label: __('Type'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'title', + label: __('Title'), + tdClass: 'gl-md-w-30 gl-word-break-word', + }, + { + key: 'url', + label: __('URL'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'details', + label: __('Details'), + }, + ], + data() { + return { + page: 1, + perPage: DEFAULT_PAGE_SIZE, + }; + }, + computed: { + items() { + return []; + }, + + hasItems() { + return this.items.length > 0; + }, + + pageInfo() { + const mockPageInfo = { + page: this.page, + perPage: this.perPage, + totalPages: this.page, + total: this.items.length, + }; + return mockPageInfo; + }, + }, + + methods: { + setPage(page) { + this.page = page; + }, + + setPageSize(size) { + this.perPage = size; + this.page = 1; + }, + }, +}; +</script> + +<template> + <div> + <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" show-empty> + <template #empty> + <gl-empty-state :title="s__('Import|No import details')" /> + </template> + + <template #cell(type)="{ item: { type } }"> + {{ $options.STATISTIC_ITEMS[type] }} + </template> + <template #cell(url)="{ item: { url } }"> + <gl-link v-if="url" :href="url" target="_blank"> + {{ url }} + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-table> + <pagination-bar + v-if="hasItems" + :page-info="pageInfo" + class="gl-mt-5" + :storage-key="$options.LOCAL_STORAGE_KEY" + @set-page="setPage" + @set-page-size="setPageSize" + /> + </div> +</template> diff --git a/app/assets/javascripts/import/details/index.js b/app/assets/javascripts/import/details/index.js new file mode 100644 index 00000000000..70850d947e2 --- /dev/null +++ b/app/assets/javascripts/import/details/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import ImportDetailsApp from './components/import_details_app.vue'; + +export default () => { + const el = document.querySelector('.js-import-details'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'ImportDetailsRoot', + render(createElement) { + return createElement(ImportDetailsApp); + }, + }); +}; diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index ec2ab9d0c3d..96d07803545 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -1,32 +1,10 @@ <script> -import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { STATUSES } from '../constants'; - -const STATISTIC_ITEMS = { - diff_note: __('Diff notes'), - issue: __('Issues'), - issue_attachment: s__('GithubImporter|Issue links'), - issue_event: __('Issue events'), - label: __('Labels'), - lfs_object: __('LFS objects'), - merge_request_attachment: s__('GithubImporter|Merge request links'), - milestone: __('Milestones'), - note: __('Notes'), - note_attachment: s__('GithubImporter|Note links'), - protected_branch: __('Protected branches'), - collaborator: s__('GithubImporter|Collaborators'), - pull_request: s__('GithubImporter|Pull requests'), - pull_request_merged_by: s__('GithubImporter|PR mergers'), - pull_request_review: s__('GithubImporter|PR reviews'), - pull_request_review_request: s__('GithubImporter|PR reviews'), - release: __('Releases'), - release_attachment: s__('GithubImporter|Release links'), -}; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -// support both camel case and snake case versions -Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS)); +import { STATISTIC_ITEMS } from '~/import/constants'; +import { STATUSES } from '../constants'; const SCHEDULED_STATUS = { icon: 'status-scheduled', @@ -78,6 +56,13 @@ export default { GlAccordionItem, GlBadge, GlIcon, + GlLink, + }, + mixins: [glFeatureFlagMixin()], + inject: { + detailsPath: { + default: undefined, + }, }, props: { status: { @@ -103,13 +88,16 @@ export default { return this.stats && this.knownStats.length > 0; }, + isIncomplete() { + return this.status === STATUSES.FINISHED && this.stats && isIncompleteImport(this.stats); + }, + mappedStatus() { if (this.status === STATUSES.FINISHED) { - const isIncomplete = this.stats && isIncompleteImport(this.stats); - return isIncomplete + return this.isIncomplete ? { icon: 'status-alert', - text: __('Partial import'), + text: s__('Import|Partially completed'), variant: 'warning', } : { @@ -121,6 +109,10 @@ export default { return STATUS_MAP[this.status]; }, + + showDetails() { + return Boolean(this.detailsPath) && this.glFeatures.importDetailsPage && this.isIncomplete; + }, }, methods: { @@ -141,25 +133,22 @@ export default { }, STATISTIC_ITEMS, + i18n: { + detailsLink: s__('Import|See failures'), + }, }; </script> <template> <div> - <div class="gl-display-inline-block gl-w-13"> - <gl-badge - :icon="mappedStatus.icon" - :variant="mappedStatus.variant" - size="md" - icon-size="sm" - class="gl-mr-2" - > + <div class="gl-display-inline-block"> + <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" icon-size="sm"> {{ mappedStatus.text }} </gl-badge> </div> <gl-accordion v-if="hasStats" :header-level="3"> <gl-accordion-item :title="__('Details')"> - <ul class="gl-p-0 gl-list-style-none gl-font-sm"> + <ul class="gl-p-0 gl-mb-3 gl-list-style-none gl-font-sm"> <li v-for="key in knownStats" :key="key"> <div class="gl-display-flex gl-w-20 gl-align-items-center"> <gl-icon @@ -174,6 +163,7 @@ export default { </div> </li> </ul> + <gl-link v-if="showDetails" :href="detailsPath">{{ $options.i18n.detailsLink }}</gl-link> </gl-accordion-item> </gl-accordion> </div> diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index aaa37f145aa..55a8bad27b9 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -180,18 +180,20 @@ export default { class="gl-mb-5" /> <div v-if="repositories.length" class="gl-w-full"> - <table> - <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100"> - <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> - {{ fromHeaderText }} - </th> - <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> - {{ __('To GitLab') }} - </th> - <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"> - {{ __('Status') }} - </th> - <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th> + <table class="table gl-table"> + <thead> + <tr> + <th class="gl-w-half"> + {{ fromHeaderText }} + </th> + <th class="gl-w-half"> + {{ __('To GitLab') }} + </th> + <th> + {{ __('Status') }} + </th> + <th></th> + </tr> </thead> <tbody> <template v-for="repo in repositories"> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index 265cca9070e..b20309baac7 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -155,16 +155,16 @@ export default { <template> <tr - class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top" + class="gl-h-11" data-qa-selector="project_import_row" :data-qa-source-project="repo.importSource.fullName" > - <td class="gl-p-4 gl-vertical-align-top"> + <td> <gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink" >{{ repo.importSource.fullName }} <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </gl-link> - <div v-if="isFinished" class="gl-font-sm"> + <div v-if="isFinished" class="gl-font-sm gl-mt-2"> <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')"> <template #link> <gl-link @@ -179,52 +179,50 @@ export default { </gl-sprintf> </div> </td> - <td - class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top" - data-testid="fullPath" - data-qa-selector="project_path_content" - > - <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> - <template v-else-if="isImportNotStarted || isSelectedForReimport"> - <div class="gl-display-flex gl-align-items-stretch gl-w-full"> - <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> - <template v-if="namespaces.length"> - <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="ns in namespaces" - :key="ns.fullPath" - data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns.fullPath" - @click="updateImportTarget({ targetNamespace: ns.fullPath })" - > - {{ ns.fullPath }} - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> - <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{ - userNamespace - }}</gl-dropdown-item> - </import-group-dropdown> - <div - class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" - > - / + <td data-testid="fullPath" data-qa-selector="project_path_content"> + <div class="gl-display-flex gl-sm-flex-wrap"> + <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> + <template v-else-if="isImportNotStarted || isSelectedForReimport"> + <div class="gl-display-flex gl-align-items-stretch gl-w-full"> + <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> + <template v-if="namespaces.length"> + <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in namespaces" + :key="ns.fullPath" + data-qa-selector="target_group_dropdown_item" + :data-qa-group-name="ns.fullPath" + @click="updateImportTarget({ targetNamespace: ns.fullPath })" + > + {{ ns.fullPath }} + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> + <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{ + userNamespace + }}</gl-dropdown-item> + </import-group-dropdown> + <div + class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" + > + / + </div> + <gl-form-input + ref="newNameInput" + v-model="newNameInput" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + data-qa-selector="project_path_field" + /> </div> - <gl-form-input - ref="newNameInput" - v-model="newNameInput" - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - data-qa-selector="project_path_field" - /> - </div> - </template> - <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> + </template> + <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> + </div> </td> - <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator"> + <td data-qa-selector="import_status_indicator"> <import-status :status="importStatus" :stats="stats" /> </td> - <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap"> + <td data-testid="actions" class="gl-white-space-nowrap"> <gl-tooltip :target="() => $refs.cancelButton.$el"> <div class="gl-text-left"> <p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p> diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 66ffd378426..f898e23b47a 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -66,12 +66,16 @@ export default function mountImportProjectsTable({ const store = initStoreFromElement(mountElement); const props = initPropsFromElement(mountElement); + const { detailsPath } = mountElement.dataset; return new Vue({ el: mountElement, name: 'ImportProjectsRoot', store, apolloProvider, + provide: { + detailsPath, + }, render(createElement) { // We are using attrs instead of props so root-level component with inheritAttrs // will be able to pass them down diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index f8e70fea7aa..e15cb2224f4 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -12,6 +12,7 @@ import { GlEmptyState, } from '@gitlab/ui'; import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; +import { STATUS_CLOSED } from '~/issues/constants'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { s__, n__ } from '~/locale'; import { INCIDENT_SEVERITY } from '~/sidebar/constants'; @@ -301,6 +302,9 @@ export default { getEscalationStatus(escalationStatus) { return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus; }, + isClosed(item) { + return item.state === STATUS_CLOSED; + }, showIncidentLink({ iid }) { return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid); }, @@ -397,7 +401,7 @@ export default { <template #cell(title)="{ item }"> <div :class="{ - 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed', + 'gl-display-flex gl-align-items-center gl-max-w-full': isClosed(item), }" > <gl-link @@ -411,7 +415,7 @@ export default { </tooltip-on-truncate> </gl-link> <gl-icon - v-if="item.state === 'closed'" + v-if="isClosed(item)" name="issue-close" class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0" :size="16" diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index dde40ec2983..6f8d5cf5f89 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { s__ } from '~/locale'; export const I18N = { @@ -51,11 +50,13 @@ export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; export const INCIDENT_DETAILS_PATH = 'incident'; +const category = 'Incident Management'; // eslint-disable-line @gitlab/require-i18n-strings + /** * Tracks snowplow event when user clicks create new incident */ export const trackIncidentCreateNewOptions = { - category: 'Incident Management', + category, action: 'create_incident_button_clicks', }; @@ -63,7 +64,7 @@ export const trackIncidentCreateNewOptions = { * Tracks snowplow event when user views incidents list */ export const trackIncidentListViewsOptions = { - category: 'Incident Management', + category, action: 'view_incidents_list', }; @@ -71,6 +72,6 @@ export const trackIncidentListViewsOptions = { * Tracks snowplow event when user views incident details */ export const trackIncidentDetailsViewsOptions = { - category: 'Incident Management', + category, action: 'view_incident_details', }; diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js index 8413fe92f89..82350a3987e 100644 --- a/app/assets/javascripts/init_diff_stats_dropdown.js +++ b/app/assets/javascripts/init_diff_stats_dropdown.js @@ -4,7 +4,15 @@ import { stickyMonitor } from './lib/utils/sticky'; export const initDiffStatsDropdown = (stickyTop) => { if (stickyTop) { - stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false); + // We spend quite a bit of effort in our CSS to set the correct padding-top on the + // layout page, so we re-use the padding set there to determine at what height our + // element should be sticky + const pageLayout = document.querySelector('.layout-page'); + const pageLayoutTopOffset = pageLayout + ? parseFloat(window.getComputedStyle(pageLayout).getPropertyValue('padding-top') || 0) + : 0; + + stickyMonitor(document.querySelector('.js-diff-files-changed'), pageLayoutTopOffset, false); } const el = document.querySelector('.js-diff-stats-dropdown'); diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue index 767675cc64c..aaa04dc4b43 100644 --- a/app/assets/javascripts/invite_members/components/invite_group_notification.vue +++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue @@ -1,12 +1,7 @@ <script> import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { GROUP_MODAL_ALERT_BODY } from '../constants'; - -const SHARE_GROUP_LINK = - 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group'; export default { - SHARE_GROUP_LINK, name: 'InviteGroupNotification', components: { GlAlert, GlSprintf, GlLink }, inject: ['freeUsersLimit'], @@ -15,18 +10,23 @@ export default { type: String, required: true, }, - }, - i18n: { - body: GROUP_MODAL_ALERT_BODY, + notificationText: { + type: String, + required: true, + }, + notificationLink: { + type: String, + required: true, + }, }, }; </script> <template> <gl-alert variant="warning" :dismissible="false"> - <gl-sprintf :message="$options.i18n.body"> + <gl-sprintf :message="notificationText"> <template #link="{ content }"> - <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{ + <gl-link :href="notificationLink" target="_blank" class="gl-label-link">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 3be3b9df747..51355baef99 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -190,7 +190,13 @@ export default { @submit="sendInvite" > <template #alert> - <invite-group-notification v-if="freeUserCapEnabled" :name="name" /> + <invite-group-notification + v-if="freeUserCapEnabled" + :name="name" + :notification-text="$options.labels[inviteTo].notificationText" + :notification-link="$options.labels[inviteTo].notificationLink" + class="gl-mb-5" + /> </template> <template #select> 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 812e39e6392..e99a61caf3f 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -13,20 +13,21 @@ 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 Tracking from '~/tracking'; -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__, sprintf } from '~/locale'; import { + memberName, + triggerExternalAlert, + qualifiesForTasksToBeDone, +} from 'ee_else_ce/invite_members/utils/member_utils'; +import { USERS_FILTER_ALL, INVITE_MEMBERS_FOR_TASK, MEMBER_MODAL_LABELS, - LEARN_GITLAB, INVITE_MEMBER_MODAL_TRACKING_CATEGORY, } from '../constants'; import eventHub from '../event_hub'; import { responseFromSuccess } from '../utils/response_message_parser'; -import { memberName } from '../utils/member_utils'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import { displaySuccessfulInvitationAlert, @@ -170,11 +171,7 @@ export default { ); }, tasksToBeDoneEnabled() { - return ( - (getParameterValues('open_modal')[0] === 'invite_members_for_task' || - this.isOnLearnGitlab) && - this.tasksToBeDoneOptions.length - ); + return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length; }, showTasksToBeDone() { return ( @@ -193,9 +190,6 @@ export default { ? this.selectedTaskProject.id : ''; }, - isOnLearnGitlab() { - return this.source === LEARN_GITLAB; - }, showUserLimitNotification() { return !isEmpty(this.usersLimitDataset.alertVariant); }, @@ -248,14 +242,10 @@ export default { eventHub.$on('openModal', (options) => { this.openModal(options); - if (this.isOnLearnGitlab) { - this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source); - } }); if (this.tasksToBeDoneEnabled) { this.openModal({ source: 'in_product_marketing_email' }); - this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view); } }, methods: { @@ -283,16 +273,29 @@ export default { closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, - trackEvent(experimentName, eventName) { - const tracking = new ExperimentTracking(experimentName); - tracking.event(eventName); - }, showEmptyInvitesAlert() { this.invalidFeedbackMessage = this.$options.labels.placeHolder; this.shouldShowEmptyInvitesAlert = true; this.$refs.alerts.focus(); }, - sendInvite({ accessLevel, expiresAt }) { + getInvitePayload({ accessLevel, expiresAt }) { + const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); + + const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {}; + const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {}; + + return { + format: 'json', + expires_at: expiresAt, + access_level: accessLevel, + invite_source: this.source, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, + ...email, + ...userId, + }; + }, + async sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; this.clearValidation(); @@ -301,40 +304,28 @@ export default { return; } - const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); + this.trackInviteMembersForTask(); const apiAddByInvite = this.isProject ? Api.inviteProjectMembers.bind(Api) : Api.inviteGroupMembers.bind(Api); - const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {}; - const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {}; + try { + const payload = this.getInvitePayload({ accessLevel, expiresAt }); + const response = await apiAddByInvite(this.id, payload); - this.trackinviteMembersForTask(); - - apiAddByInvite(this.id, { - format: 'json', - expires_at: expiresAt, - access_level: accessLevel, - invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, - ...email, - ...userId, - }) - .then((response) => { - const { error, message } = responseFromSuccess(response); + const { error, message } = responseFromSuccess(response); - if (error) { - this.showMemberErrors(message); - } else { - this.onInviteSuccess(); - } - }) - .catch((e) => this.showInvalidFeedbackMessage(e)) - .finally(() => { - this.isLoading = false; - }); + if (error) { + this.showMemberErrors(message); + } else { + this.onInviteSuccess(); + } + } catch (e) { + this.showInvalidFeedbackMessage(e); + } finally { + this.isLoading = false; + } }, showMemberErrors(message) { this.invalidMembers = message; @@ -344,11 +335,10 @@ export default { // initial token creation hits this and nothing is found... so safe navigation return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; }, - trackinviteMembersForTask() { + trackInviteMembersForTask() { const label = 'selected_tasks_to_be_done'; const property = this.selectedTasksToBeDone.join(','); - const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); - tracking.event(INVITE_MEMBERS_FOR_TASK.submit); + this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property }); }, onCancel() { this.track('click_cancel', { label: this.source }); @@ -377,9 +367,7 @@ export default { } }, showSuccessMessage() { - if (this.isOnLearnGitlab) { - eventHub.$emit('showSuccessfulInvitationsAlert'); - } else { + if (!triggerExternalAlert(this.source)) { this.$toast.show(this.$options.labels.toastMessageSuccessful); } @@ -431,7 +419,9 @@ export default { @access-level="onAccessLevelUpdate" > <template #intro-text-before> - <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> + <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"> + <gl-emoji data-name="tada" /> + </div> </template> <template #intro-text-after> <br /> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 86badd16d6c..d5e9e498c6b 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,12 +1,11 @@ import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const SEARCH_DELAY = 200; export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100'; export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100'; export const INVITE_MEMBERS_FOR_TASK = { minimum_access_level: 30, - name: 'invite_members_for_task', - view: 'modal_opened_from_email', submit: 'submit', }; export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully'; @@ -61,9 +60,18 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", ); -export const GROUP_MODAL_ALERT_BODY = s__( - 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', +export const GROUP_MODAL_TO_GROUP_ALERT_BODY = s__( + 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', ); +export const GROUP_MODAL_TO_GROUP_ALERT_LINK = helpPagePath('user/group/manage', { + anchor: 'share-a-group-with-another-group', +}); +export const GROUP_MODAL_TO_PROJECT_ALERT_BODY = s__( + 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your project%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', +); +export const GROUP_MODAL_TO_PROJECT_ALERT_LINK = helpPagePath('user/project/members/index', { + anchor: 'add-groups-to-a-project', +}); export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite'); export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite'); @@ -129,16 +137,19 @@ export const GROUP_MODAL_LABELS = { title: GROUP_MODAL_DEFAULT_TITLE, toGroup: { introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, + notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY, + notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK, }, toProject: { introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, + notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY, + notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK, }, searchField: GROUP_SEARCH_FIELD, placeHolder: GROUP_PLACEHOLDER, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, }; -export const LEARN_GITLAB = 'learn_gitlab'; export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed'; export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal'; diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js index d85162626f1..240a3a89686 100644 --- a/app/assets/javascripts/invite_members/utils/member_utils.js +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -1,4 +1,14 @@ +import { getParameterValues } from '~/lib/utils/url_utility'; + 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; } + +export function triggerExternalAlert() { + return false; +} + +export function qualifiesForTasksToBeDone() { + return getParameterValues('open_modal')[0] === 'invite_members_for_task'; +} diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index c4b9bdb150b..d32336395dc 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -111,13 +111,14 @@ export default { <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> <gl-icon v-if="hasState" + :id="`iconElementXL-${itemId}`" ref="iconElementXL" :class="iconClasses" :name="iconName" :title="stateTitle" :aria-label="state" /> - <gl-tooltip :target="() => $refs.iconElementXL"> + <gl-tooltip :target="`iconElementXL-${itemId}`"> <span v-safe-html="stateTitle"></span> </gl-tooltip> <gl-icon @@ -141,7 +142,7 @@ export default { <!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). --> <!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 --> <div - class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-wrap-reverse" + class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-reverse" > <!-- Path area: status icon (<XL), path, issue # --> <div @@ -221,7 +222,7 @@ export default { category="tertiary" size="small" :disabled="removeDisabled" - class="js-issue-item-remove-button" + class="js-issue-item-remove-button gl-mr-2" data-qa-selector="remove_related_issue_button" :title="__('Remove')" :aria-label="__('Remove')" diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index 9ffcf14c943..799c0a18444 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -125,8 +125,13 @@ export default { </script> <template> - <gl-badge class="issuable-status-badge gl-mr-3" :class="badgeClass" :variant="badgeVariant"> - <gl-icon :name="badgeIcon" /> + <gl-badge + class="issuable-status-badge gl-mr-3" + :class="badgeClass" + :variant="badgeVariant" + :aria-label="badgeText" + > + <gl-icon :name="badgeIcon" class="gl-badge-icon" /> <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span> </gl-badge> </template> diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 8a094d5d688..a1525ad2bec 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -6,12 +6,13 @@ import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility' import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import ZenMode from '~/zen_mode'; +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; const DATA_ISSUES_NEW_PATH = 'data-new-issue-path'; -function organizeQuery(obj, isFallbackKey = false) { +export function organizeQuery(obj, isFallbackKey = false) { if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { return obj; } @@ -83,11 +84,10 @@ export default class IssuableForm { this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH)); this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); - this.descriptionField = this.form.find('textarea[name*="[description]"]'); + this.descriptionField = () => this.form.find('textarea[name*="[description]"]'); + this.submitButton = this.form.find('.js-issuable-submit-button'); this.draftCheck = document.querySelector('input.js-toggle-draft'); - if (!(this.titleField.length && this.descriptionField.length)) { - return; - } + if (!this.titleField.length) return; this.autosaves = this.initAutosave(); this.form.on('submit', this.handleSubmit); @@ -99,7 +99,7 @@ export default class IssuableForm { if ($issuableDueDate.length) { const calendar = new Pikaday({ field: $issuableDueDate.get(0), - theme: 'gitlab-theme animate-picker', + theme: 'gl-datepicker-theme animate-picker', format: 'yyyy-mm-dd', container: $issuableDueDate.parent().get(0), parse: (dateString) => parsePikadayDate(dateString), @@ -125,13 +125,6 @@ export default class IssuableForm { ); IssuableForm.addAutosave( autosaveMap, - 'description', - this.form.find('textarea[name*="[description]"]').get(0), - this.searchTerm, - this.fallbackKey, - ); - IssuableForm.addAutosave( - autosaveMap, 'confidential', this.form.find('input:checkbox[name*="[confidential]"]').get(0), this.searchTerm, @@ -148,7 +141,21 @@ export default class IssuableForm { return autosaveMap; } - handleSubmit() { + async handleSubmit(event) { + event.preventDefault(); + + const form = event.target; + const descriptionText = this.descriptionField().val(); + + if (containsSensitiveToken(descriptionText)) { + const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt); + if (!confirmed) { + this.submitButton.removeAttr('disabled'); + this.submitButton.removeClass('disabled'); + return false; + } + } + form.submit(); return this.resetAutosave(); } diff --git a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js index 4a6edae0c06..cbad6a2537d 100644 --- a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js @@ -1,4 +1,5 @@ import { isEmpty } from 'lodash'; +import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; import { sprintf, __ } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -107,13 +108,13 @@ const mixins = { return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; }, isOpen() { - return this.state === 'opened' || this.state === 'reopened'; + return this.state === STATUS_OPEN || this.state === STATUS_REOPENED; }, isClosed() { - return this.state === 'closed'; + return this.state === STATUS_CLOSED; }, isMerged() { - return this.state === 'merged'; + return this.state === STATUS_MERGED; }, hasTitle() { return this.title.length > 0; diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index b7d885ed8a7..d35355a8f26 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -5,6 +5,7 @@ export const STATUS_CLOSED = 'closed'; export const STATUS_MERGED = 'merged'; export const STATUS_OPEN = 'opened'; export const STATUS_REOPENED = 'reopened'; +export const STATUS_LOCKED = 'locked'; export const TITLE_LENGTH_MAX = 255; @@ -22,4 +23,6 @@ export const IssuableStatusText = { [STATUS_CLOSED]: __('Closed'), [STATUS_OPEN]: __('Open'), [STATUS_REOPENED]: __('Open'), + [STATUS_MERGED]: __('Merged'), + [STATUS_LOCKED]: __('Open'), }; diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index c821c18bcb9..de0334b4ffe 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -432,7 +432,7 @@ export default class CreateMergeRequestDropdown { let xhr = null; event.preventDefault(); - if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) { + if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) { this.droplab.hooks.forEach((hook) => hook.list.toggle()); return; @@ -442,9 +442,9 @@ export default class CreateMergeRequestDropdown { return; } - if (event.target.dataset.action === CREATE_MERGE_REQUEST) { + if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) { xhr = this.createMergeRequest(); - } else if (event.target.dataset.action === CREATE_BRANCH) { + } else if (event.currentTarget.dataset.action === CREATE_BRANCH) { xhr = this.createBranch(); } diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 83387d3ac29..61531880842 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -3,12 +3,10 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GLForm from '~/gl_form'; import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { TYPE_INCIDENT } from '~/issues/constants'; import Issue from '~/issues/issue'; -import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new'; import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; import { initRelatedIssues } from '~/related_issues'; import { @@ -38,15 +36,14 @@ export function initFilteredSearchServiceDesk() { } export function initForm() { - new GLForm($('.issue-form')); // eslint-disable-line no-new new IssuableForm($('.issue-form')); // eslint-disable-line no-new IssuableLabelSelector(); - new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initTitleSuggestions(); initTypePopover(); + initTypeSelect(); mountMilestoneDropdown(); } diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index d11540ad3dd..5199c36db5a 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -77,7 +77,7 @@ export default { class="issuable-milestone gl-mr-3" data-testid="issuable-milestone" > - <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate"> + <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate" class="gl-font-sm"> <gl-icon name="clock" /> {{ issue.milestone.title }} </gl-link> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 99064a50e3f..2c6f11b682c 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -76,8 +76,8 @@ export const ALTERNATIVE_FILTER = 'alternativeFilter'; export const i18n = { calendarLabel: __('Subscribe to calendar'), - closed: __('CLOSED'), - closedMoved: __('CLOSED (MOVED)'), + closed: __('Closed'), + closedMoved: __('Closed (moved)'), confidentialNo: __('No'), confidentialYes: __('Yes'), downvotes: __('Downvotes'), diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js index b590006929a..e64870152bd 100644 --- a/app/assets/javascripts/issues/list/graphql.js +++ b/app/assets/javascripts/issues/list/graphql.js @@ -1,7 +1,9 @@ import produce from 'immer'; -import createDefaultClient from '~/lib/graphql'; +import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +let client; + const resolvers = { Mutation: { reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => { @@ -22,6 +24,10 @@ const resolvers = { }, }; -export const gqlClient = gon.features?.frontendCaching - ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' }) - : createDefaultClient(resolvers); +export async function gqlClient() { + if (client) return client; + client = gon.features?.frontendCaching + ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' }) + : createDefaultClient(resolvers); + return client; +} diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index aca894549e4..a97b59c1e4f 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -6,7 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue'; import { gqlClient } from './graphql'; -export function mountJiraIssuesListApp() { +export async function mountJiraIssuesListApp() { const el = document.querySelector('.js-jira-issues-import-status-root'); if (!el) { @@ -27,7 +27,7 @@ export function mountJiraIssuesListApp() { el, name: 'JiraIssuesImportStatusRoot', apolloProvider: new VueApollo({ - defaultClient: gqlClient, + defaultClient: await gqlClient(), }), render(createComponent) { return createComponent(JiraIssuesImportStatusApp, { @@ -42,7 +42,7 @@ export function mountJiraIssuesListApp() { }); } -export function mountIssuesListApp() { +export async function mountIssuesListApp() { const el = document.querySelector('.js-issues-list-root'); if (!el) { @@ -100,7 +100,7 @@ export function mountIssuesListApp() { el, name: 'IssuesListRoot', apolloProvider: new VueApollo({ - defaultClient: gqlClient, + defaultClient: await gqlClient(), }), router: new VueRouter({ base: window.location.pathname, diff --git a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue index a01f4f747b9..be2237ae2a2 100644 --- a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue +++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import { STATUS_CLOSED } from '~/issues/constants'; import { __ } from '~/locale'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; @@ -42,7 +43,7 @@ export default { ].filter(({ count }) => count); }, isClosed() { - return this.suggestion.state === 'closed'; + return this.suggestion.state === STATUS_CLOSED; }, stateIconClass() { return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500'; diff --git a/app/assets/javascripts/issues/new/components/type_select.vue b/app/assets/javascripts/issues/new/components/type_select.vue new file mode 100644 index 00000000000..81c3a769d26 --- /dev/null +++ b/app/assets/javascripts/issues/new/components/type_select.vue @@ -0,0 +1,113 @@ +<script> +import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; +import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants'; +import { visitUrl } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; +import { __ } from '~/locale'; + +export default { + i18n: { + selectType: __('Select type'), + issuableType: { + [TYPE_ISSUE]: __('Issue'), + [TYPE_INCIDENT]: __('Incident'), + }, + }, + components: { + GlCollapsibleListbox, + GlIcon, + }, + mixins: [Tracking.mixin()], + props: { + selectedType: { + required: false, + default: '', + type: String, + }, + isIssueAllowed: { + required: false, + default: false, + type: Boolean, + }, + isIncidentAllowed: { + required: false, + default: false, + type: Boolean, + }, + issuePath: { + required: false, + default: '', + type: String, + }, + incidentPath: { + required: false, + default: '', + type: String, + }, + }, + data() { + return { + selected: this.selectedType, + }; + }, + computed: { + toggleText() { + return this.selectedType + ? this.$options.i18n.issuableType[this.selectedType] + : this.$options.i18n.selectType; + }, + dropdownItems() { + const issueItem = this.isIssueAllowed + ? { + value: TYPE_ISSUE, + text: __('Issue'), + icon: 'issue-type-issue', + href: this.issuePath, + } + : null; + const incidentItem = this.isIncidentAllowed + ? { + value: TYPE_INCIDENT, + text: __('Incident'), + icon: 'issue-type-incident', + href: this.incidentPath, + tracking: { + action: 'select_issue_type_incident', + label: 'select_issue_type_incident_dropdown_option', + }, + } + : null; + + return [issueItem, incidentItem].filter(Boolean); + }, + }, + methods: { + selectType(type) { + const selectedItem = this.dropdownItems.find((item) => item.value === type); + if (selectedItem.tracking) { + const { action, label } = selectedItem.tracking; + this.track(action, { label }); + } + + visitUrl(selectedItem.href); + }, + }, +}; +</script> + +<template> + <gl-collapsible-listbox + v-model="selected" + :header-text="$options.i18n.selectType" + :toggle-text="toggleText" + :items="dropdownItems" + block + class="js-issuable-type-filter-dropdown-wrap" + @select="selectType" + > + <template #list-item="{ item }"> + <gl-icon :name="item.icon" :size="16" /> + {{ item.text }} + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js index 91599502996..84a170e6564 100644 --- a/app/assets/javascripts/issues/new/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -1,8 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import TitleSuggestions from './components/title_suggestions.vue'; import TypePopover from './components/type_popover.vue'; +import TypeSelect from './components/type_select.vue'; export function initTitleSuggestions() { const el = document.getElementById('js-suggestions'); @@ -56,3 +58,28 @@ export function initTypePopover() { render: (createElement) => createElement(TypePopover), }); } + +export function initTypeSelect() { + const el = document.getElementById('js-type-select'); + + if (!el) { + return undefined; + } + + const { selectedType, isIssueAllowed, isIncidentAllowed, issuePath, incidentPath } = el.dataset; + + return new Vue({ + el, + name: 'TypeSelectRoot', + render: (createElement) => + createElement(TypeSelect, { + props: { + selectedType, + isIssueAllowed: parseBoolean(isIssueAllowed), + isIncidentAllowed: parseBoolean(isIncidentAllowed), + issuePath, + incidentPath, + }, + }), + }); +} diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 15f97222971..bc32a15a420 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -14,6 +14,7 @@ import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; @@ -95,10 +96,10 @@ export default { required: false, default: '', }, - initialTaskStatus: { - type: String, + initialTaskCompletionStatus: { + type: Object, required: false, - default: '', + default: () => ({}), }, updatedAt: { type: String, @@ -197,7 +198,7 @@ export default { updatedAt: this.updatedAt, updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, - taskStatus: this.initialTaskStatus, + taskCompletionStatus: this.initialTaskCompletionStatus, lock_version: this.lockVersion, }); @@ -222,9 +223,6 @@ export default { formState() { return this.store.formState; }, - hasUpdated() { - return Boolean(this.state.updatedAt); - }, issueChanged() { const { store: { @@ -379,7 +377,7 @@ export default { this.showForm = false; }, - updateIssuable() { + async updateIssuable() { this.setFormState({ updateLoading: true }); const { @@ -392,6 +390,14 @@ export default { this.alert?.dismiss(); + if (containsSensitiveToken(issuablePayload.description)) { + const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt); + if (!confirmed) { + this.setFormState({ updateLoading: false }); + return false; + } + } + return this.service .updateIssuable(issuablePayload) .then((res) => res.data) @@ -557,7 +563,6 @@ export default { :description-html="state.descriptionHtml" :description-text="state.descriptionText" :updated-at="state.updatedAt" - :task-status="state.taskStatus" :issuable-type="issuableType" :update-url="updateEndpoint" :lock-version="state.lock_version" @@ -570,7 +575,7 @@ export default { /> <edited-component - v-if="hasUpdated" + :task-completion-status="state.taskCompletionStatus" :updated-at="state.updatedAt" :updated-by-name="state.updatedByName" :updated-by-path="state.updatedByPath" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index bdee6c5fe9a..3721f224d5e 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,6 +1,5 @@ <script> import { GlToast } from '@gitlab/ui'; -import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; @@ -59,11 +58,6 @@ export default { required: false, default: '', }, - taskStatus: { - type: String, - required: false, - default: '', - }, issuableType: { type: String, required: false, @@ -138,7 +132,10 @@ export default { }, watch: { descriptionHtml(newDescription, oldDescription) { - if (!this.initialUpdate && newDescription !== oldDescription) { + if ( + !this.initialUpdate && + this.stripClientState(newDescription) !== this.stripClientState(oldDescription) + ) { this.animateChange(); } else { this.initialUpdate = false; @@ -148,16 +145,12 @@ export default { this.renderGFM(); }); }, - taskStatus() { - this.updateTaskStatusText(); - }, }, mounted() { eventHub.$on('convert-task-list-item', this.convertTaskListItem); eventHub.$on('delete-task-list-item', this.deleteTaskListItem); this.renderGFM(); - this.updateTaskStatusText(); }, beforeDestroy() { eventHub.$off('convert-task-list-item', this.convertTaskListItem); @@ -282,24 +275,6 @@ export default { this.$emit('taskListUpdateFailed'); }, - updateTaskStatusText() { - const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); - const $issuableHeader = $('.issuable-meta'); - const $tasks = $('#task_status', $issuableHeader); - const $tasksShort = $('#task_status_short', $issuableHeader); - - if (taskRegexMatches) { - $tasks.text(this.taskStatus); - $tasksShort.text( - `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${ - taskRegexMatches[2] > 1 ? 's' : '' - }`, - ); - } else { - $tasks.text(''); - $tasksShort.text(''); - } - }, createTaskListItemActions(provide) { const app = new Vue({ el: document.createElement('div'), @@ -349,6 +324,9 @@ export default { listItem.append(element); } }, + stripClientState(description) { + return description.replaceAll('<details open="true">', '<details>'); + }, async createTask({ taskTitle, taskDescription, oldDescription }) { try { const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription); diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 5138a4530e9..6a0edb59b65 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,13 +1,20 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { n__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { TimeAgoTooltip, + GlLink, GlSprintf, }, props: { + taskCompletionStatus: { + type: Object, + required: false, + default: () => ({}), + }, updatedAt: { type: String, required: false, @@ -25,36 +32,61 @@ export default { }, }, computed: { + completedCount() { + return this.taskCompletionStatus.completed_count; + }, + count() { + return this.taskCompletionStatus.count; + }, hasUpdatedBy() { return this.updatedByName && this.updatedByPath; }, + showCheck() { + return this.completedCount === this.count; + }, + taskStatus() { + const { completedCount, count } = this; + if (!count) { + return undefined; + } + + return sprintf( + n__( + '%{completedCount} of %{count} checklist item completed', + '%{completedCount} of %{count} checklist items completed', + count, + ), + { completedCount, count }, + ); + }, }, }; </script> <template> <small class="edited-text js-issue-widgets"> - <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')"> - <template #timeago> - <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> - </template> - </gl-sprintf> - <gl-sprintf v-else-if="!updatedAt" :message="__('Edited by %{author}')"> - <template #author> - <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline"> - <span>{{ updatedByName }}</span> - </a> - </template> - </gl-sprintf> - <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')"> - <template #timeago> - <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> - </template> - <template #author> - <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline"> - <span>{{ updatedByName }}</span> - </a> - </template> - </gl-sprintf> + <template v-if="taskStatus"> + <template v-if="showCheck">✓</template> + {{ taskStatus }} + <template v-if="updatedAt">·</template> + </template> + + <template v-if="updatedAt"> + <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')"> + <template #timeago> + <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')"> + <template #timeago> + <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> + </template> + <template #author> + <gl-link :href="updatedByPath" class="gl-font-sm gl-hover-text-gray-900 gl-text-gray-700"> + {{ updatedByName }} + </gl-link> + </template> + </gl-sprintf> + </template> </small> </template> 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 index 243666b2323..8267c0130a3 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -199,7 +199,7 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> - <gl-form-group v-if="glFeatures.incidentEventTags"> + <gl-form-group> <label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags"> {{ $options.i18n.tagsLabel }} <timeline-events-tags-popover /> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue index 7c6ff002014..373c5970e64 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue @@ -42,8 +42,8 @@ export default { }, mounted() { this.gitlabBasePath = retrieveBaseUrl(); - setApiBaseURL(this.gitlabBasePath); if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) { + setApiBaseURL(this.gitlabBasePath); this.showSetupInstructions = true; } }, diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue index 2018942a7e8..1c7ba1d331b 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue @@ -45,7 +45,9 @@ export default { data-testid="artifacts-remove-timeline" > <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span> - <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span> + <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{ + s__('Job|The artifacts will be removed') + }}</span> <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> <gl-link :href="helpUrl" @@ -53,11 +55,11 @@ export default { rel="noopener noreferrer nofollow" data-testid="artifact-expired-help-link" > - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </p> <p v-else-if="isLocked" class="build-detail-row"> - <span data-testid="job-locked-message">{{ + <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content">{{ s__( 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', ) 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 3d87cea6445..ff7982319e7 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -19,7 +19,7 @@ export default { loadingAriaLabel: __('Loading'), }, filterSearchBoxStyles: - 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b', + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100', components: { GlAlert, GlSkeletonLoader, @@ -140,6 +140,19 @@ export default { this.infiniteScrollingTriggered = false; this.filterSearchTriggered = true; + // all filters have been cleared reset query param + // and refetch jobs/count with defaults + if (!filters.length) { + updateHistory({ + url: setUrlParams({ statuses: null }, window.location.href, true), + }); + + this.$apollo.queries.jobs.refetch({ statuses: null }); + this.$apollo.queries.jobsCount.refetch({ statuses: null }); + + return; + } + // Eventually there will be more tokens available // this code is written to scale for those tokens filters.forEach((filter) => { @@ -223,7 +236,7 @@ export default { <jobs-table-empty-state v-else-if="showEmptyState" /> - <jobs-table v-else :jobs="jobs.list" /> + <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" /> <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> <gl-loading-icon diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue index 2be404de1e1..904e1ba9a47 100644 --- a/app/assets/javascripts/labels/components/delete_label_modal.vue +++ b/app/assets/javascripts/labels/components/delete_label_modal.vue @@ -81,14 +81,9 @@ export default { </gl-sprintf> <template #modal-footer> <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button> - <gl-button - category="primary" - variant="danger" - :href="destroyPath" - data-method="delete" - data-testid="delete-button" - >{{ __('Delete label') }}</gl-button - > + <gl-button category="primary" variant="danger" :href="destroyPath" data-method="delete">{{ + __('Delete label') + }}</gl-button> </template> </gl-modal> </template> diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js index 60ab0c92256..fa0104fcf12 100644 --- a/app/assets/javascripts/labels/create_label_dropdown.js +++ b/app/assets/javascripts/labels/create_label_dropdown.js @@ -1,5 +1,3 @@ -/* eslint-disable func-names */ - import $ from 'jquery'; import Api from '~/api'; import { humanize } from '~/lib/utils/text_utility'; @@ -49,6 +47,7 @@ export default class CreateLabelDropdown { addBinding() { const self = this; + // eslint-disable-next-line func-names this.$colorSuggestions.on('click', function (e) { const $this = $(this); self.addColorValue(e, $this); diff --git a/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js new file mode 100644 index 00000000000..5d2a002bf85 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js @@ -0,0 +1,97 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable class-methods-use-this */ +import { db } from './local_db'; + +/** + * IndexedDB implementation of apollo-cache-persist [PersistentStorage][1] + * + * [1]: https://github.com/apollographql/apollo-cache-persist/blob/d536c741d1f2828a0ef9abda343a9186dd8dbff2/src/types/index.ts#L15 + */ +export class IndexedDBPersistentStorage { + static async create() { + await db.open(); + + return new IndexedDBPersistentStorage(); + } + + async getItem(queryId) { + const resultObj = {}; + const selectedQuery = await db.table('queries').get(queryId); + const tableNames = new Set(db.tables.map((table) => table.name)); + + if (selectedQuery) { + resultObj.ROOT_QUERY = selectedQuery; + + const lookupTable = []; + + const parseObjectsForRef = async (selObject) => { + const ops = Object.values(selObject).map(async (child) => { + if (!child) { + return; + } + + if (child.__ref) { + const pathId = child.__ref; + const [refType, ...refKeyParts] = pathId.split(':'); + const refKey = refKeyParts.join(':'); + + if ( + !resultObj[pathId] && + !lookupTable.includes(pathId) && + tableNames.has(refType.toLowerCase()) + ) { + lookupTable.push(pathId); + const selectedEntity = await db.table(refType.toLowerCase()).get(refKey); + if (selectedEntity) { + await parseObjectsForRef(selectedEntity); + resultObj[pathId] = selectedEntity; + } + } + } else if (typeof child === 'object') { + await parseObjectsForRef(child); + } + }); + + return Promise.all(ops); + }; + + await parseObjectsForRef(resultObj.ROOT_QUERY); + } + + return resultObj; + } + + async setItem(key, value) { + await this.#setQueryResults(key, JSON.parse(value)); + } + + async removeItem() { + // apollo-cache-persist only ever calls this when we're removing everything, so let's blow it all away + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113745#note_1329175993 + + await Promise.all( + db.tables.map((table) => { + return table.clear(); + }), + ); + } + + async #setQueryResults(queryId, results) { + await Promise.all( + Object.keys(results).map((id) => { + const objectType = id.split(':')[0]; + if (objectType === 'ROOT_QUERY') { + return db.table('queries').put(results[id], queryId); + } + const key = objectType.toLowerCase(); + const tableExists = db.tables.some((table) => table.name === key); + if (tableExists) { + return db.table(key).put(results[id], id); + } + return new Promise((resolve) => { + resolve(); + }); + }), + ); + } +} diff --git a/app/assets/javascripts/lib/apollo/local_db.js b/app/assets/javascripts/lib/apollo/local_db.js new file mode 100644 index 00000000000..cda30ff9d42 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/local_db.js @@ -0,0 +1,14 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import Dexie from 'dexie'; + +export const db = new Dexie('GLLocalCache'); +db.version(1).stores({ + pages: 'url, timestamp', + queries: '', + project: 'id', + group: 'id', + usercore: 'id', + issue: 'id, state, title', + label: 'id, title', + milestone: 'id', +}); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c0e923b2670..2e6fcbea80d 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,7 +1,7 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; -import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist'; +import { persistCache } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import possibleTypes from '~/graphql_shared/possible_types.json'; @@ -53,6 +53,15 @@ export const typePolicies = { TreeEntry: { keyFields: ['webPath'], }, + Subscription: { + fields: { + aiCompletionResponse: { + read(value) { + return value ?? null; + }, + }, + }, + }, }; export const stripWhitespaceFromQuery = (url, path) => { @@ -104,7 +113,7 @@ Object.defineProperty(window, 'pendingApolloRequests', { }, }); -export default (resolvers = {}, config = {}) => { +function createApolloClient(resolvers = {}, config = {}) { const { baseUrl, batchMax = 10, @@ -113,7 +122,6 @@ export default (resolvers = {}, config = {}) => { typeDefs, path = '/api/graphql', useGet = false, - localCacheKey = null, } = config; let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -171,6 +179,7 @@ export default (resolvers = {}, config = {}) => { config: { url: httpResponse.url, operationName: operation.operationName, + method: operation.getContext()?.fetchOptions?.method || 'POST', // If method is not explicitly set, we default to POST request }, headers: { 'x-request-id': httpResponse.headers.get('x-request-id'), @@ -237,16 +246,6 @@ export default (resolvers = {}, config = {}) => { }, }); - if (localCacheKey) { - persistCacheSync({ - cache: newCache, - // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode - debug: process.env.NODE_ENV === 'development', - storage: new LocalStorageWrapper(window.localStorage), - persistenceMapper, - }); - } - ac = new ApolloClient({ typeDefs, link: appLink, @@ -262,5 +261,42 @@ export default (resolvers = {}, config = {}) => { acs.push(ac); - return ac; + return { client: ac, cache: newCache }; +} + +export async function createApolloClientWithCaching(resolvers = {}, config = {}) { + const { localCacheKey = null } = config; + const { client, cache } = createApolloClient(resolvers, config); + + if (localCacheKey) { + let storage; + + // Test that we can use IndexedDB. If not, no persisting for you! + try { + const { IndexedDBPersistentStorage } = await import( + /* webpackChunkName: 'indexed_db_persistent_storage' */ './apollo/indexed_db_persistent_storage' + ); + + storage = await IndexedDBPersistentStorage.create(); + } catch (error) { + return client; + } + + await persistCache({ + cache, + // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode + debug: process.env.NODE_ENV === 'development', + storage, + key: localCacheKey, + persistenceMapper, + }); + } + + return client; +} + +export default (resolvers = {}, config = {}) => { + const { client } = createApolloClient(resolvers, config); + + return client; }; diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js index c72561ce69d..60b46989375 100644 --- a/app/assets/javascripts/lib/mermaid.js +++ b/app/assets/javascripts/lib/mermaid.js @@ -12,8 +12,9 @@ const drawDiagram = (source) => { // eslint-disable-next-line no-unsanitized/property element.innerHTML = svgCode; - const height = parseInt(element.firstElementChild.getAttribute('height'), 10); - const width = parseInt(element.firstElementChild.style.maxWidth, 10); + element.firstElementChild.removeAttribute('height'); + const { height, width } = element.firstElementChild.getBoundingClientRect(); + setIframeRenderedSize(height, width); }; mermaid.mermaidAPI.render('mermaid', source, insertSvg); diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 7da3bab0a4b..520d7f627f6 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -1,3 +1,6 @@ +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { __ } from '~/locale'; + const commonTooltips = () => ({ mode: 'x', intersect: false, @@ -98,3 +101,38 @@ export const firstAndLastY = (data) => { return [firstY, lastY]; }; + +const toolboxIconSvgPath = async (name) => { + return `path://${await getSvgIconPathContent(name)}`; +}; + +export const getToolboxOptions = async () => { + const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath); + + try { + const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises); + + return { + toolbox: { + feature: { + dataZoom: { + icon: { zoom: marqueeSelectionPath, back: redoPath }, + }, + restore: { + icon: repeatPath, + }, + saveAsImage: { + icon: downloadPath, + }, + }, + }, + }; + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(__('SVG could not be rendered correctly: '), e); + } + + return {}; + } +}; diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js new file mode 100644 index 00000000000..64c77bf1080 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js @@ -0,0 +1,13 @@ +import { stringifyTime, parseSeconds } from './date_format_utility'; + +/** + * Formats seconds into a human readable value of elapsed time, + * optionally limiting it to hours. + * @param {Number} seconds Seconds to format + * @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours + * @return {String} Provided seconds in human readable elapsed time format + */ +export const formatTimeSpent = (seconds, limitToHours) => { + const negative = seconds < 0; + return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours })); +}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index c1081239544..f9a70371680 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -2,3 +2,4 @@ export * from './datetime/timeago_utility'; export * from './datetime/date_format_utility'; export * from './datetime/date_calculation_utility'; export * from './datetime/pikaday_utility'; +export * from './datetime/time_spent_utility'; diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js index 4cea4257e7b..febf83a4d38 100644 --- a/app/assets/javascripts/lib/utils/error_message.js +++ b/app/assets/javascripts/lib/utils/error_message.js @@ -1,20 +1,15 @@ -export const USER_FACING_ERROR_MESSAGE_PREFIX = 'UF:'; - -const getMessageFromError = (error = '') => { - return error.message || error; -}; - -export const parseErrorMessage = (error = '') => { - const messageString = getMessageFromError(error); - - if (messageString.startsWith(USER_FACING_ERROR_MESSAGE_PREFIX)) { - return { - message: messageString.replace(USER_FACING_ERROR_MESSAGE_PREFIX, '').trim(), - userFacing: true, - }; - } - return { - message: messageString, - userFacing: false, - }; +/** + * Utility to parse an error object returned from API. + * + * + * @param { Object } error - An error object directly from API response + * @param { string } error.message - The error message, returned from API. + * @param { string } defaultMessage - Default user-facing error message + * @returns { string } - A transformed user-facing error message, or defaultMessage + */ +export const parseErrorMessage = (error = {}, defaultMessage = '') => { + const messageString = error.message || ''; + return messageString.startsWith(window.gon.uf_error_prefix) + ? messageString.replace(window.gon.uf_error_prefix, '').trim() + : defaultMessage; }; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index bd47f10b3ac..7cfcd11ece9 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,3 +1,7 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; export const BACKSPACE_KEY = 'Backspace'; +export const ARROW_DOWN_KEY = 'ArrowDown'; +export const ARROW_UP_KEY = 'ArrowUp'; +export const END_KEY = 'End'; +export const HOME_KEY = 'Home'; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js new file mode 100644 index 00000000000..2807911c9bb --- /dev/null +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -0,0 +1,45 @@ +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { s__, __ } from '~/locale'; + +export const i18n = { + defaultPrompt: s__( + 'SecretDetection|This comment appears to have a token in it. Are you sure you want to add it?', + ), + descriptionPrompt: s__( + 'SecretDetection|This description appears to have a token in it. Are you sure you want to add it?', + ), + primaryBtnText: __('Proceed'), +}; + +const sensitiveDataPatterns = [ + { + name: 'GitLab Personal Access Token', + regex: 'glpat-[0-9a-zA-Z_-]{20}', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Feed Token', + regex: 'feed_token=[0-9a-zA-Z_-]{20}', + }, +]; + +export const containsSensitiveToken = (message) => { + for (const rule of sensitiveDataPatterns) { + const regex = new RegExp(rule.regex, 'gi'); + if (regex.test(message)) { + return true; + } + } + return false; +}; + +export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) { + const confirmed = await confirmAction(prompt, { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }); + if (!confirmed) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 2d5e9bc91f2..a2873622682 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -371,7 +371,7 @@ export function insertMarkdownText({ }); } -function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { +export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { const $textArea = $(textArea); textArea = $textArea.get(0); const text = $textArea.val(); @@ -627,10 +627,9 @@ export function addMarkdownListeners(form) { }); const $allToolbarBtns = $(form) - .off('click', '.js-md, .saved-replies-dropdown li') - .on('click', '.js-md, .saved-replies-dropdown li', function () { - const $savedReplyContent = $('.js-saved-reply-content', this); - const $toolbarBtn = $savedReplyContent.length ? $savedReplyContent : $(this); + .off('click', '.js-md') + .on('click', '.js-md', function () { + const $toolbarBtn = $(this); return updateTextForToolbarBtn($toolbarBtn); }); diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js new file mode 100644 index 00000000000..fd08d34a80e --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import { createApolloProvider } from '@vue/apollo-option'; +import { ApolloMutation } from '@vue/apollo-components'; + +export { ApolloMutation }; + +const installed = new WeakMap(); + +function callLifecycle(hookName, ...extraArgs) { + const { GITLAB_INTERNAL_ADDED_MIXINS: addedMixins } = this.$; + if (!addedMixins) { + return []; + } + + return addedMixins.map((m) => m[hookName]?.apply(this, extraArgs)); +} + +function createMixinForLateInit({ install, shouldInstall }) { + return { + created() { + callLifecycle.call(this, 'created'); + }, + // @vue/compat normalizez lifecycle hook names so there is no error here + destroyed() { + callLifecycle.call(this, 'unmounted'); + }, + + data(...args) { + const extraData = callLifecycle.call(this, 'data', ...args); + if (!extraData.length) { + return {}; + } + + return Object.assign({}, ...extraData); + }, + + beforeCreate() { + if (shouldInstall(this)) { + const { mixins } = this.$.appContext; + const globalMixinsBeforeInit = new Set(mixins); + install(this); + + this.$.GITLAB_INTERNAL_ADDED_MIXINS = mixins.filter((m) => !globalMixinsBeforeInit.has(m)); + + callLifecycle.call(this, 'beforeCreate'); + } + }, + }; +} + +export default class VueCompatApollo { + constructor(...args) { + // eslint-disable-next-line no-constructor-return + return createApolloProvider(...args); + } + + static install() { + Vue.mixin( + createMixinForLateInit({ + shouldInstall: (vm) => + vm.$options.apolloProvider && + !installed.get(vm.$.appContext.app)?.has(vm.$options.apolloProvider), + install: (vm) => { + const { app } = vm.$.appContext; + const { apolloProvider } = vm.$options; + + if (!installed.has(app)) { + installed.set(app, new WeakSet()); + } + + installed.get(app).add(apolloProvider); + + vm.$.appContext.app.use(vm.$options.apolloProvider); + }, + }), + ); + } +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js new file mode 100644 index 00000000000..006ed920ef0 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js @@ -0,0 +1,105 @@ +import Vue from 'vue'; +import { + createRouter, + createMemoryHistory, + createWebHistory, + createWebHashHistory, +} from 'vue-router-vue3'; + +const mode = (value, options) => { + if (!value) return null; + let history; + // eslint-disable-next-line default-case + switch (value) { + case 'history': + history = createWebHistory(options.base); + break; + case 'hash': + history = createWebHashHistory(); + break; + case 'abstract': + history = createMemoryHistory(); + break; + } + return { history }; +}; + +const base = () => null; + +const toNewCatchAllPath = (path) => { + if (path === '*') return '/:pathMatch(.*)*'; + return path; +}; + +const routes = (value) => { + if (!value) return null; + const newRoutes = value.reduce(function handleRoutes(acc, route) { + const newRoute = { + ...route, + path: toNewCatchAllPath(route.path), + }; + if (route.children) { + newRoute.children = route.children.reduce(handleRoutes, []); + } + acc.push(newRoute); + return acc; + }, []); + return { routes: newRoutes }; +}; + +const scrollBehavior = (value) => { + return { + scrollBehavior(...args) { + const { x, y, left, top } = value(...args); + return { left: x || left, top: y || top }; + }, + }; +}; + +const transformers = { + mode, + base, + routes, + scrollBehavior, +}; + +const transformOptions = (options) => { + const defaultConfig = { + routes: null, + history: createWebHashHistory(), + }; + return Object.keys(options).reduce((acc, key) => { + const value = options[key]; + if (key in transformers) { + Object.assign(acc, transformers[key](value, options)); + } else { + acc[key] = value; + } + return acc; + }, defaultConfig); +}; + +const installed = new WeakMap(); + +export default class VueRouterCompat { + constructor(options) { + // eslint-disable-next-line no-constructor-return + return createRouter(transformOptions(options)); + } + + static install() { + Vue.mixin({ + beforeCreate() { + const { app } = this.$.appContext; + const { router } = this.$options; + if (router && !installed.get(app)?.has(router)) { + if (!installed.has(app)) { + installed.set(app, new WeakSet()); + } + installed.get(app).add(router); + this.$.appContext.app.use(this.$options.router); + } + }, + }); + } +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vuex.js b/app/assets/javascripts/lib/utils/vue3compat/vuex.js new file mode 100644 index 00000000000..ff94ff3d04a --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vuex.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { + createStore, + mapState, + mapGetters, + mapActions, + mapMutations, + createNamespacedHelpers, +} from 'vuex-vue3'; + +export { mapState, mapGetters, mapActions, mapMutations, createNamespacedHelpers }; + +const installedStores = new WeakMap(); + +export default { + Store: class VuexCompatStore { + constructor(...args) { + // eslint-disable-next-line no-constructor-return + return createStore(...args); + } + }, + + install() { + Vue.mixin({ + beforeCreate() { + const { app } = this.$.appContext; + const { store } = this.$options; + if (store && !installedStores.get(app)?.has(store)) { + if (!installedStores.has(app)) { + installedStores.set(app, new WeakSet()); + } + installedStores.get(app).add(store); + this.$.appContext.app.use(this.$options.store); + } + }, + }); + }, +}; diff --git a/app/assets/javascripts/lib/utils/web_ide_navigator.js b/app/assets/javascripts/lib/utils/web_ide_navigator.js new file mode 100644 index 00000000000..f0579b5886d --- /dev/null +++ b/app/assets/javascripts/lib/utils/web_ide_navigator.js @@ -0,0 +1,24 @@ +import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility'; + +/** + * Takes a project path and optional file path and branch + * and then redirects the user to the web IDE. + * + * @param {string} projectPath - Full path to project including namespace (ex. flightjs/Flight) + * @param {string} filePath - optional path to file to be edited, otherwise will open at base directory (ex. README.md) + * @param {string} branch - optional branch to open the IDE, defaults to 'main' + */ + +export const openWebIDE = (projectPath, filePath, branch = 'main') => { + if (!projectPath) { + throw new TypeError('projectPath parameter is required'); + } + + const pathnameSegments = [projectPath, 'edit', branch, '-']; + + if (filePath) { + pathnameSegments.push(filePath); + } + + visitUrl(webIDEUrl(`/${pathnameSegments.join('/')}/`)); +}; diff --git a/app/assets/javascripts/locale/ensure_single_line.cjs b/app/assets/javascripts/locale/ensure_single_line.cjs index c2c63777001..f7790cadc48 100644 --- a/app/assets/javascripts/locale/ensure_single_line.cjs +++ b/app/assets/javascripts/locale/ensure_single_line.cjs @@ -1,5 +1,3 @@ -/* eslint-disable import/no-commonjs */ - const SPLIT_REGEX = /\s*[\r\n]+\s*/; /** diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a1539aba786..fd002e29afc 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -89,9 +89,11 @@ initRails(); function deferredInitialisation() { const $body = $('body'); - if (!gon.use_new_navigation) initTopNav(); + if (!gon.use_new_navigation) { + initTopNav(); + initTodoToggle(); + } initBreadcrumbs(); - initTodoToggle(); initPrefetchLinks('.js-prefetch-document'); initLogoAnimation(); initServicePingConsent(); diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index 76b286f94ad..685482a76de 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,5 +1,6 @@ <script> import { mapState } from 'vuex'; +import { __ } from '~/locale'; import { getParameterByName, setUrlParams, @@ -46,6 +47,10 @@ export default { return false; } + if (token.type === 'user_type' && !gon.features?.serviceAccountsCrud) { + return false; + } + return this.filteredSearchBar.tokens?.includes(token.type); }); }, @@ -94,6 +99,14 @@ export default { }; } } else { + // Remove this block after this issue is closed: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159 + if (value.data === __('Service account')) { + return { + ...accumulator, + [type]: 'service_account', + }; + } + return { ...accumulator, [type]: value.data, diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index e066b023fbb..a85bb09e17b 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -81,19 +81,18 @@ export default { return; } - this.updateMemberRole({ - memberId: this.member.id, - accessLevel: { integerValue: newRoleValue, stringValue: newRoleName }, - }) - .then(() => { - this.$toast.show(s__('Members|Role updated successfully.')); - }) - .catch((error) => { - Sentry.captureException(error); - }) - .finally(() => { - this.busy = false; + try { + await this.updateMemberRole({ + memberId: this.member.id, + accessLevel: { integerValue: newRoleValue, stringValue: newRoleName }, }); + + this.$toast.show(s__('Members|Role updated successfully.')); + } catch (error) { + Sentry.captureException(error); + } finally { + this.busy = false; + } }, }, }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index d55e942dafa..124b14a9845 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -13,7 +13,7 @@ import axios from './lib/utils/axios_utils'; import { localTimeAgo } from './lib/utils/datetime_utility'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; -import { __ } from './locale'; +import { __, s__ } from './locale'; import syntaxHighlight from './syntax_highlight'; // MergeRequestTabs @@ -316,7 +316,7 @@ export default class MergeRequestTabs { }) .catch(() => { toggleLoader(false); - createAlert({ message: __('MergeRequest|Failed to load the page') }); + createAlert({ message: s__('MergeRequest|Failed to load the page') }); }); } diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js index f90fdb04923..9d210f7a6ec 100644 --- a/app/assets/javascripts/milestones/index.js +++ b/app/assets/javascripts/milestones/index.js @@ -64,8 +64,6 @@ export function initDeleteMilestoneModal() { if (!successful) { button.removeAttribute('disabled'); } - - button.querySelector('.js-loading-icon').classList.add('hidden'); }; const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); @@ -75,7 +73,6 @@ export function initDeleteMilestoneModal() { `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, ); button.setAttribute('disabled', ''); - button.querySelector('.js-loading-icon').classList.remove('hidden'); eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); }; diff --git a/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue new file mode 100644 index 00000000000..4c0f99cf62c --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue @@ -0,0 +1,98 @@ +<script> +import { + GlModal, + GlDropdown, + GlTooltipDirective, + GlDropdownItem, + GlModalDirective, +} from '@gitlab/ui'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlModal, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + props: { + deletePath: { + type: String, + required: true, + }, + deleteConfirmationText: { + type: String, + required: true, + }, + actionPrimaryText: { + type: String, + required: true, + }, + modalTitle: { + type: String, + required: true, + }, + }, + data() { + return { + isDeleteModalVisible: false, + modal: { + id: 'ml-experiments-delete-modal', + deleteConfirmation: this.deleteConfirmationText, + actionPrimary: { + text: this.actionPrimaryText, + attributes: { variant: 'danger' }, + }, + actionCancel: { + text: __('Cancel'), + }, + }, + }; + }, + methods: { + confirmDelete() { + this.$refs.deleteForm.submit(); + }, + }, + csrf, +}; +</script> + +<template> + <gl-dropdown + right + category="tertiary" + :aria-label="__('More actions')" + icon="ellipsis_v" + no-caret + > + <gl-dropdown-item + v-gl-modal-directive="modal.id" + :aria-label="actionPrimaryText" + variant="danger" + > + {{ actionPrimaryText }} + + <form ref="deleteForm" method="post" :action="deletePath"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + </form> + + <gl-modal + :modal-id="modal.id" + :title="modalTitle" + :action-primary="modal.actionPrimary" + :action-cancel="modal.actionCancel" + @primary="confirmDelete" + > + <p> + {{ deleteConfirmationText }} + </p> + </gl-modal> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue index 3c765de92a2..23b58543f11 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue @@ -2,6 +2,7 @@ import { GlLink } from '@gitlab/ui'; import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants'; import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import { TITLE_LABEL, INFO_LABEL, @@ -12,12 +13,16 @@ import { PARAMETERS_LABEL, METRICS_LABEL, METADATA_LABEL, + DELETE_CANDIDATE_CONFIRMATION_MESSAGE, + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, + DELETE_CANDIDATE_MODAL_TITLE, } from './translations'; export default { name: 'MlCandidatesShow', components: { IncubationAlert, + DeleteButton, GlLink, }, props: { @@ -36,6 +41,9 @@ export default { PARAMETERS_LABEL, METRICS_LABEL, METADATA_LABEL, + DELETE_CANDIDATE_CONFIRMATION_MESSAGE, + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, + DELETE_CANDIDATE_MODAL_TITLE, }, computed: { sections() { @@ -67,11 +75,22 @@ export default { :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" /> - <h3> - {{ $options.i18n.TITLE_LABEL }} - </h3> + <div class="detail-page-header gl-flex-wrap"> + <div class="detail-page-header-body"> + <h1 class="page-title gl-font-size-h-display flex-fill"> + {{ $options.i18n.TITLE_LABEL }} + </h1> - <table class="candidate-details"> + <delete-button + :delete-path="candidate.info.path" + :delete-confirmation-text="$options.i18n.DELETE_CANDIDATE_CONFIRMATION_MESSAGE" + :action-primary-text="$options.i18n.DELETE_CANDIDATE_PRIMARY_ACTION_LABEL" + :modal-title="$options.i18n.DELETE_CANDIDATE_MODAL_TITLE" + /> + </div> + </div> + + <table class="candidate-details gl-w-full"> <tbody> <tr class="divider"></tr> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js index caad145873e..5f7714aa0c0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js @@ -9,3 +9,8 @@ export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts'); export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters'); export const METRICS_LABEL = s__('MlExperimentTracking|Metrics'); export const METADATA_LABEL = s__('MlExperimentTracking|Metadata'); +export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( + 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', +); +export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); +export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue new file mode 100644 index 00000000000..92c662fedf1 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue @@ -0,0 +1,47 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import { __ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; + +export default { + name: 'ExperimentHeader', + components: { + DeleteButton, + GlButton, + }, + props: { + title: { + type: String, + required: true, + }, + deleteInfo: { + type: Object, + required: true, + }, + }, + methods: { + downloadCsv() { + const currentPath = window.location.pathname; + const currentSearch = window.location.search; + + visitUrl(`${currentPath}.csv${currentSearch}`); + }, + }, + i18n: { + downloadAsCsvLabel: __('Download as CSV'), + }, +}; +</script> + +<template> + <div class="detail-page-header gl-flex-wrap"> + <div class="detail-page-header-body"> + <h1 class="page-title gl-font-size-h-display flex-fill">{{ title }}</h1> + + <gl-button @click="downloadCsv">{{ $options.i18n.downloadAsCsvLabel }}</gl-button> + + <delete-button v-bind="deleteInfo" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue index ca0a42fda10..acb5fc7cad2 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue @@ -12,6 +12,7 @@ import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue'; import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import ExperimentHeader from './components/experiment_header.vue'; import { LIST_KEY_CREATED_AT, BASE_SORT_FIELDS, @@ -30,8 +31,13 @@ export default { IncubationAlert, RegistrySearch, KeysetPagination, + ExperimentHeader, }, props: { + experiment: { + type: Object, + required: true, + }, candidates: { type: Array, required: true, @@ -124,6 +130,14 @@ export default { hasItems() { return this.candidates.length > 0; }, + deleteButtonInfo() { + return { + deletePath: this.experiment.path, + deleteConfirmationText: translations.DELETE_EXPERIMENT_CONFIRMATION_MESSAGE, + actionPrimaryText: translations.DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL, + modalTitle: translations.DELETE_EXPERIMENT_MODAL_TITLE, + }; + }, }, methods: { submitFilters() { @@ -157,6 +171,8 @@ export default { :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE" /> + <experiment-header :title="experiment.name" :delete-info="deleteButtonInfo" /> + <registry-search :filters="filters" :sorting="sorting" diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js index 63b0d902b72..5c34a66921d 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js @@ -14,3 +14,8 @@ export const EMPTY_STATE_DESCRIPTION_LABEL = s__( 'MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client.', ); export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No candidates'); +export const DELETE_EXPERIMENT_CONFIRMATION_MESSAGE = s__( + 'MlExperimentTracking|Deleting this experiment will also delete its candidates and their associated metadata.', +); +export const DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete experiment'); +export const DELETE_EXPERIMENT_MODAL_TITLE = s__('MLExperimentTracking|Delete experiment?'); diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 4fdc08487f2..32e85262882 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -40,7 +40,7 @@ function prometheusMetricQueryParams(timeRange) { * Extract error messages from API or HTTP request errors. * * - API errors are in `error.response.data.message` - * - HTTP (axios) errors are in `error.messsage` + * - HTTP (axios) errors are in `error.message` * * @param {Object} error * @returns {String} User friendly error message diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 0d849e1a2d8..5f4d2703d21 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -97,7 +97,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => { */ const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show'); -/* eslint-disable @gitlab/require-i18n-strings */ /** * Tracks snowplow event when user generates link to metric chart * @param {String} chart link that will be sent as a property for the event @@ -107,13 +106,13 @@ export const generateLinkToChartOptions = (chartLink) => { const isCLusterHealthBoard = isClusterHealthBoard(); const category = isCLusterHealthBoard - ? 'Cluster Monitoring' + ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings : 'Incident Management::Embedded metrics'; const action = isCLusterHealthBoard ? 'generate_link_to_cluster_metric_chart' : 'generate_link_to_metrics_chart'; - return { category, action, label: 'Chart link', property: chartLink }; + return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings }; /** @@ -125,13 +124,13 @@ export const downloadCSVOptions = (title) => { const isCLusterHealthBoard = isClusterHealthBoard(); const category = isCLusterHealthBoard - ? 'Cluster Monitoring' + ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings : 'Incident Management::Embedded metrics'; const action = isCLusterHealthBoard ? 'download_csv_of_cluster_metric_chart' : 'download_csv_of_metrics_dashboard_chart'; - return { category, action, label: 'Chart title', property: title }; + return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings }; /* eslint-enable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 2488c8aee9c..d537117d962 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -41,7 +41,7 @@ export default () => { apolloProvider, provide: { reportAbusePath: notesDataset.reportAbusePath, - newSavedRepliesPath: notesDataset.savedRepliesNewPath, + newCommentTemplatePath: notesDataset.newCommentTemplatePath, }, data() { const noteableData = JSON.parse(notesDataset.noteableData); diff --git a/app/assets/javascripts/mr_notes/stores/drawer/actions.js b/app/assets/javascripts/mr_notes/stores/drawer/actions.js new file mode 100644 index 00000000000..4f8c3bb7f20 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/actions.js @@ -0,0 +1,5 @@ +import * as types from './mutation_types'; + +export const setDrawer = ({ commit }, data) => { + return commit(types.default.SET_DRAWER, data); +}; diff --git a/app/assets/javascripts/mr_notes/stores/drawer/getters.js b/app/assets/javascripts/mr_notes/stores/drawer/getters.js new file mode 100644 index 00000000000..dd61bc900fa --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/getters.js @@ -0,0 +1 @@ +export const activeDrawer = (state) => state.activeDrawer; diff --git a/app/assets/javascripts/mr_notes/stores/drawer/index.js b/app/assets/javascripts/mr_notes/stores/drawer/index.js new file mode 100644 index 00000000000..c4a7eacb78a --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/index.js @@ -0,0 +1,13 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +export default () => ({ + namespaced: true, + state: { + activeDrawer: {}, + }, + mutations, + actions, + getters, +}); diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js new file mode 100644 index 00000000000..5fe8a9ba20d --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js @@ -0,0 +1,3 @@ +export default { + SET_DRAWER: 'SET_DRAWER', +}; diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutations.js b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js new file mode 100644 index 00000000000..eee43f2b316 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js @@ -0,0 +1,7 @@ +import types from './mutation_types'; + +export default { + [types.SET_DRAWER](state, drawer) { + Object.assign(state, { activeDrawer: drawer }); + }, +}; diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js index 7527c685c71..1f8e61beff0 100644 --- a/app/assets/javascripts/mr_notes/stores/index.js +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -4,6 +4,7 @@ import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments' import diffsModule from '~/diffs/store/modules'; import notesModule from '~/notes/stores/modules'; import mrPageModule from './modules'; +import findingsDrawer from './drawer'; Vue.use(Vuex); @@ -12,6 +13,7 @@ export const createModules = () => ({ notes: notesModule(), diffs: diffsModule(), batchComments: batchCommentsModule(), + findingsDrawer: findingsDrawer(), }); export const createStore = () => diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index ca6232fa4c4..5eb5e5b9b90 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -7,7 +7,7 @@ import Tracking from '~/tracking'; export default { i18n: { - badgeLabel: s__('NorthstarNavigation|Alpha'), + badgeLabel: s__('NorthstarNavigation|Beta'), sectionTitle: s__('NorthstarNavigation|Navigation redesign'), toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'), toggleLabel: s__('NorthstarNavigation|Toggle new navigation'), @@ -51,7 +51,7 @@ export default { Tracking.event(undefined, 'click_toggle', { label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta', - property: this.enabled ? 'navigation' : 'navigation_top', + property: this.enabled ? 'nav_user_menu' : 'navigation_top', }); window.location.reload(); diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index a7c2e572037..9d07f435620 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -10,12 +10,12 @@ export default class NewBranchForm { } addBinding() { - this.name.addEventListener('blur', this.validate); + this.name.addEventListener('change', this.validate); } init() { if (this.name != null && this.name.value.length > 0) { - const event = new CustomEvent('blur'); + const event = new CustomEvent('change'); this.name.dispatchEvent(event); } } @@ -77,6 +77,7 @@ export default class NewBranchForm { const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { this.branchNameError.textContent = errors.join(', '); + this.name.focus(); } } } diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index 037be8467cb..c76ffce9168 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,4 +1,3 @@ -/* eslint-disable no-return-assign */ export default class NewCommitForm { constructor(form) { this.form = form; @@ -21,6 +20,6 @@ export default class NewCommitForm { this.createMergeRequestContainer.hide(); this.createMergeRequest.prop('checked', false); } - return (this.wasDifferent = different); + this.wasDifferent = different; } } diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 64e801a7516..211a12208c1 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -51,7 +51,7 @@ export default { language="python" :code="code" :max-height="maxHeight" - class="gl-border" + class="gl-border gl-p-4!" /> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/dataframe.vue b/app/assets/javascripts/notebook/cells/output/dataframe.vue new file mode 100644 index 00000000000..4fe02ee6edf --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/dataframe.vue @@ -0,0 +1,46 @@ +<script> +import JSONTable from '~/behaviors/components/json_table.vue'; +import Prompt from '../prompt.vue'; +import { convertHtmlTableToJson } from './dataframe_util'; + +export default { + name: 'DataframeOutput', + components: { + Prompt, + JSONTable, + }, + props: { + count: { + type: Number, + required: true, + }, + rawCode: { + type: String, + required: true, + }, + index: { + type: Number, + required: true, + }, + }, + computed: { + showOutput() { + return this.index === 0; + }, + dataframeAsJSONTable() { + return { + ...convertHtmlTableToJson(this.rawCode), + caption: '', + hasFilter: true, + }; + }, + }, +}; +</script> + +<template> + <div class="output"> + <prompt type="Out" :count="count" :show-output="showOutput" /> + <j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" /> + </div> +</template> diff --git a/app/assets/javascripts/notebook/cells/output/dataframe_util.js b/app/assets/javascripts/notebook/cells/output/dataframe_util.js new file mode 100644 index 00000000000..2fdaaced0b9 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js @@ -0,0 +1,44 @@ +import { sanitize } from '~/lib/dompurify'; + +/** + * Converts a dataframe in the output of a Jupyter Notebook cell to a json object + * + * @param {string} input - the dataframe + * @param {DOMParser} parser - the html parser + * @returns {Object} The converted JSON object with an `items` property containing the rows. + */ +export function convertHtmlTableToJson(input, domParser) { + const parser = domParser || new DOMParser(); + const htmlDoc = parser.parseFromString(sanitize(input), 'text/html'); + + if (!htmlDoc) return { fields: [], items: [] }; + + const columnNames = [...htmlDoc.querySelectorAll('table > thead th')].map( + (head) => head.innerText, + ); + + if (!columnNames) return { fields: [], items: [] }; + + const itemValues = [...htmlDoc.querySelectorAll('table > tbody > tr')].map((row) => + [...row.querySelectorAll('td')].map((item) => item.innerText), + ); + + return { + fields: columnNames.map((column) => ({ + key: column === '' ? 'index' : column, + label: column, + sortable: true, + })), + items: itemValues.map((values, itemIndex) => ({ + index: itemIndex, + ...Object.fromEntries(values.map((value, index) => [columnNames[index + 1], value])), + })), + }; +} + +export function isDataframe(output) { + const htmlData = output.data['text/html']; + if (!htmlData) return false; + + return htmlData.slice(0, 20).some((line) => line.includes('dataframe')); +} diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 22bcb5dd66a..0437b85913b 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -5,6 +5,8 @@ import ImageOutput from './image.vue'; import LatexOutput from './latex.vue'; import MarkdownOutput from './markdown.vue'; import ErrorOutput from './error.vue'; +import DataframeOutput from './dataframe.vue'; +import { isDataframe } from './dataframe_util'; const TEXT_MARKDOWN = 'text/markdown'; const ERROR_OUTPUT_TYPE = 'error'; @@ -66,6 +68,8 @@ export default { return ImageOutput; } else if (output.data['image/jpeg']) { return ImageOutput; + } else if (isDataframe(output)) { + return DataframeOutput; } else if (output.data['text/html']) { return HtmlOutput; } else if (output.data['text/latex']) { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4bcddb260e1..6794f838c84 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,6 +7,7 @@ import { createAlert } from '~/alert'; import { badgeState } from '~/issuable/components/status_box.vue'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { capitalizeFirstCharacter, convertToCamelCase, @@ -81,6 +82,9 @@ export default { 'hasDrafts', ]), ...mapState(['isToggleStateButtonLoading']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, noteableDisplayName() { const displayNameMap = { [constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue, @@ -224,7 +228,7 @@ export default { handleSaveDraft() { this.handleSave({ isDraft: true }); }, - handleSave({ withIssueAction = false, isDraft = false } = {}) { + async handleSave({ withIssueAction = false, isDraft = false } = {}) { this.errors = []; if (this.note.length) { @@ -246,6 +250,13 @@ export default { noteData.data.note.type = constants.DISCUSSION_NOTE; } + if (containsSensitiveToken(this.note)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + return; + } + } + this.note = ''; // Empty textarea while being requested. Repopulate in catch this.stopPolling(); @@ -363,6 +374,7 @@ export default { :form-field-props="formFieldProps" :autosave-key="autosaveKey" :disabled="isSubmitting" + :autocomplete-data-sources="autocompleteDataSources" supports-quick-actions @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleEnter()" @@ -399,10 +411,10 @@ export default { {{ $options.i18n.internal }} <gl-icon v-gl-tooltip:tooltipcontainer.bottom - name="question" + name="question-o" :size="16" :title="$options.i18n.internalVisibility" - class="gl-text-gray-500" + class="gl-text-blue-500" /> </gl-form-checkbox> <comment-type-dropdown diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 37935e9c3c6..ba5ffc60917 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -157,7 +157,7 @@ export default { v-if="resolveAllDiscussionsIssuePath && !allResolved" :href="resolveAllDiscussionsIssuePath" > - {{ __('Create issue to resolve all threads') }} + {{ __('Resolve all with new issue') }} </gl-dropdown-item> </gl-dropdown> </gl-button-group> diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index 39b3df899a5..d02327a37a7 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -28,7 +28,9 @@ export default { class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note" data-qa-selector="discussion_filter_container" > - <div class="timeline-icon d-none d-lg-flex"> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > <gl-icon name="comment" /> </div> <div class="timeline-content gl-pl-8"> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 89cd252b94b..bce17aebd64 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -21,7 +21,7 @@ export default { editCommentLabel: __('Edit comment'), deleteCommentLabel: __('Delete comment'), moreActionsLabel: __('More actions'), - reportAbuse: __('Report abuse to administrator'), + reportAbuse: __('Report abuse'), }, name: 'NoteActions', components: { @@ -367,7 +367,7 @@ export default { @click="closeTooltip" /> <!-- eslint-enable @gitlab/vue-no-data-toggle --> - <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> + <ul class="dropdown-menu more-actions-dropdown dropdown-menu-right"> <gl-dropdown-item v-if="canEdit" class="js-note-edit gl-sm-display-none!" diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 21841680cab..9c04a72375b 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -35,9 +35,6 @@ export default { isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, - addButtonClass() { - return this.isAuthoredByMe ? 'js-user-authored' : ''; - }, }, methods: { ...mapActions(['toggleAwardRequest']), @@ -64,7 +61,6 @@ export default { :awards="awards" :can-award-emoji="canAwardEmoji" :current-user-id="getUserData.id" - :add-button-class="addButtonClass" @award="handleAward($event)" /> </div> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index eef011db7d2..b4e5129ca0e 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -5,7 +5,6 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; -import autosave from '../mixins/autosave'; import NoteAttachment from './note_attachment.vue'; import NoteAwardsList from './note_awards_list.vue'; import NoteEditedText from './note_edited_text.vue'; @@ -22,7 +21,6 @@ export default { directives: { SafeHtml, }, - mixins: [autosave], props: { note: { type: Object, @@ -96,21 +94,9 @@ export default { }, mounted() { this.renderGFM(); - - if (this.isEditing) { - this.initAutoSave(this.note); - } }, updated() { this.renderGFM(); - - if (this.isEditing) { - if (!this.autosave) { - this.initAutoSave(this.note); - } else { - this.setAutoSave(); - } - } }, methods: { ...mapActions([ diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index b6ede10d02b..34ae0c7ffc1 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,10 +1,10 @@ <script> -import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; -import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -15,13 +15,14 @@ export default { i18n: COMMENT_FORM, name: 'NoteForm', components: { - MarkdownField, + MarkdownEditor, CommentFieldLayout, GlButton, GlSprintf, GlLink, + GlFormCheckbox, }, - mixins: [issuableStateMixin, resolvable], + mixins: [issuableStateMixin, resolvable, glFeaturesFlagMixin()], props: { noteBody: { type: String, @@ -95,20 +96,22 @@ export default { }, }, data() { - let updatedNoteBody = this.noteBody; - - if (!updatedNoteBody && this.autosaveKey) { - updatedNoteBody = getDraft(this.autosaveKey) || ''; - } - return { - updatedNoteBody, + updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, isResolving: this.resolveDiscussion, isUnresolving: !this.resolveDiscussion, resolveAsThread: true, isSubmittingWithKeydown: false, + formFieldProps: { + id: 'note_note', + name: 'note[note]', + 'aria-label': __('Reply to comment'), + placeholder: this.$options.i18n.bodyPlaceholder, + class: 'note-textarea js-gfm-input js-note-text markdown-area js-vue-issue-note-form', + 'data-qa-selector': 'reply_field', + }, }; }, computed: { @@ -123,6 +126,9 @@ export default { withBatchComments: (state) => state.batchComments?.withBatchComments, }), ...mapGetters('batchComments', ['hasDrafts']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, showBatchCommentsActions() { return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit; }, @@ -135,11 +141,6 @@ export default { .some((n) => n.current_user?.can_resolve_discussion) || this.isDraft ); }, - textareaPlaceholder() { - return this.discussionNote?.internal - ? this.$options.i18n.bodyPlaceholderInternal - : this.$options.i18n.bodyPlaceholder; - }, noteHash() { if (this.noteId) { return `#note_${this.noteId}`; @@ -214,6 +215,9 @@ export default { placeholder: { link: ['startTag', 'endTag'] }, }; }, + enableContentEditor() { + return Boolean(this.glFeatures.contentEditorOnIssues); + }, }, watch: { noteBody() { @@ -225,7 +229,7 @@ export default { }, }, mounted() { - this.$refs.textarea.focus(); + this.updatePlaceholder(); }, methods: { ...mapActions(['toggleResolveNote']), @@ -252,19 +256,21 @@ export default { }, cancelHandler(shouldConfirm = false) { // check if any dropdowns are active before sending the cancelation event - if (!this.$refs.textarea.classList.contains('at-who-active')) { + if ( + !this.$refs.markdownEditor.$el + .querySelector('textarea') + ?.classList.contains('at-who-active') + ) { this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); } }, - onInput() { - if (this.isSubmittingWithKeydown) { - return; - } - - if (this.autosaveKey) { - const { autosaveKey, updatedNoteBody: text } = this; - updateDraft(autosaveKey, text); - } + updatePlaceholder() { + this.formFieldProps.placeholder = this.discussionNote?.internal + ? this.$options.i18n.bodyPlaceholderInternal + : this.$options.i18n.bodyPlaceholder; + }, + onInput(value) { + this.updatedNoteBody = value; }, handleKeySubmit() { if (this.showBatchCommentsActions) { @@ -273,6 +279,7 @@ export default { this.isSubmittingWithKeydown = true; this.handleUpdate(); } + this.updatedNoteBody = ''; }, handleUpdate(shouldResolve) { const beforeSubmitDiscussionState = this.discussionResolved; @@ -333,53 +340,47 @@ export default { :noteable-data="getNoteableData" :is-internal-note="discussion.internal" > - <markdown-field - :markdown-preview-path="markdownPreviewPath" + <markdown-editor + ref="markdownEditor" + :enable-content-editor="enableContentEditor" + :value="updatedNoteBody" + :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" :line="line" :lines="lines" - :note="discussionNote" :can-suggest="canSuggest" :add-spacing-classes="false" :help-page-path="helpPagePath" + :note="discussionNote" + :form-field-props="formFieldProps" :show-suggest-popover="showSuggestPopover" - :textarea-value="updatedNoteBody" + :quick-actions-docs-path="quickActionsDocsPath" + :autosave-key="autosaveKey" + :autocomplete-data-sources="autocompleteDataSources" + :disabled="isSubmitting" + supports-quick-actions + autofocus + @keydown.meta.enter="handleKeySubmit()" + @keydown.ctrl.enter="handleKeySubmit()" + @keydown.exact.up="editMyLastNote()" + @keydown.exact.esc="cancelHandler(true)" + @input="onInput" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" - > - <template #textarea> - <textarea - id="note_note" - ref="textarea" - v-model="updatedNoteBody" - :disabled="isSubmitting" - data-supports-quick-actions="true" - name="note[note]" - class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" - data-qa-selector="reply_field" - dir="auto" - :aria-label="__('Reply to comment')" - :placeholder="textareaPlaceholder" - @keydown.meta.enter="handleKeySubmit()" - @keydown.ctrl.enter="handleKeySubmit()" - @keydown.exact.up="editMyLastNote()" - @keydown.exact.esc="cancelHandler(true)" - @input="onInput" - ></textarea> - </template> - </markdown-field> + /> </comment-field-layout> <div class="note-form-actions"> <template v-if="showBatchCommentsActions"> <p v-if="showResolveDiscussionToggle"> <label> <template v-if="discussionResolved"> - <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" /> - {{ __('Unresolve thread') }} + <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox"> + {{ __('Unresolve thread') }} + </gl-form-checkbox> </template> <template v-else> - <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" /> - {{ __('Resolve thread') }} + <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox"> + {{ __('Resolve thread') }} + </gl-form-checkbox> </template> </label> </p> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 60ae573bae7..3375e366ecf 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -11,6 +11,7 @@ import { s__, __, sprintf } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -207,12 +208,21 @@ export default { this.isReplying = false; clearDraft(this.autosaveKey); }), - saveReply(noteText, form, callback) { + async saveReply(noteText, form, callback) { if (!noteText) { this.cancelReplyForm(); callback(); return; } + + if (containsSensitiveToken(noteText)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + callback(); + return; + } + } + const postData = { in_reply_to_discussion_id: this.discussion.reply_id, target_type: this.getNoteableData.targetType, diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 80025d6f98a..ae2f94a5a80 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -13,6 +13,7 @@ import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '~/locale'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -294,10 +295,9 @@ export default { this.isRequesting = false; this.oldContent = null; renderGFM(this.$refs.noteBody.$el); - this.$refs.noteBody.resetAutoSave(); this.$emit('updateSuccess'); }, - formUpdateHandler({ noteText, callback, resolveDiscussion }) { + async formUpdateHandler({ noteText, callback, resolveDiscussion }) { const position = { ...this.note.position, }; @@ -320,6 +320,14 @@ export default { if (this.isDraft) return; + if (containsSensitiveToken(noteText)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + callback(); + return; + } + } + const data = { endpoint: this.note.path, note: { @@ -383,7 +391,6 @@ export default { }); if (!confirmed) return; } - this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { // eslint-disable-next-line vue/no-mutating-props this.note.note_html = this.oldContent; diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue index 9c3b2139a5d..95c02884ace 100644 --- a/app/assets/javascripts/notes/components/notes_activity_header.vue +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -27,7 +27,7 @@ export default { <template> <div - class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5" + class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5 gl-pb-3" > <h2 class="gl-font-size-h1 gl-m-0">{{ __('Activity') }}</h2> <div class="gl-display-flex gl-gap-3 gl-w-full gl-sm-w-auto gl-mt-3 gl-sm-mt-0"> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index b884c6b6d19..cf7207d260d 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { apolloProvider } from '~/graphql_shared/issuable_client'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; import NotesApp from './components/notes_app.vue'; @@ -58,7 +59,8 @@ export default () => { provide: { showTimelineViewToggle, reportAbusePath: notesDataset.reportAbusePath, - newSavedRepliesPath: notesDataset.savedRepliesNewPath, + newCommentTemplatePath: notesDataset.newCommentTemplatePath, + resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id), }, data() { return { diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js deleted file mode 100644 index 17272d5abef..00000000000 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ /dev/null @@ -1,30 +0,0 @@ -import { s__ } from '~/locale'; -import Autosave from '~/autosave'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -export default { - methods: { - initAutoSave(noteable, extraKeys = []) { - let keys = [ - s__('Autosave|Note'), - capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType), - noteable.id, - ]; - - if (extraKeys) { - keys = keys.concat(extraKeys); - } - - this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys); - }, - resetAutoSave() { - this.autosave.reset(); - }, - setAutoSave() { - this.autosave.save(); - }, - disposeAutoSave() { - this.autosave.dispose(); - }, - }, -}; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 3dbcf28d11c..90de7db8c1b 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -63,6 +63,7 @@ function getPreviousDiscussion() { function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { const discussion = getDiscussion(); + if (!isOverviewPage() && !discussion) { window.mrTabs?.eventHub.$once('NotesAppReady', () => { handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions); @@ -71,9 +72,12 @@ function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { window.mrTabs?.tabShown('show', undefined, false); return; } - const id = discussion.dataset.discussionId; - ctx.expandDiscussion({ discussionId: id }); - scrollToElement(discussion, scrollOptions); + + if (discussion) { + const id = discussion.dataset.discussionId; + ctx.expandDiscussion({ discussionId: id }); + scrollToElement(discussion, scrollOptions); + } } export default { diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index 14e97fcef46..9a1323cdaf2 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { marked } from 'marked'; import { sanitize } from '~/lib/dompurify'; import { markdownConfig } from '~/lib/utils/text_utility'; @@ -8,9 +7,9 @@ import { markdownConfig } from '~/lib/utils/text_utility'; * @param {Boolean} enabled that will be send as a property for the event */ export const trackToggleTimelineView = (enabled) => ({ - category: 'Incident Management', + category: 'Incident Management', // eslint-disable-line @gitlab/require-i18n-strings action: 'toggle_incident_comments_into_timeline_view', - label: 'Status', + label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings property: enabled, }); diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue new file mode 100644 index 00000000000..c4a928c5e07 --- /dev/null +++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue @@ -0,0 +1,106 @@ +<script> +import { GlButton, GlModal } from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; +import { + CONFIRM_MODAL, + CONFIRM_MODAL_TITLE, + COPY_SECRET, + DESCRIPTION_SECRET, + RENEW_SECRET, + RENEW_SECRET_FAILURE, + RENEW_SECRET_SUCCESS, + WARNING_NO_SECRET, +} from '../constants'; + +export default { + CONFIRM_MODAL, + CONFIRM_MODAL_TITLE, + COPY_SECRET, + DESCRIPTION_SECRET, + RENEW_SECRET, + name: 'OAuthSecret', + components: { + GlButton, + GlModal, + InputCopyToggleVisibility, + }, + inject: ['initialSecret', 'renewPath'], + data() { + return { + secret: this.initialSecret, + alert: null, + isModalVisible: false, + isLoading: false, + }; + }, + computed: { + actionPrimary() { + return { + text: this.$options.RENEW_SECRET, + attributes: { + variant: 'confirm', + loading: this.isLoading, + }, + }; + }, + }, + created() { + if (!this.secret) { + this.alert = createAlert({ message: WARNING_NO_SECRET, variant: VARIANT_WARNING }); + } + }, + methods: { + displayModal() { + this.isModalVisible = true; + }, + async renewSecret(event) { + event.preventDefault(); + this.isLoading = true; + this.alert?.dismiss(); + + try { + const { data } = await axios.put(this.renewPath); + this.alert = createAlert({ message: RENEW_SECRET_SUCCESS, variant: VARIANT_SUCCESS }); + this.secret = data.secret; + } catch { + this.alert = createAlert({ message: RENEW_SECRET_FAILURE }); + } finally { + this.isLoading = false; + this.isModalVisible = false; + } + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <input-copy-toggle-visibility + v-if="secret" + :copy-button-title="$options.COPY_SECRET" + :value="secret" + class="gl-mt-n3 gl-mb-0" + > + <template #description> + {{ $options.DESCRIPTION_SECRET }} + </template> + </input-copy-toggle-visibility> + + <gl-button category="secondary" class="gl-align-self-start" @click="displayModal">{{ + $options.RENEW_SECRET + }}</gl-button> + + <gl-modal + v-model="isModalVisible" + :title="$options.CONFIRM_MODAL_TITLE" + size="sm" + modal-id="modal-renew-secret" + :action-primary="actionPrimary" + @primary="renewSecret" + > + {{ $options.CONFIRM_MODAL }} + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/oauth_application/constants.js b/app/assets/javascripts/oauth_application/constants.js new file mode 100644 index 00000000000..5eaacadda78 --- /dev/null +++ b/app/assets/javascripts/oauth_application/constants.js @@ -0,0 +1,20 @@ +import { __, s__ } from '~/locale'; + +export const CONFIRM_MODAL = s__( + 'AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab.', +); +export const CONFIRM_MODAL_TITLE = s__('AuthorizedApplication|Renew secret?'); +export const COPY_SECRET = __('Copy secret'); +export const DESCRIPTION_SECRET = __( + 'This is the only time the secret is accessible. Copy the secret and store it securely.', +); +export const RENEW_SECRET = s__('AuthorizedApplication|Renew secret'); +export const RENEW_SECRET_FAILURE = s__( + 'AuthorizedApplication|There was an error trying to renew the application secret. Please try again.', +); +export const RENEW_SECRET_SUCCESS = s__( + 'AuthorizedApplication|Application secret was successfully renewed.', +); +export const WARNING_NO_SECRET = __( + 'The secret is only available when you create the application or renew the secret.', +); diff --git a/app/assets/javascripts/oauth_application/index.js b/app/assets/javascripts/oauth_application/index.js new file mode 100644 index 00000000000..f8f1f647a15 --- /dev/null +++ b/app/assets/javascripts/oauth_application/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import OAuthSecret from './components/oauth_secret.vue'; + +export const initOAuthApplicationSecret = () => { + const el = document.querySelector('#js-oauth-application-secret'); + + if (!el) { + return null; + } + + const { initialSecret, renewPath } = el.dataset; + + return new Vue({ + el, + name: 'OAuthSecretRoot', + provide: { initialSecret, renewPath }, + render(h) { + return h(OAuthSecret); + }, + }); +}; 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 5d77ff9dc0d..4e154870f55 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 @@ -4,9 +4,10 @@ import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { formatDate } from '~/lib/utils/datetime_utility'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { - UPDATED_AT, + CREATED_AT, CLEANUP_UNSCHEDULED_TEXT, CLEANUP_SCHEDULED_TEXT, CLEANUP_ONGOING_TEXT, @@ -65,11 +66,11 @@ export default { visibilityIcon() { return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; }, - timeAgo() { - return this.timeFormatted(this.imageDetails.updatedAt); + formattedCreatedAtDate() { + return formatDate(this.imageDetails.createdAt, 'mmm d, yyyy HH:MM', true); }, - updatedText() { - return sprintf(UPDATED_AT, { time: this.timeAgo }); + createdText() { + return sprintf(CREATED_AT, { time: this.formattedCreatedAtDate }); }, tagCountText() { if (this.$apollo.queries.containerRepository.loading) { @@ -145,9 +146,9 @@ export default { <template #metadata-updated> <metadata-item :icon="visibilityIcon" - :text="updatedText" + :text="createdText" size="xl" - data-testid="updated-and-visibility" + data-testid="created-and-visibility" /> </template> <template #right-actions> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 863d1c2629b..a1c837a4add 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -3,12 +3,16 @@ import { GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { n__ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; - import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { + ALERT_SUCCESS_TAG, + ALERT_DANGER_TAG, + ALERT_SUCCESS_TAGS, + ALERT_DANGER_TAGS, REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, GRAPHQL_PAGE_SIZE, @@ -20,19 +24,22 @@ import { NO_TAGS_MATCHING_FILTERS_DESCRIPTION, } from '../../constants/index'; import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; +import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import TagsListRow from './tags_list_row.vue'; +import DeleteModal from './delete_modal.vue'; export default { name: 'TagsList', components: { + DeleteModal, GlEmptyState, TagsListRow, TagsLoader, RegistryList, PersistedSearch, }, + mixins: [Tracking.mixin()], inject: ['config'], - props: { id: { type: [Number, String], @@ -77,6 +84,8 @@ export default { return { containerRepository: {}, filters: {}, + itemsToBeDeleted: [], + mutationLoading: false, sort: null, }; }, @@ -88,7 +97,7 @@ export default { return this.containerRepository?.tags?.nodes || []; }, hideBulkDelete() { - return !(this.containerRepository?.canDelete || false); + return !this.containerRepository?.canDelete; }, tagsPageInfo() { return this.containerRepository?.tags?.pageInfo; @@ -105,7 +114,12 @@ export default { return this.tags.length === 0; }, isLoading() { - return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort; + return ( + this.isImageLoading || + this.$apollo.queries.containerRepository.loading || + this.mutationLoading || + !this.sort + ); }, hasFilters() { return this.filters?.name; @@ -116,17 +130,61 @@ export default { emptyStateDescription() { return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE; }, + tracking() { + return { + label: + this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, }, methods: { + deleteTags(toBeDeleted) { + this.itemsToBeDeleted = toBeDeleted; + this.track('click_button'); + this.$refs.deleteModal.show(); + }, + confirmDelete() { + this.handleDeleteTag(); + }, + async handleDeleteTag() { + this.track('confirm_delete'); + const { itemsToBeDeleted } = this; + this.mutationLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: deleteContainerRepositoryTagsMutation, + variables: { + id: this.queryVariables.id, + tagNames: itemsToBeDeleted.map((item) => item.name), + }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: getContainerRepositoryTagsQuery, + variables: this.queryVariables, + }, + ], + }); + if (data?.destroyContainerRepositoryTags?.errors[0]) { + throw new Error(); + } + this.$emit( + 'delete', + itemsToBeDeleted.length === 1 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS, + ); + this.itemsToBeDeleted = []; + } catch (e) { + this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS); + } finally { + this.mutationLoading = false; + } + }, fetchNextPage() { this.$apollo.queries.containerRepository.fetchMore({ variables: { after: this.tagsPageInfo?.endCursor, first: GRAPHQL_PAGE_SIZE, }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, }); }, fetchPreviousPage() { @@ -136,9 +194,6 @@ export default { before: this.tagsPageInfo?.startCursor, last: GRAPHQL_PAGE_SIZE, }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, }); }, handleSearchUpdate({ sort, filters }) { @@ -193,7 +248,7 @@ export default { id-property="name" @prev-page="fetchPreviousPage" @next-page="fetchNextPage" - @delete="$emit('delete', $event)" + @delete="deleteTags" > <template #default="{ selectItem, isSelected, item, first }"> <tags-list-row @@ -203,10 +258,17 @@ export default { :is-mobile="isMobile" :disabled="disabled" @select="selectItem(item)" - @delete="$emit('delete', [item])" + @delete="deleteTags([item])" /> </template> </registry-list> + + <delete-modal + ref="deleteModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirmDelete="confirmDelete" + @cancel="track('cancel_delete')" + /> </template> </template> </div> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 7bb69363743..7ac803a8ece 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js @@ -65,7 +65,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( 'ContainerRegistry|Invalid tag: missing manifest digest', ); -export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}'); +export const CREATED_AT = s__('ContainerRegistry|Created %{time}'); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js index 9d0ecfd2dcb..71538ea5a07 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js @@ -1,14 +1,10 @@ import { s__ } from '~/locale'; -export const EXPIRATION_POLICY_WILL_RUN_IN = s__( - 'ContainerRegistry|Expiration policy will run in %{time}', -); -export const EXPIRATION_POLICY_DISABLED_TEXT = s__( - 'ContainerRegistry|Expiration policy is disabled.', -); +export const EXPIRATION_POLICY_WILL_RUN_IN = s__('ContainerRegistry|Cleanup will run in %{time}'); +export const EXPIRATION_POLICY_DISABLED_TEXT = s__('ContainerRegistry|Cleanup is not scheduled.'); export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); export const DELETE_ALERT_LINK_TEXT = s__( - 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}', + 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}', ); export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__( 'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}', diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js index 850dca07a3f..f9820df4a12 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js @@ -6,7 +6,6 @@ Vue.use(VueApollo); export const mergeVariables = (existing, incoming) => { if (!incoming) return existing; - if (!existing) return incoming; return incoming; }; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql index e2036d9e63d..eae663acb48 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -7,7 +7,6 @@ query getContainerRepositoryDetails($id: ContainerRepositoryID!) { location canDelete createdAt - updatedAt expirationPolicyStartedAt expirationPolicyCleanupStatus project { 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 2b5fb1a70ed..c6ed4c06577 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 @@ -15,22 +15,14 @@ import StatusAlert from '../components/details_page/status_alert.vue'; import TagsList from '../components/details_page/tags_list.vue'; import { - ALERT_SUCCESS_TAG, - ALERT_DANGER_TAG, - ALERT_SUCCESS_TAGS, - ALERT_DANGER_TAGS, ALERT_DANGER_IMAGE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, - GRAPHQL_PAGE_SIZE, MISSING_OR_DELETED_IMAGE_TITLE, MISSING_OR_DELETED_IMAGE_MESSAGE, } from '../constants/index'; -import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; -import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; -import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql'; export default { name: 'RegistryDetailsPage', @@ -76,7 +68,6 @@ export default { mutationLoading: false, deleteAlertType: null, hidePartialCleanupWarning: false, - deleteImageAlert: false, }; }, computed: { @@ -97,8 +88,7 @@ export default { }, tracking() { return { - label: - this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + label: 'registry_image_delete', }; }, pageActionsAreDisabled() { @@ -112,57 +102,8 @@ export default { : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, - deleteTags(toBeDeleted) { - this.deleteImageAlert = false; - this.itemsToBeDeleted = toBeDeleted; - this.track('click_button'); - this.$refs.deleteModal.show(); - }, confirmDelete() { - if (this.deleteImageAlert) { - this.$refs.deleteImage.doDelete(); - } else { - this.handleDeleteTag(); - } - }, - async handleDeleteTag() { - this.track('confirm_delete'); - const { itemsToBeDeleted } = this; - this.itemsToBeDeleted = []; - this.mutationLoading = true; - try { - const { data } = await this.$apollo.mutate({ - mutation: deleteContainerRepositoryTagsMutation, - variables: { - id: this.queryVariables.id, - tagNames: itemsToBeDeleted.map((i) => i.name), - }, - awaitRefetchQueries: true, - refetchQueries: [ - { - query: getContainerRepositoryTagsQuery, - variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE }, - }, - { - query: getContainerRepositoriesDetails, - variables: { - fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, - isGroupPage: this.config.isGroupPage, - }, - }, - ], - }); - - if (data?.destroyContainerRepositoryTags?.errors[0]) { - throw new Error(); - } - this.deleteAlertType = - itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; - } catch (e) { - this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; - } - - this.mutationLoading = false; + this.$refs.deleteImage.doDelete(); }, handleResize() { this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs'; @@ -174,7 +115,6 @@ export default { }); }, deleteImage() { - this.deleteImageAlert = true; this.itemsToBeDeleted = [{ ...this.containerRepository }]; this.$refs.deleteModal.show(); }, @@ -185,6 +125,9 @@ export default { this.itemsToBeDeleted = []; this.mutationLoading = true; }, + showAlert(alertType) { + this.deleteAlertType = alertType; + }, }, }; </script> @@ -222,7 +165,7 @@ export default { :is-image-loading="isLoading" :is-mobile="isMobile" :disabled="pageActionsAreDisabled" - @delete="deleteTags" + @delete="showAlert" /> <delete-image @@ -237,7 +180,7 @@ export default { <delete-modal ref="deleteModal" :items-to-be-deleted="itemsToBeDeleted" - :delete-image="deleteImageAlert" + delete-image @confirmDelete="confirmDelete" @cancel="track('cancel_delete')" /> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index b24ec65464f..d32e90f3adb 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -8,7 +8,6 @@ import { GlFormInputGroup, GlModal, GlModalDirective, - GlSkeletonLoader, GlSprintf, } from '@gitlab/ui'; import { __, s__, n__, sprintf } from '~/locale'; @@ -30,7 +29,6 @@ export default { GlFormGroup, GlFormInputGroup, GlModal, - GlSkeletonLoader, GlSprintf, ClipboardButton, TitleArea, @@ -208,23 +206,20 @@ export default { </template> </gl-form-group> - <gl-skeleton-loader v-if="$apollo.queries.group.loading" /> + <manifests-list + v-if="manifests && manifests.length" + :loading="$apollo.queries.group.loading" + :manifests="manifests" + :pagination="pageInfo" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + /> - <div v-else data-testid="main-area"> - <manifests-list - v-if="manifests && manifests.length" - :manifests="manifests" - :pagination="pageInfo" - @prev-page="fetchPreviousPage" - @next-page="fetchNextPage" - /> - - <gl-empty-state - v-else - :svg-path="noManifestsIllustration" - :title="$options.i18n.noManifestTitle" - /> - </div> + <gl-empty-state + v-else + :svg-path="noManifestsIllustration" + :title="$options.i18n.noManifestTitle" + /> <gl-modal :modal-id="$options.confirmClearCacheModal" diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue index 005c8feea3a..0d9b8330fe3 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue @@ -1,5 +1,5 @@ <script> -import { GlKeysetPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui'; import { s__ } from '~/locale'; import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; @@ -8,6 +8,7 @@ export default { components: { ManifestRow, GlKeysetPagination, + GlSkeletonLoader, }, props: { manifests: { @@ -19,6 +20,11 @@ export default { type: Object, required: true, }, + loading: { + type: Boolean, + required: false, + default: () => false, + }, }, i18n: { listTitle: s__('DependencyProxy|Image list'), @@ -34,19 +40,22 @@ export default { <template> <div class="gl-mt-6"> <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3> - <div - class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column" - > - <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" /> - </div> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - v-bind="pagination" - class="gl-mt-3" - @prev="$emit('prev-page')" - @next="$emit('next-page')" - /> + <gl-skeleton-loader v-if="loading" /> + <div v-else data-testid="main-area"> + <div + class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column" + > + <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" /> + </div> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pagination" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue index 9bab08b8548..a9d076afb92 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue @@ -36,7 +36,7 @@ export default { }, }, i18n: { - LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'), + LIST_TITLE_TEXT: s__('InfrastructureRegistry|Terraform Module Registry'), LIST_INTRO_TEXT: s__( 'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}', ), diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue index b167fff26b0..f790c7b1430 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue @@ -1,39 +1,74 @@ <script> -import { GlModal } from '@gitlab/ui'; -import { __, n__ } from '~/locale'; +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import { + DELETE_MODAL_CONTENT, + DELETE_MODAL_TITLE, + DELETE_PACKAGES_MODAL_DESCRIPTION, DELETE_PACKAGES_MODAL_TITLE, DELETE_PACKAGE_MODAL_PRIMARY_ACTION, + DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT, + DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT, + DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + REQUEST_FORWARDING_HELP_PAGE_PATH, } from '~/packages_and_registries/package_registry/constants'; export default { name: 'DeleteModal', i18n: { - DELETE_PACKAGES_MODAL_TITLE, + DELETE_MODAL_CONTENT, + DELETE_PACKAGES_MODAL_DESCRIPTION, }, components: { + GlLink, GlModal, + GlSprintf, }, props: { itemsToBeDeleted: { type: Array, required: true, }, + showRequestForwardingContent: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - description() { - return n__( - 'PackageRegistry|You are about to delete 1 package. This operation is irreversible.', - `PackageRegistry|You are about to delete %d packages. This operation is irreversible.`, - this.itemsToBeDeleted.length, - ); + itemToBeDeleted() { + if (this.itemsToBeDeleted.length === 1) { + const [itemToBeDeleted] = this.itemsToBeDeleted; + return itemToBeDeleted; + } + return null; + }, + title() { + return this.itemToBeDeleted ? DELETE_MODAL_TITLE : DELETE_PACKAGES_MODAL_TITLE; + }, + packagesDeletePrimaryActionProps() { + let text = DELETE_PACKAGE_MODAL_PRIMARY_ACTION; + + if (this.showRequestForwardingContent) { + if (this.itemToBeDeleted) { + text = DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION; + } else { + text = DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION; + } + } + return { + text, + attributes: { variant: 'danger', category: 'primary' }, + }; + }, + requestForwardingContentMessage() { + return this.itemToBeDeleted + ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT + : DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT; }, }, modal: { - packagesDeletePrimaryAction: { - text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION, - attributes: { variant: 'danger', category: 'primary' }, - }, cancelAction: { text: __('Cancel'), }, @@ -43,6 +78,9 @@ export default { this.$refs.deleteModal.show(); }, }, + links: { + REQUEST_FORWARDING_HELP_PAGE_PATH, + }, }; </script> @@ -51,12 +89,34 @@ export default { ref="deleteModal" size="sm" modal-id="delete-packages-modal" - :action-primary="$options.modal.packagesDeletePrimaryAction" + :action-primary="packagesDeletePrimaryActionProps" :action-cancel="$options.modal.cancelAction" - :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE" + :title="title" @primary="$emit('confirm')" @cancel="$emit('cancel')" > - <span>{{ description }}</span> + <p v-if="showRequestForwardingContent"> + <gl-sprintf :message="requestForwardingContentMessage"> + <template #docLink="{ content }"> + <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p v-else> + <gl-sprintf v-if="itemToBeDeleted" :message="$options.i18n.DELETE_MODAL_CONTENT"> + <template #version> + <strong>{{ itemToBeDeleted.version }}</strong> + </template> + + <template #name> + <strong>{{ itemToBeDeleted.name }}</strong> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="$options.i18n.DELETE_PACKAGES_MODAL_DESCRIPTION"> + <template #count> + {{ itemsToBeDeleted.length }} + </template> + </gl-sprintf> + </p> </gl-modal> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index 3d5ac528920..7ea19df7a6c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,4 +1,6 @@ <script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { n__ } from '~/locale'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; @@ -10,16 +12,20 @@ import { CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, DELETE_PACKAGE_VERSION_TRACKING_ACTION, DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE, + GRAPHQL_PAGE_SIZE, REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, } from '~/packages_and_registries/package_registry/constants'; import Tracking from '~/tracking'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; +import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql'; export default { components: { DeleteModal, DeletePackageModal, + GlAlert, VersionRow, PackagesListLoader, RegistryList, @@ -31,33 +37,65 @@ export default { required: false, default: false, }, - versions: { - type: Array, - required: true, - default: () => [], - }, - pageInfo: { - type: Object, - required: true, + count: { + type: Number, + required: false, + default: 0, }, - isLoading: { + isMutationLoading: { type: Boolean, required: false, default: false, }, + packageId: { + type: String, + required: true, + }, }, data() { return { itemToBeDeleted: null, itemsToBeDeleted: [], + packageVersions: {}, + fetchPackageVersionsError: false, }; }, + apollo: { + packageVersions: { + query: getPackageVersionsQuery, + variables() { + return this.queryVariables; + }, + skip() { + return this.isListEmpty; + }, + update(data) { + return data.package?.versions ?? {}; + }, + error(error) { + this.fetchPackageVersionsError = true; + Sentry.captureException(error); + }, + }, + }, computed: { + isListEmpty() { + return this.count === 0; + }, + isLoading() { + return this.$apollo.queries.packageVersions.loading || this.isMutationLoading; + }, + pageInfo() { + return this.packageVersions?.pageInfo ?? {}; + }, listTitle() { return n__('%d version', '%d versions', this.versions.length); }, - isListEmpty() { - return this.versions.length === 0; + queryVariables() { + return { + id: this.packageId, + first: GRAPHQL_PAGE_SIZE, + }; }, tracking() { const category = this.itemToBeDeleted @@ -67,6 +105,9 @@ export default { category, }; }, + versions() { + return this.packageVersions?.nodes ?? []; + }, }, methods: { deleteItemConfirmation() { @@ -101,6 +142,32 @@ export default { this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); this.$refs.deletePackagesModal.show(); }, + fetchPreviousVersionsPage() { + const variables = { + ...this.queryVariables, + first: null, + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo?.startCursor, + }; + this.$apollo.queries.packageVersions.fetchMore({ + variables, + }); + }, + fetchNextVersionsPage() { + const variables = { + ...this.queryVariables, + first: GRAPHQL_PAGE_SIZE, + last: null, + after: this.pageInfo?.endCursor, + }; + + this.$apollo.queries.packageVersions.fetchMore({ + variables, + }); + }, + }, + i18n: { + errorMessage: FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE, }, }; </script> @@ -109,6 +176,9 @@ export default { <div v-if="isLoading"> <packages-list-loader /> </div> + <gl-alert v-else-if="fetchPackageVersionsError" variant="danger" :dismissible="false">{{ + $options.i18n.errorMessage + }}</gl-alert> <slot v-else-if="isListEmpty" name="empty-state"></slot> <div v-else> <registry-list @@ -118,8 +188,8 @@ export default { :pagination="pageInfo" :title="listTitle" @delete="setItemsToBeDeleted" - @prev-page="$emit('prev-page')" - @next-page="$emit('next-page')" + @prev-page="fetchPreviousVersionsPage" + @next-page="fetchNextVersionsPage" > <template #default="{ first, item, isSelected, selectItem }"> <version-row diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index 193a222853f..37a6fe75f15 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -130,13 +130,14 @@ export default { <template v-if="packageEntity.canDestroy" #right-action> <gl-dropdown + data-testid="delete-dropdown" icon="ellipsis_v" :text="$options.i18n.moreActions" :text-sr-only="true" category="tertiary" no-caret > - <gl-dropdown-item variant="danger" @click="$emit('delete')">{{ + <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{ $options.i18n.deletePackage }}</gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue index 440e11a99f2..05359128af4 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue @@ -39,5 +39,8 @@ export default { <template #metadata-amount> <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> </template> + <template #right-actions> + <slot name="settings-link"></slot> + </template> </title-area> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 486ab4fdc99..effed4891d8 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -1,7 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; -import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; @@ -14,16 +13,24 @@ import { CANCEL_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGES_TRACKING_ACTION, PACKAGE_ERROR_STATUS, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, + PACKAGE_TYPE_PYPI, } from '~/packages_and_registries/package_registry/constants'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import Tracking from '~/tracking'; +const forwardingFieldToPackageTypeMapping = { + mavenPackageRequestsForwarding: PACKAGE_TYPE_MAVEN, + npmPackageRequestsForwarding: PACKAGE_TYPE_NPM, + pypiPackageRequestsForwarding: PACKAGE_TYPE_PYPI, +}; + export default { name: 'PackagesList', components: { GlAlert, DeleteModal, - DeletePackageModal, PackagesListLoader, PackagesListRow, RegistryList, @@ -44,16 +51,27 @@ export default { type: Object, required: true, }, + groupSettings: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { - itemToBeDeleted: null, itemsToBeDeleted: [], errorPackages: [], }; }, computed: { + itemToBeDeleted() { + if (this.itemsToBeDeleted.length === 1) { + const [itemToBeDeleted] = this.itemsToBeDeleted; + return itemToBeDeleted; + } + return null; + }, listTitle() { return n__('%d package', '%d packages', this.list.length); }, @@ -77,6 +95,15 @@ export default { showErrorPackageAlert() { return this.errorPackages.length > 0; }, + packageTypesWithForwardingEnabled() { + return Object.keys(this.groupSettings) + .filter((field) => this.groupSettings[field]) + .map((field) => forwardingFieldToPackageTypeMapping[field]); + }, + isRequestForwardingEnabled() { + const selectedPackageTypes = new Set(this.itemsToBeDeleted.map((item) => item.packageType)); + return this.packageTypesWithForwardingEnabled.some((type) => selectedPackageTypes.has(type)); + }, }, watch: { list(newVal) { @@ -88,40 +115,36 @@ export default { this.list.length > 0 ? this.list.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS) : []; }, methods: { - setItemToBeDeleted(item) { - this.itemToBeDeleted = { ...item }; - this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION); - }, setItemsToBeDeleted(items) { + this.itemsToBeDeleted = items; if (items.length === 1) { - const [item] = items; - this.setItemToBeDeleted(item); - return; + this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION); + } else { + this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION); } - this.itemsToBeDeleted = items; - this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION); this.$refs.deletePackagesModal.show(); }, deleteItemsConfirmation() { this.$emit('delete', this.itemsToBeDeleted); - this.track(DELETE_PACKAGES_TRACKING_ACTION); + + if (this.itemToBeDeleted) { + this.track(DELETE_PACKAGE_TRACKING_ACTION); + } else { + this.track(DELETE_PACKAGES_TRACKING_ACTION); + } + this.itemsToBeDeleted = []; }, deleteItemsCanceled() { - this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION); + if (this.itemToBeDeleted) { + this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION); + } else { + this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION); + } this.itemsToBeDeleted = []; }, - deleteItemConfirmation() { - this.$emit('delete', [this.itemToBeDeleted]); - this.track(DELETE_PACKAGE_TRACKING_ACTION); - this.itemToBeDeleted = null; - }, - deleteItemCanceled() { - this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION); - this.itemToBeDeleted = null; - }, showConfirmationModal() { - this.setItemToBeDeleted(this.errorPackages[0]); + this.setItemsToBeDeleted([this.errorPackages[0]]); }, }, i18n: { @@ -165,21 +188,16 @@ export default { :first="first" :package-entity="item" :selected="isSelected(item)" - @delete="setItemToBeDeleted(item)" + @delete="setItemsToBeDeleted([item])" @select="selectItem(item)" /> </template> </registry-list> - <delete-package-modal - :item-to-be-deleted="itemToBeDeleted" - @ok="deleteItemConfirmation" - @cancel="deleteItemCanceled" - /> - <delete-modal ref="deletePackagesModal" :items-to-be-deleted="itemsToBeDeleted" + :show-request-forwarding-content="isRequestForwardingEnabled" @confirm="deleteItemsConfirmation" @cancel="deleteItemsCanceled" /> 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 eda8d9e0066..ad5edcd7602 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -115,6 +115,10 @@ export const DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'delete_package_version'; export const REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'request_delete_package_version'; export const CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'cancel_delete_package_version'; +export const FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE = s__( + 'PackageRegistry|Failed to load version data', +); + export const DELETE_PACKAGES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting packages.', ); @@ -122,6 +126,21 @@ export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages del export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages'); export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete'); +export const DELETE_PACKAGES_MODAL_DESCRIPTION = s__( + 'PackageRegistry|You are about to delete %{count} packages. This operation is irreversible.', +); +export const DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__( + 'PackageRegistry|Yes, delete package', +); +export const DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__( + 'PackageRegistry|Yes, delete selected packages', +); +export const DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT = s__( + 'PackageRegistry|Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete the package anyway? %{docLinkStart}What are the risks?%{docLinkEnd}', +); +export const DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT = s__( + 'PackageRegistry|Some of the selected package formats allow request forwarding. Deleting a package while request forwarding is enabled for the project can pose a security risk. Do you want to proceed with deleting the selected packages? %{docLinkStart}What are the risks?%{docLinkEnd}', +); export const DELETE_PACKAGE_TEXT = s__('PackageRegistry|Delete package'); export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully'); @@ -207,5 +226,9 @@ export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/inde export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index'); export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index'); export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens'); +export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath( + 'user/packages/package_registry/supported_functionality', + { anchor: 'deleting-packages' }, +); export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql new file mode 100644 index 00000000000..db05f497b7f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql @@ -0,0 +1,8 @@ +fragment GroupPackageSettings on Group { + id + packageSettings { + mavenPackageRequestsForwarding + npmPackageRequestsForwarding + pypiPackageRequestsForwarding + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js index 56f95fa2c1f..39e5da54509 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -4,6 +4,27 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); +export const mergeVariables = (existing, incoming) => { + if (!incoming) return existing; + return incoming; +}; + export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + cacheConfig: { + typePolicies: { + PackageDetailsType: { + fields: { + versions: { + keyArgs: false, + merge: mergeVariables, + }, + }, + }, + }, + }, + }, + ), }); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index b5313f929f8..99864f7ad0c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -1,10 +1,4 @@ -query getPackageDetails( - $id: PackagesPackageID! - $first: Int - $last: Int - $after: String - $before: String -) { +query getPackageDetails($id: PackagesPackageID!) { package(id: $id) { id name @@ -62,31 +56,8 @@ query getPackageDetails( downloadPath } } - versions(after: $after, before: $before, first: $first, last: $last) { + versions { count - nodes { - id - name - canDestroy - createdAt - version - status - _links { - webPath - } - tags(first: 1) { - nodes { - id - name - } - } - } - pageInfo { - hasNextPage - hasPreviousPage - endCursor - startCursor - } } dependencyLinks { nodes { diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql new file mode 100644 index 00000000000..a4119ac5821 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql @@ -0,0 +1,38 @@ +query getPackageVersions( + $id: PackagesPackageID! + $first: Int + $last: Int + $after: String + $before: String +) { + package(id: $id) { + id + versions(after: $after, before: $before, first: $first, last: $last) { + count + nodes { + id + name + canDestroy + createdAt + packageType + version + status + _links { + webPath + } + tags(first: 1) { + nodes { + id + name + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql index 5bde5f08e56..f25f24cbc5f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -1,4 +1,5 @@ #import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql" +#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getPackages( @@ -32,6 +33,9 @@ query getPackages( ...PageInfo } } + group { + ...GroupPackageSettings + } } group(fullPath: $fullPath) @include(if: $isGroupPage) { id @@ -52,5 +56,6 @@ query getPackages( ...PageInfo } } + ...GroupPackageSettings } } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js index 15ed98122a0..e2f8d239bae 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -19,6 +19,7 @@ export default () => { npmInstanceUrl, projectListUrl, groupListUrl, + settingsPath, } = el.dataset; const isGroupPage = pageType === 'groups'; @@ -48,6 +49,7 @@ export default () => { projectListUrl, groupListUrl, breadCrumbState, + settingsPath, }, render(createElement) { return createElement(PackageRegistry); 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 1ce2140894e..0f1c63a04ad 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 @@ -54,6 +54,7 @@ import { import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; +import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql'; import Tracking from '~/tracking'; export default { @@ -135,7 +136,6 @@ export default { queryVariables() { return { id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId), - first: GRAPHQL_PAGE_SIZE, }; }, packageFiles() { @@ -147,9 +147,6 @@ export default { isLoading() { return this.$apollo.queries.packageEntity.loading; }, - isVersionsLoading() { - return this.isLoading || this.versionsMutationLoading; - }, packageFilesLoading() { return this.isLoading || this.mutationLoading; }, @@ -161,12 +158,12 @@ export default { category: packageTypeToTrackCategory(this.packageType), }; }, - versionPageInfo() { - return this.packageEntity?.versions?.pageInfo ?? {}; - }, packageDependencies() { return this.packageEntity.dependencyLinks?.nodes || []; }, + packageVersionsCount() { + return this.packageEntity.versions?.count ?? 0; + }, showDependencies() { return this.packageType === PACKAGE_TYPE_NUGET; }, @@ -190,6 +187,17 @@ export default { }, ]; }, + refetchVersionsQueryData() { + return [ + { + query: getPackageVersionsQuery, + variables: { + id: this.queryVariables.id, + first: GRAPHQL_PAGE_SIZE, + }, + }, + ]; + }, }, methods: { formatSize(size) { @@ -274,34 +282,6 @@ export default { resetDeleteModalContent() { this.deletePackageModalContent = DELETE_MODAL_CONTENT; }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, - fetchPreviousVersionsPage() { - const variables = { - ...this.queryVariables, - first: null, - last: GRAPHQL_PAGE_SIZE, - before: this.versionPageInfo?.startCursor, - }; - this.$apollo.queries.packageEntity.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - }, - fetchNextVersionsPage() { - const variables = { - ...this.queryVariables, - first: GRAPHQL_PAGE_SIZE, - last: null, - after: this.versionPageInfo?.endCursor, - }; - - this.$apollo.queries.packageEntity.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - }, }, i18n: { DELETE_MODAL_TITLE, @@ -403,12 +383,12 @@ export default { <template #title> <span>{{ $options.i18n.otherVersionsTabTitle }}</span> <gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{ - packageEntity.versions.count + packageVersionsCount }}</gl-badge> </template> <delete-packages - :refetch-queries="refetchQueriesData" + :refetch-queries="refetchVersionsQueryData" show-success-alert @start="versionsMutationLoading = true" @end="versionsMutationLoading = false" @@ -416,12 +396,10 @@ export default { <template #default="{ deletePackages }"> <package-versions-list :can-destroy="packageEntity.canDestroy" - :is-loading="isVersionsLoading" - :page-info="versionPageInfo" - :versions="packageEntity.versions.nodes" + :count="packageVersionsCount" + :is-mutation-loading="versionsMutationLoading" + :package-id="packageEntity.id" @delete="deletePackages" - @prev-page="fetchPreviousVersionsPage" - @next-page="fetchNextVersionsPage" > <template #empty-state> <p class="gl-mt-3" data-testid="no-versions-message"> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 6e92a6420ac..044ce4e6413 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_INFO } from '~/alert'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { historyReplaceState } from '~/lib/utils/common_utils'; @@ -19,6 +19,7 @@ import PackageList from '~/packages_and_registries/package_registry/components/l export default { components: { + GlButton, GlEmptyState, GlLink, GlSprintf, @@ -27,23 +28,26 @@ export default { PackageSearch, DeletePackages, }, - inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['emptyListIllustration', 'isGroupPage', 'fullPath', 'settingsPath'], data() { return { - packages: {}, + packagesResource: {}, sort: '', filters: {}, mutationLoading: false, }; }, apollo: { - packages: { + packagesResource: { query: getPackagesQuery, variables() { return this.queryVariables; }, update(data) { - return data[this.graphqlResource]?.packages ?? {}; + return data[this.graphqlResource] ?? {}; }, skip() { return !this.sort; @@ -51,6 +55,14 @@ export default { }, }, computed: { + packages() { + return this.packagesResource?.packages ?? {}; + }, + groupSettings() { + return this.isGroupPage + ? this.packagesResource?.packageSettings ?? {} + : this.packagesResource?.group?.packageSettings ?? {}; + }, queryVariables() { return { isGroupPage: this.isGroupPage, @@ -83,7 +95,7 @@ export default { : this.$options.i18n.noResultsTitle; }, isLoading() { - return this.$apollo.queries.packages.loading || this.mutationLoading; + return this.$apollo.queries.packagesResource.loading || this.mutationLoading; }, refetchQueriesData() { return [ @@ -123,7 +135,7 @@ export default { after: this.pageInfo?.endCursor, }; - this.$apollo.queries.packages.fetchMore({ + this.$apollo.queries.packagesResource.fetchMore({ variables, updateQuery: this.updateQuery, }); @@ -136,7 +148,7 @@ export default { before: this.pageInfo?.startCursor, }; - this.$apollo.queries.packages.fetchMore({ + this.$apollo.queries.packagesResource.fetchMore({ variables, updateQuery: this.updateQuery, }); @@ -149,6 +161,7 @@ export default { noResultsText: s__( 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', ), + settingsText: s__('PackageRegistry|Configure in settings'), }, links: { EMPTY_LIST_HELP_URL, @@ -159,7 +172,16 @@ export default { <template> <div> - <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> + <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount"> + <template v-if="settingsPath" #settings-link> + <gl-button + v-gl-tooltip="$options.i18n.settingsText" + icon="settings" + :href="settingsPath" + :aria-label="$options.i18n.settingsText" + /> + </template> + </package-title> <package-search class="gl-mb-5" @update="handleSearchUpdate" /> <delete-packages @@ -170,6 +192,7 @@ export default { > <template #default="{ deletePackages }"> <package-list + :group-settings="groupSettings" :list="packages.nodes" :is-loading="isLoading" :page-info="pageInfo" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue index b7d7f0aaca7..ab88d9e8936 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue @@ -1,12 +1,14 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { isEqual } from 'lodash'; import { + PACKAGE_FORWARDING_SECURITY_DESCRIPTION, PACKAGE_FORWARDING_SETTINGS_HEADER, PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, PACKAGE_FORWARDING_FORM_BUTTON, PACKAGE_FORWARDING_FIELDS, MAVEN_FORWARDING_FIELDS, + REQUEST_FORWARDING_HELP_PAGE_PATH, } from '~/packages_and_registries/settings/group/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; @@ -20,12 +22,15 @@ export default { name: 'PackageForwardingSettings', i18n: { PACKAGE_FORWARDING_FORM_BUTTON, + PACKAGE_FORWARDING_SECURITY_DESCRIPTION, PACKAGE_FORWARDING_SETTINGS_HEADER, PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, }, components: { ForwardingSettings, GlButton, + GlLink, + GlSprintf, SettingsBlock, }, mixins: [glFeatureFlagsMixin()], @@ -150,6 +155,9 @@ export default { this.$set(this.workingCopy, type, value); }, }, + links: { + REQUEST_FORWARDING_HELP_PAGE_PATH, + }, }; </script> @@ -157,9 +165,14 @@ export default { <settings-block> <template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template> <template #description> - <span data-testid="description"> + <span class="gl-display-block gl-mb-2" data-testid="description"> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }} </span> + <gl-sprintf :message="$options.i18n.PACKAGE_FORWARDING_SECURITY_DESCRIPTION"> + <template #docLink="{ content }"> + <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link> + </template> + </gl-sprintf> </template> <template #default> <form @submit.prevent="submit"> 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 b47759df35f..fa73c01c5c4 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -17,6 +17,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Publish packages if their name or version matches this regex.', ); +export const PACKAGE_FORWARDING_SECURITY_DESCRIPTION = s__( + 'PackageRegistry|There are security risks if packages are deleted while request forwarding is enabled. %{docLinkStart}What are the risks?%{docLinkEnd}', +); export const PACKAGE_FORWARDING_SETTINGS_HEADER = s__('PackageRegistry|Package forwarding'); export const PACKAGE_FORWARDING_SETTINGS_DESCRIPTION = s__( 'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.', @@ -79,3 +82,7 @@ export const MAVEN_FORWARDING_FIELDS = { // Parameters export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index'); +export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath( + 'user/packages/package_registry/supported_functionality', + { anchor: 'deleting-packages' }, +); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue index 7a9ea7c0bf7..35fc0910a16 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue @@ -8,7 +8,7 @@ import { export default { i18n: { - toggleLabel: s__('ContainerRegistry|Enable expiration policy'), + toggleLabel: s__('ContainerRegistry|Enable cleanup policy'), }, components: { GlFormGroup, 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 731fb3e4c45..5f59372e5ba 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -1,6 +1,6 @@ import { s__, __ } from '~/locale'; -export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`); +export const CONTAINER_CLEANUP_POLICY_TITLE = s__('ContainerRegistry|Cleanup policies'); export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__( `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`, ); diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index 76623377d90..adffab277cc 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -55,15 +55,6 @@ export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) => RegistryBreadcrumb, }, render(createElement) { - // FIXME(@tnir): this is a workaround until the MR gets merged: - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115 - const parentEl = breadCrumbEl.parentElement.parentElement; - if (parentEl) { - parentEl.classList.remove('breadcrumbs-container'); - parentEl.classList.add('gl-display-flex'); - parentEl.classList.add('w-100'); - } - // End of FIXME(@tnir) return createElement('registry-breadcrumb', { class: breadCrumbEl.className, props: { diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js index feceeb0b10a..ea7c9042e6d 100644 --- a/app/assets/javascripts/pages/abuse_reports/index.js +++ b/app/assets/javascripts/pages/abuse_reports/index.js @@ -1,3 +1,5 @@ import { initLinkToSpam } from '~/abuse_reports'; +import initFilePickers from '~/file_pickers'; initLinkToSpam(); +initFilePickers(); diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js index 3397b02aeba..df9e38431b0 100644 --- a/app/assets/javascripts/pages/admin/applications/index.js +++ b/app/assets/javascripts/pages/admin/applications/index.js @@ -1,3 +1,5 @@ import initApplicationDeleteButtons from '~/admin/applications'; +import { initOAuthApplicationSecret } from '~/oauth_application'; initApplicationDeleteButtons(); +initOAuthApplicationSecret(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue index 72cfc005782..72cfc005782 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue +++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue index 3bc785ee1b6..3bc785ee1b6 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js index cfde1fc0a2b..84be895e194 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { DEFAULT_FIELDS } from '~/jobs/components/table/constants'; export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); @@ -10,3 +11,11 @@ export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed'); export const CANCEL_JOBS_WARNING = s__( "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?", ); + +/* Admin Table constants */ +export const DEFAULT_FIELDS_ADMIN = [ + ...DEFAULT_FIELDS.slice(0, 2), + { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, + { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, + ...DEFAULT_FIELDS.slice(2), +]; diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue new file mode 100644 index 00000000000..b89e311ff1d --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue @@ -0,0 +1,118 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { validateQueryString } from '~/jobs/components/filtered_search/utils'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; +import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue'; +import { DEFAULT_FIELDS_ADMIN } from '../constants'; +import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql'; + +export default { + i18n: { + jobsFetchErrorMsg: __('There was an error fetching the jobs.'), + }, + components: { + JobsTableEmptyState, + GlAlert, + JobsTable, + JobsTableTabs, + }, + inject: { + jobStatuses: { + default: null, + }, + url: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, + apollo: { + jobs: { + query: GetAllJobs, + variables() { + return this.variables; + }, + update(data) { + const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data || {}; + return { + list, + pageInfo, + count, + }; + }, + error() { + this.error = this.$options.i18n.jobsFetchErrorMsg; + }, + }, + }, + data() { + return { + jobs: { + list: [], + }, + error: '', + count: 0, + scope: null, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + DEFAULT_FIELDS_ADMIN, + }; + }, + computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search + showEmptyState() { + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); + }, + variables() { + return { ...this.validatedQueryString }; + }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, + jobsCount() { + return this.jobs.count; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount) { + if (this.scope) return; + + this.count = newCount; + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="error" class="gl-mt-2" variant="danger" dismissible @dismiss="error = ''"> + {{ error }} + </gl-alert> + + <jobs-table-tabs :all-jobs-count="count" :loading="loading" /> + + <jobs-table-empty-state v-if="showEmptyState" /> + + <jobs-table + v-else + :jobs="jobs.list" + :table-fields="DEFAULT_FIELDS_ADMIN" + class="gl-table-no-top-border" + /> + </div> +</template> diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js new file mode 100644 index 00000000000..fd7ee2a6f8c --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js @@ -0,0 +1,62 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Query: { + fields: { + jobs: { + keyArgs: ['statuses'], + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + if (incoming.nodes) { + let nodes; + + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + return { + nodes: existing.nodes, + pageInfo: existing.pageInfo, + statuses: args.statuses, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql new file mode 100644 index 00000000000..374009efa15 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql @@ -0,0 +1,81 @@ +query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) { + jobs(after: $after, first: $first, statuses: $statuses) { + count + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + artifacts { + nodes { + id + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + project { + id + fullPath + webUrl + } + path + user { + id + webPath + avatarUrl + } + } + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } +} diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue deleted file mode 100644 index c5a0509b625..00000000000 --- a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue +++ /dev/null @@ -1,19 +0,0 @@ -<script> -export default { - inject: { - jobStatuses: { - default: null, - }, - url: { - default: '', - }, - emptyStateSvgPath: { - default: '', - }, - }, -}; -</script> - -<template> - <div>{{ __('Jobs') }}</div> -</template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 9df52557212..9c2a255a1a3 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,11 +1,21 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; -import { CANCEL_JOBS_MODAL_ID } from './components/constants'; -import CancelJobsModal from './components/cancel_jobs_modal.vue'; -import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue'; +import createDefaultClient from '~/lib/graphql'; +import { CANCEL_JOBS_MODAL_ID } from '../components/constants'; +import CancelJobsModal from '../components/cancel_jobs_modal.vue'; +import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue'; +import cacheConfig from '../components/table/graphql/cache_config'; Vue.use(Translate); +Vue.use(VueApollo); + +const client = createDefaultClient({}, { cacheConfig }); + +const apolloProvider = new VueApollo({ + defaultClient: client, +}); function initJobs() { const buttonId = 'js-stop-jobs-button'; @@ -44,6 +54,7 @@ export function initAdminJobsApp() { return new Vue({ el: containerEl, + apolloProvider, provide: { url, emptyStateSvgPath, diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index dec06fe6f4d..721168f6140 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -9,6 +9,7 @@ import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import initConfirmDanger from '~/init_confirm_danger'; +import { initGroupSettingsReadme } from '~/groups/settings/init_group_settings_readme'; initFilePickers(); initConfirmDanger(); @@ -27,3 +28,5 @@ initProjectSelects(); initSearchSettings(); initCascadingSettingsLockPopovers(); + +initGroupSettingsReadme(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 1b3c7ba5a52..2e71eced66f 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -23,7 +23,7 @@ const APP_OPTIONS = { requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { show: true, - tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'], + tokens: ['two_factor', 'with_inherited_permissions', 'enterprise', 'user_type'], searchParam: 'search', placeholder: s__('Members|Filter members'), recentSearchesStorageKey: 'group_members', diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index 8b68cb5f3bf..513f4968dbd 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -11,6 +11,10 @@ export default { NewNamespacePage, }, props: { + rootPath: { + type: String, + required: true, + }, groupsUrl: { type: String, required: true, @@ -44,6 +48,7 @@ export default { { text: s__('GroupsNew|New subgroup'), href: '#' }, ] : [ + { text: s__('Navigation|Your work'), href: this.rootPath }, { text: s__('GroupsNew|Groups'), href: this.groupsUrl }, { text: s__('GroupsNew|New group'), href: '#' }, ]; diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index b16c5f3da9f..6227d5ff880 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -22,6 +22,7 @@ initFilePickers(); function initNewGroupCreation(el) { const { hasErrors, + rootPath, groupsUrl, parentGroupUrl, parentGroupName, @@ -33,6 +34,7 @@ function initNewGroupCreation(el) { const props = { groupsUrl, + rootPath, parentGroupUrl, parentGroupName, importExistingGroupPath, diff --git a/app/assets/javascripts/pages/groups/runners/new/index.js b/app/assets/javascripts/pages/groups/runners/new/index.js new file mode 100644 index 00000000000..318643d95a4 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/new/index.js @@ -0,0 +1,3 @@ +import { initGroupNewRunner } from '~/ci/runner/group_new_runner'; + +initGroupNewRunner(); diff --git a/app/assets/javascripts/pages/groups/runners/register/index.js b/app/assets/javascripts/pages/groups/runners/register/index.js new file mode 100644 index 00000000000..b02e33e21f2 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/register/index.js @@ -0,0 +1,3 @@ +import { initGroupRegisterRunner } from '~/ci/runner/group_register_runner'; + +initGroupRegisterRunner(); diff --git a/app/assets/javascripts/pages/groups/settings/applications/index.js b/app/assets/javascripts/pages/groups/settings/applications/index.js new file mode 100644 index 00000000000..4dee5433ec9 --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/applications/index.js @@ -0,0 +1,3 @@ +import { initOAuthApplicationSecret } from '~/oauth_application'; + +initOAuthApplicationSecret(); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 52124865bcc..dba65c7e791 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new */ - import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; @@ -7,11 +5,11 @@ import initNotificationsDropdown from '~/notifications'; import ProjectsList from '~/projects_list'; export default function initGroupDetails() { - new ShortcutsNavigation(); + new ShortcutsNavigation(); // eslint-disable-line no-new initNotificationsDropdown(); - new ProjectsList(); + new ProjectsList(); // eslint-disable-line no-new initInviteMembersBanner(); initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index 53bceb3a6f0..f6a4ca0f360 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,5 +1,6 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; +import { initGroupReadme } from '~/groups/init_group_readme'; import initReadMore from '~/read_more'; import initGroupDetails from '../shared/group_details'; @@ -7,3 +8,4 @@ leaveByUrl('group'); initGroupDetails(); initGroupOverviewTabs(); initReadMore(); +initGroupReadme(); diff --git a/app/assets/javascripts/pages/import/github/details/index.js b/app/assets/javascripts/pages/import/github/details/index.js new file mode 100644 index 00000000000..44a85589c9d --- /dev/null +++ b/app/assets/javascripts/pages/import/github/details/index.js @@ -0,0 +1,3 @@ +import initImportDetails from '~/import/details'; + +initImportDetails(); diff --git a/app/assets/javascripts/pages/oauth/applications/index.js b/app/assets/javascripts/pages/oauth/applications/index.js new file mode 100644 index 00000000000..4dee5433ec9 --- /dev/null +++ b/app/assets/javascripts/pages/oauth/applications/index.js @@ -0,0 +1,3 @@ +import { initOAuthApplicationSecret } from '~/oauth_application'; + +initOAuthApplicationSecret(); diff --git a/app/assets/javascripts/pages/profiles/comment_templates/index.js b/app/assets/javascripts/pages/profiles/comment_templates/index.js new file mode 100644 index 00000000000..413816c29cc --- /dev/null +++ b/app/assets/javascripts/pages/profiles/comment_templates/index.js @@ -0,0 +1,3 @@ +import { initCommentTemplates } from '~/comment_templates'; + +initCommentTemplates(); diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js deleted file mode 100644 index ef227b82172..00000000000 --- a/app/assets/javascripts/pages/profiles/saved_replies/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { initSavedReplies } from '~/saved_replies'; - -initSavedReplies(); diff --git a/app/assets/javascripts/pages/projects/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js index 4aa9b225790..df8f110a60d 100644 --- a/app/assets/javascripts/pages/projects/artifacts/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/index.js @@ -1,3 +1,3 @@ -import { initArtifactsTable } from '~/artifacts/index'; +import { initArtifactsTable } from '~/ci/artifacts/index'; initArtifactsTable(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 02fcc6ea940..ec894586803 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -8,6 +8,7 @@ import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; import createDefaultClient from '~/lib/graphql'; import initBlob from '~/pages/projects/init_blob'; +import ForkInfo from '~/repository/components/fork_info.vue'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; @@ -16,6 +17,7 @@ import createStore from '~/code_navigation/store'; import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils'; import RefSelector from '~/ref/components/ref_selector.vue'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(Vuex); Vue.use(VueApollo); @@ -44,6 +46,7 @@ const initRefSwitcher = () => { projectId, value: refType ? joinPaths('refs', refType, ref) : ref, useSymbolicRefNames: true, + queryParams: { sort: 'updated_desc' }, }, on: { input(selectedRef) { @@ -58,7 +61,15 @@ const initRefSwitcher = () => { initRefSwitcher(); if (viewBlobEl) { - const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset; + const { + blobPath, + projectPath, + targetBranch, + originalBranch, + resourceId, + userId, + explainCodeAvailable, + } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ @@ -69,6 +80,9 @@ if (viewBlobEl) { provide: { targetBranch, originalBranch, + resourceId, + userId, + explainCodeAvailable: parseBoolean(explainCodeAvailable), }, render(createElement) { return createElement(BlobContentViewer, { @@ -87,6 +101,47 @@ if (viewBlobEl) { initBlob(); } +const initForkInfo = () => { + const forkEl = document.getElementById('js-fork-info'); + if (!forkEl) { + return null; + } + const { + projectPath, + selectedBranch, + sourceName, + sourcePath, + sourceDefaultBranch, + canSyncBranch, + aheadComparePath, + behindComparePath, + canUserCreateMrInFork, + createMrPath, + } = forkEl.dataset; + return new Vue({ + el: forkEl, + apolloProvider, + render(h) { + return h(ForkInfo, { + props: { + canSyncBranch: parseBoolean(canSyncBranch), + projectPath, + selectedBranch, + sourceName, + sourcePath, + sourceDefaultBranch, + aheadComparePath, + behindComparePath, + canUserCreateMrInFork, + createMrPath, + }, + }); + }, + }); +}; + +initForkInfo(); + const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); const statusLink = document.querySelector('.commit-actions .ci-status-link'); if (statusLink) { diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index f871cd804e7..9a47a720709 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -18,11 +18,7 @@ import '~/sourcegraph/load'; import DiffStats from '~/diffs/components/diff_stats.vue'; import { initReportAbuse } from '~/projects/report_abuse'; -const hasPerfBar = document.querySelector('.with-performance-bar'); -const performanceHeight = hasPerfBar ? 35 : 0; -initDiffStatsDropdown( - (document.querySelector('.navbar-gitlab')?.offsetHeight ?? 0) + performanceHeight, -); +initDiffStatsDropdown(true); new ZenMode(); new ShortcutsNavigation(); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index 760bf3f7131..5bcdd34e258 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -7,8 +7,7 @@ import syntaxHighlight from '~/syntax_highlight'; initCompareSelector(); new Diff(); // eslint-disable-line no-new -const paddingTop = 16; -initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); +initDiffStatsDropdown(true); GpgBadges.fetch(); syntaxHighlight([document.querySelector('.files')]); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index 05a1bbc69ed..fe9f0c7e69f 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ -import initCycleAnalytics from '~/analytics/cycle_analytics'; +import cycleAnalyticsAppBundle from 'ee_else_ce/analytics/cycle_analytics/bundle'; -initCycleAnalytics(); +cycleAnalyticsAppBundle(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 06dcd2c2d94..c5b63b74c35 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,8 @@ import { initForm } from 'ee_else_ce/issues'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); + +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 06dcd2c2d94..c5b63b74c35 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,8 @@ import { initForm } from 'ee_else_ce/issues'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); + +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 2718765ee23..3d81e77f879 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -3,6 +3,8 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; import CompareApp from '~/merge_requests/components/compare_app.vue'; import { __ } from '~/locale'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { @@ -82,4 +84,6 @@ if (mrNewCompareNode) { action: mrNewSubmitNode.dataset.mrSubmitAction, }); initPipelines(); + // eslint-disable-next-line no-new + new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); } diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index f8cb8b30250..6127adc3584 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,10 +1,11 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; - +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import initCheckFormState from './check_form_state'; import initFormUpdate from './update_form'; @@ -72,3 +73,5 @@ initMergeRequest(); initFormUpdate(); initCheckFormState(); initTargetBranchSelector(); +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index d4734b8842d..599fd225de9 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -4,19 +4,13 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GLForm from '~/gl_form'; import LabelsSelect from '~/labels/labels_select'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; export default () => { new ShortcutsNavigation(); - new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); IssuableLabelSelector(); new LabelsSelect(); - new IssuableTemplateSelectors({ - warnTemplateOverride: true, - }); mountMilestoneDropdown('[name="merge_request[milestone_id]"]'); }; diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index a90cabb3c68..f50763151ef 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -9,6 +9,7 @@ const initShowExperiment = () => { } const props = { + experiment: JSON.parse(element.dataset.experiment), candidates: JSON.parse(element.dataset.candidates), metricNames: JSON.parse(element.dataset.metrics), paramNames: JSON.parse(element.dataset.params), diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index 414636f0a74..a669ea5baaf 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -24,7 +24,7 @@ const initRefSwitcher = () => { }, on: { input(selectedRef) { - visitUrl(joinPaths(networkRootPath, selectedRef)); + visitUrl(joinPaths(networkRootPath, encodeURIComponent(selectedRef))); }, }, }); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 242c5a1a97b..eab4be4dcf1 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -176,7 +176,7 @@ export default { <gl-icon v-if="showDailyLimitMessage(option)" v-gl-tooltip.hover - name="question" + name="question-o" :title="scheduleDailyLimitMsg" /> </gl-form-radio> diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 5f15a11e708..e6ee6b702bb 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -3,28 +3,10 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import initClonePanel from '~/clone_panel'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { serializeForm } from '~/lib/utils/forms'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; - -const BRANCH_REF_TYPE = 'heads'; -const TAG_REF_TYPE = 'tags'; -const BRANCH_GROUP_NAME = __('Branches'); -const TAG_GROUP_NAME = __('Tags'); export default class Project { constructor() { initClonePanel(); - // Ref switcher - if (document.querySelector('.js-project-refs-dropdown')) { - Project.initRefSwitcher(); - $('.project-refs-select').on('change', function () { - return $(this).parents('form').trigger('submit'); - }); - } $('.js-hide-no-ssh-message').on('click', function (e) { setCookie('hide_no_ssh_message', 'false'); @@ -48,125 +30,4 @@ export default class Project { static changeProject(url) { return (window.location = url); } - - static initRefSwitcher() { - const refListItem = document.createElement('li'); - const refLink = document.createElement('a'); - - refLink.href = '#'; - - return $('.js-project-refs-dropdown').each(function () { - const $dropdown = $(this); - const selected = $dropdown.data('selected'); - const refType = $dropdown.data('refType'); - const fieldName = $dropdown.data('fieldName'); - const shouldVisit = Boolean($dropdown.data('visit')); - const $form = $dropdown.closest('form'); - const path = $form.find('#path').val(); - const action = $form.attr('action'); - const linkTarget = mergeUrlParams(serializeForm($form[0]), action); - - return initDeprecatedJQueryDropdown($dropdown, { - data(term, callback) { - axios - .get($dropdown.data('refsUrl'), { - params: { - ref: $dropdown.data('ref'), - search: term, - }, - }) - .then(({ data }) => callback(data)) - .catch(() => - createAlert({ - message: __('An error occurred while getting projects'), - }), - ); - }, - selectable: true, - filterable: true, - filterRemote: true, - filterByText: true, - inputFieldName: $dropdown.data('inputFieldName'), - fieldName, - renderRow(ref, _, params) { - const li = refListItem.cloneNode(false); - - const link = refLink.cloneNode(false); - - if (ref === selected) { - // Check group and current ref type to avoid adding a class when tags and branches share the same name - if ( - (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) || - (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) || - !refType - ) { - link.className = 'is-active'; - } - } - - link.textContent = ref; - link.dataset.ref = ref; - if (ref.length > 0 && shouldVisit) { - const urlParams = { [fieldName]: ref }; - if (params.group === BRANCH_GROUP_NAME) { - urlParams.ref_type = BRANCH_REF_TYPE; - } else { - urlParams.ref_type = TAG_REF_TYPE; - } - link.href = mergeUrlParams(urlParams, linkTarget); - } - - li.appendChild(link); - - return li; - }, - id(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel(obj, $el) { - return $el.text().trim(); - }, - clicked(options) { - const { e } = options; - - if (!shouldVisit) { - e.preventDefault(); - } - - // Some pages need to dynamically get the current path - // so they can opt-in to JS getting the path from the - // current URL by not setting a path in the dropdown form - if (shouldVisit && path === undefined) { - e.preventDefault(); - - const selectedUrl = new URL(e.target.href); - const loc = window.location.href; - - if (loc.includes('/-/')) { - const currentRef = $dropdown.data('ref'); - // The split and startWith is to ensure an exact word match - // and avoid partial match ie. currentRef is "dev" and loc is "development" - const splitPathAfterRefPortion = loc.split('/-/')[1].split(currentRef)[1]; - const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/'); - - if (doesPathContainRef) { - // We are ignoring the url containing the ref portion - // and plucking the thereafter portion to reconstructure the url that is correct - const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0]; - selectedUrl.searchParams.set('path', targetPath); - selectedUrl.hash = window.location.hash; - } - } - - // Open in new window if "meta" key is pressed - if (e.metaKey) { - window.open(selectedUrl.href, '_blank'); - } else { - window.location.href = selectedUrl.href; - } - } - }, - }); - }); - } } diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index d2263fa815d..087808c33da 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,3 +1,4 @@ +import 'bootstrap/js/dist/collapse'; import MirrorRepos from '~/mirrors/mirror_repos'; import mountBranchRules from '~/projects/settings/repository/branch_rules/mount_branch_rules'; import mountDefaultBranchSelector from '~/projects/settings/mount_default_branch_selector'; diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js index 885b8ca8e12..d907b8a470d 100644 --- a/app/assets/javascripts/pages/projects/usage_quotas/index.js +++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js @@ -1,9 +1,21 @@ import initProjectStorage from '~/usage_quotas/storage/init_project_storage'; import initSearchSettings from '~/search_settings'; +import { GlTabsBehavior, HISTORY_TYPE_HASH } from '~/tabs'; + +const initGlTabs = () => { + const tabsEl = document.getElementById('js-project-usage-quotas-tabs'); + if (!tabsEl) { + return; + } + + // eslint-disable-next-line no-new + new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH }); +}; const initVueApp = () => { initProjectStorage('js-project-storage-count-app'); }; +initGlTabs(); initVueApp(); initSearchSettings(); diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index b8de2757284..00f7c5d60d1 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -5,6 +5,7 @@ import LengthValidator from '~/validators/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; import EmailFormatValidator from '~/pages/sessions/new/email_format_validator'; import { initLanguageSwitcher } from '~/language_switcher'; +import { initTogglePasswordVisibility } from '~/authentication/password'; import Tracking from '~/tracking'; new UsernameValidator(); // eslint-disable-line no-new @@ -19,3 +20,4 @@ Tracking.enableFormTracking({ }); initLanguageSwitcher(); +initTogglePasswordVisibility(); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index ee48543f0d2..bad8a7cedc6 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -14,7 +14,7 @@ export default class OAuthRememberMe { } bindEvents() { - $('#remember_me', this.container).on('click', this.toggleRememberMe); + $('#remember_me_omniauth', this.container).on('click', this.toggleRememberMe); } toggleRememberMe(event) { 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 549c964cce4..3b38d715ea5 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -128,6 +128,9 @@ export default { }; }, computed: { + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, noContent() { return !this.content.trim(); }, @@ -351,6 +354,8 @@ export default { :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" :autofocus="pageInfo.persisted" + :enable-autocomplete="true" + :autocomplete-data-sources="autocompleteDataSources" :drawio-enabled="true" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" diff --git a/app/assets/javascripts/pages/time_tracking/timelogs/index.js b/app/assets/javascripts/pages/time_tracking/timelogs/index.js new file mode 100644 index 00000000000..41c78fbe3a6 --- /dev/null +++ b/app/assets/javascripts/pages/time_tracking/timelogs/index.js @@ -0,0 +1,3 @@ +import initTimelogsApp from '~/time_tracking'; + +initTimelogsApp(); 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 dbca8bc9be7..fac070d6e47 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,4 +1,5 @@ <script> +import { GlLink, GlPopover } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -10,8 +11,10 @@ import RequestSelector from './request_selector.vue'; export default { components: { + GlPopover, AddRequest, DetailedMetric, + GlLink, RequestSelector, }, directives: { @@ -30,6 +33,10 @@ export default { type: String, required: true, }, + requestMethod: { + type: String, + required: true, + }, peekUrl: { type: String, required: true, @@ -72,6 +79,11 @@ export default { keys: ['request', 'body'], }, { + metric: 'zkt', + header: s__('PerformanceBar|Zoekt calls'), + keys: ['request', 'body'], + }, + { metric: 'external-http', title: 'external', header: s__('PerformanceBar|External Http calls'), @@ -103,9 +115,6 @@ export default { this.currentRequestId = requestId; }, }, - initialRequest() { - return this.currentRequestId === this.requestId; - }, hasHost() { return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host; }, @@ -124,24 +133,47 @@ export default { const fileName = this.requests[0].displayName; return `${fileName}_perf_bar_${Date.now()}.json`; }, + showZoekt() { + return document.body.dataset.page === 'search:show'; + }, + showFlamegraphButtons() { + return this.isGetRequest(this.currentRequestId); + }, + showMemoryReportButton() { + return this.isGetRequest(this.currentRequestId) && this.env === 'development'; + }, memoryReportPath() { - return mergeUrlParams({ performance_bar: 'memory' }, window.location.href); + return mergeUrlParams( + { performance_bar: 'memory' }, + this.store.findRequest(this.currentRequestId).fullUrl, + ); }, }, + created() { + if (!this.showZoekt) { + this.$options.detailedMetrics = this.$options.detailedMetrics.filter( + (item) => item.metric !== 'zkt', + ); + } + }, mounted() { this.currentRequest = this.requestId; }, methods: { + glEmojiTag, changeCurrentRequest(newRequestId) { this.currentRequest = newRequestId; this.$emit('change-request', newRequestId); }, - flamegraphPath(mode) { + flamegraphPath(mode, requestId) { return mergeUrlParams( { performance_bar: 'flamegraph', stackprof_mode: mode }, - window.location.href, + this.store.findRequest(requestId).fullUrl, ); }, + isGetRequest(requestId) { + return this.store.findRequest(requestId)?.method?.toUpperCase() === 'GET'; + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; @@ -159,8 +191,17 @@ export default { class="current-host" :class="{ canary: currentRequest.details.host.canary }" > - <span v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span> - {{ currentRequest.details.host.hostname }} + <span id="canary-emoji" v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span> + <gl-popover placement="bottom" target="canary-emoji" content="Canary" /> + <span + id="host-emoji" + v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('computer')" + ></span> + <gl-popover + placement="bottom" + target="host-emoji" + :content="currentRequest.details.host.hostname" + /> </span> </div> <detailed-metric @@ -177,41 +218,45 @@ export default { id="peek-view-trace" class="view" > - <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{ + <gl-link class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|Trace') - }}</a> + }}</gl-link> </div> <div v-if="currentRequest.details" id="peek-download" class="view"> - <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{ - s__('PerformanceBar|Download') - }}</a> + <gl-link + class="gl-text-blue-200" + is-unsafe-link + :download="downloadName" + :href="downloadPath" + >{{ s__('PerformanceBar|Download') }}</gl-link + > </div> - <div - v-if="currentRequest.details && env === 'development'" - id="peek-memory-report" - class="view" - > - <a class="gl-text-blue-200" :href="memoryReportPath">{{ + <div v-if="showMemoryReportButton" id="peek-memory-report" class="view"> + <gl-link class="gl-text-blue-200" :href="memoryReportPath">{{ s__('PerformanceBar|Memory report') - }}</a> + }}</gl-link> </div> - <div v-if="currentRequest.details" id="peek-flamegraph" class="view"> - <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span> - <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{ + <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view"> + <span id="flamegraph-emoji" class="gl-text-white-200"> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('fire')"></span> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('bar_chart')"></span> + </span> + <gl-popover placement="bottom" target="flamegraph-emoji" content="Flamegraph" /> + <gl-link class="gl-text-blue-200" :href="flamegraphPath('wall', currentRequestId)">{{ s__('PerformanceBar|wall') - }}</a> + }}</gl-link> / - <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{ + <gl-link class="gl-text-blue-200" :href="flamegraphPath('cpu', currentRequestId)">{{ s__('PerformanceBar|cpu') - }}</a> + }}</gl-link> / - <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{ + <gl-link class="gl-text-blue-200" :href="flamegraphPath('object', currentRequestId)">{{ s__('PerformanceBar|object') - }}</a> + }}</gl-link> </div> - <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ + <gl-link v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ s__('PerformanceBar|Stats') - }}</a> + }}</gl-link> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 84fe14fe056..e3e48e61393 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -32,15 +32,21 @@ const initPerformanceBar = (el) => { store, env: performanceBarData.env, requestId: performanceBarData.requestId, + requestMethod: performanceBarData.requestMethod, peekUrl: performanceBarData.peekUrl, - profileUrl: performanceBarData.profileUrl, statsUrl: performanceBarData.statsUrl, }; }, mounted() { PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest); - this.addRequest(this.requestId, window.location.href); + this.addRequest( + this.requestId, + window.location.href, + undefined, + undefined, + this.requestMethod, + ); this.loadRequestDetails(this.requestId); }, beforeDestroy() { @@ -56,12 +62,12 @@ const initPerformanceBar = (el) => { this.addRequest(urlOrRequestId, urlOrRequestId); } }, - addRequest(requestId, requestUrl, operationName) { + addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) { if (!this.store.canTrackRequest(requestUrl)) { return; } - this.store.addRequest(requestId, requestUrl, operationName); + this.store.addRequest(requestId, requestUrl, operationName, requestParams, methodVerb); }, loadRequestDetails(requestId) { const request = this.store.findRequest(requestId); @@ -145,8 +151,8 @@ const initPerformanceBar = (el) => { store: this.store, env: this.env, requestId: this.requestId, + requestMethod: this.requestMethod, peekUrl: this.peekUrl, - profileUrl: this.profileUrl, statsUrl: this.statsUrl, }, on: { diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js index e67143f3ede..3a9788d8ab6 100644 --- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -14,11 +14,13 @@ export default class PerformanceBarService { fireCallback, requestId, requestUrl, + requestParams, operationName, + methodVerb, ] = PerformanceBarService.callbackParams(response, peekUrl); if (fireCallback) { - callback(requestId, requestUrl, operationName); + callback(requestId, requestUrl, operationName, requestParams, methodVerb); } return response; @@ -35,11 +37,14 @@ export default class PerformanceBarService { static callbackParams(response, peekUrl) { const requestId = response.headers && response.headers['x-request-id']; const requestUrl = response.config?.url; + const requestParams = response.config?.params; + const methodVerb = response.config?.method; + const cachedResponse = response.headers && parseBoolean(response.headers['x-gitlab-from-cache']); const fireCallback = requestUrl !== peekUrl && Boolean(requestId) && !cachedResponse; const operationName = response.config?.operationName; - return [fireCallback, requestId, requestUrl, operationName]; + return [fireCallback, requestId, requestUrl, requestParams, operationName, methodVerb]; } } diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js index 2011604534c..34e2763a478 100644 --- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -1,11 +1,22 @@ +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + export default class PerformanceBarStore { constructor() { this.requests = []; } - addRequest(requestId, requestUrl, operationName) { - if (!this.findRequest(requestId)) { - let displayName = PerformanceBarStore.truncateUrl(requestUrl); + addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) { + if (this.findRequest(requestId)) { + this.updateRequestBatchedQueriesCount(requestId); + } else { + let displayName = ''; + + if (methodVerb) { + displayName += `${methodVerb.toUpperCase()} `; + } + + displayName += PerformanceBarStore.truncateUrl(requestUrl); if (operationName) { displayName += ` (${operationName})`; @@ -14,13 +25,31 @@ export default class PerformanceBarStore { this.requests.push({ id: requestId, url: requestUrl, + fullUrl: mergeUrlParams(requestParams, requestUrl), + method: methodVerb, details: {}, + queriesInBatch: 1, // only for GraphQL displayName, }); } return this.requests; } + updateRequestBatchedQueriesCount(requestId) { + const existingRequest = this.findRequest(requestId); + existingRequest.queriesInBatch += 1; + + const oldDisplayName = existingRequest.displayName; + const regex = /\d+ queries batched/; + if (regex.test(oldDisplayName)) { + existingRequest.displayName = oldDisplayName.replace( + regex, + `${existingRequest.queriesInBatch} queries batched`, + ); + } else { + existingRequest.displayName += __(` [${existingRequest.queriesInBatch} queries batched]`); + } + } findRequest(requestId) { return this.requests.find((request) => request.id === requestId); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 3130fe42c3c..c9f43e43b2d 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -24,6 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-geo-migrate-hashed-storage-callout', '.js-unlimited-members-during-trial-alert', '.js-branch-rules-info-callout', + '.js-license-check-deprecation-alert', ]; const initCallouts = () => { 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 8f76d7535f1..83cd64c17ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -255,7 +255,7 @@ export default { this.canRefetchHeaderPipeline = true; this.$apollo.queries.headerPipeline.refetch(); }, - /* eslint-disable @gitlab/require-i18n-strings */ + // eslint-disable-next-line @gitlab/require-i18n-strings reportFailure({ type, err = 'No error string passed.', skipSentry = false }) { this.showAlert = true; this.alertType = type; @@ -263,7 +263,6 @@ export default { reportToSentry(this.$options.name, `type: ${type}, info: ${err}`); } }, - /* eslint-enable @gitlab/require-i18n-strings */ updateShowLinksState(val) { this.showLinks = val; }, 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 6d8c35f4482..73143c981ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -62,6 +62,7 @@ export default { }, showTip() { return ( + this.showLinksToggle && this.showLinks && this.showLinksActive && !this.tipPreviouslyDismissed && diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 3da792cb9df..54985a24593 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -35,7 +35,6 @@ const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => { return layers; }; -/* eslint-disable @gitlab/require-i18n-strings */ const getQueryHeaders = (etagResource) => { return { fetchOptions: { @@ -52,6 +51,7 @@ const getQueryHeaders = (etagResource) => { const serializeGqlErr = (gqlError) => { const { locations = [], message = '', path = [] } = gqlError; + // eslint-disable-next-line @gitlab/require-i18n-strings return ` ${message}. Locations: ${locations @@ -74,14 +74,12 @@ const serializeLoadErrors = (errors) => { } if (!isEmpty(networkError)) { - return `Network error: ${networkError.message}`; + return `Network error: ${networkError.message}`; // eslint-disable-line @gitlab/require-i18n-strings } return message; }; -/* eslint-enable @gitlab/require-i18n-strings */ - const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { const stopStartQuery = (query) => { if (!Visibility.hidden()) { diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue index 16f6aa5aaa4..21b585933b8 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue @@ -13,7 +13,7 @@ export default { FailedJobsTable, }, inject: { - fullPath: { + projectPath: { default: '', }, pipelineIid: { @@ -31,7 +31,7 @@ export default { query: GetFailedJobsQuery, variables() { return { - fullPath: this.fullPath, + fullPath: this.projectPath, pipelineIid: this.pipelineIid, }; }, diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue index 661de43fe3c..61748860983 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -17,7 +17,7 @@ export default { JobsTable, }, inject: { - fullPath: { + projectPath: { default: '', }, pipelineIid: { @@ -56,7 +56,7 @@ export default { computed: { queryVariables() { return { - fullPath: this.fullPath, + fullPath: this.projectPath, iid: this.pipelineIid, }; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index 03a2eac89e4..a6297213402 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -1,19 +1,8 @@ <script> -import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { - STARTER_TEMPLATE_NAME, - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, - I18N, -} from '~/ci/pipeline_editor/constants'; +import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants'; import Tracking from '~/tracking'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { isExperimentVariant } from '~/experimentation/utils'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import CiTemplates from './ci_templates.vue'; export default { @@ -21,19 +10,12 @@ export default { GlButton, GlCard, GlSprintf, - GlIcon, - GlLink, - GitlabExperiment, CiTemplates, }, mixins: [Tracking.mixin()], STARTER_TEMPLATE_NAME, - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, - inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'], + inject: ['pipelineEditorPath'], data() { return { gettingStartedTemplateUrl: mergeUrlParams( @@ -43,26 +25,12 @@ export default { tracker: null, }; }, - computed: { - sharedRunnersHelpPagePath() { - return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' }); - }, - runnersAvailabilitySectionExperimentEnabled() { - return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME); - }, - }, - created() { - this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME); - }, methods: { trackEvent(template) { this.track('template_clicked', { label: template, }); }, - trackExperimentEvent(action) { - this.tracker.event(action); - }, }, }; </script> @@ -70,92 +38,42 @@ export default { <div> <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2> - <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME"> - <template #candidate> - <div v-if="anyRunnersAvailable"> - <h2 class="gl-font-base gl-text-gray-900"> - <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" /> - {{ $options.I18N.runners.title }} - </h2> - <p class="gl-text-gray-800 gl-mb-6"> - <gl-sprintf :message="$options.I18N.runners.subtitle"> - <template #settingsLink="{ content }"> - <gl-link - data-testid="settings-link" - :href="ciRunnerSettingsPath" - @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)" - >{{ content }}</gl-link - > - </template> - <template #docsLink="{ content }"> - <gl-link - data-testid="documentation-link" - :href="sharedRunnersHelpPagePath" - @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </p> - </div> - - <div v-else> - <h2 class="gl-font-base gl-text-gray-900"> - <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" /> - {{ $options.I18N.noRunners.title }} - </h2> - <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p> - <gl-button - data-testid="settings-button" - category="primary" - variant="confirm" - :href="ciRunnerSettingsPath" - @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)" - > - {{ $options.I18N.noRunners.cta }} - </gl-button> - </div> - </template> - </gitlab-experiment> - - <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable"> - <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2> - <p class="gl-text-gray-800 gl-mb-6"> - <gl-sprintf :message="$options.I18N.learnBasics.subtitle"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.I18N.learnBasics.subtitle"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> - <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> - <gl-card> - <div class="gl-flex-direction-row"> - <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> - <div class="gl-mb-3"> - <strong class="gl-text-gray-800 gl-mb-2"> - {{ $options.I18N.learnBasics.gettingStarted.title }} - </strong> - </div> - <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> + <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> + <gl-card> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2"> + {{ $options.I18N.learnBasics.gettingStarted.title }} + </strong> </div> + <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> + </div> - <gl-button - category="primary" - variant="confirm" - :href="gettingStartedTemplateUrl" - data-testid="test-template-link" - @click="trackEvent($options.STARTER_TEMPLATE_NAME)" - > - {{ $options.I18N.learnBasics.gettingStarted.cta }} - </gl-button> - </gl-card> - </div> + <gl-button + category="primary" + variant="confirm" + :href="gettingStartedTemplateUrl" + data-testid="test-template-link" + @click="trackEvent($options.STARTER_TEMPLATE_NAME)" + > + {{ $options.I18N.learnBasics.gettingStarted.cta }} + </gl-button> + </gl-card> + </div> - <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> - <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> - <ci-templates /> - </template> + <ci-templates /> </div> </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 dd62ffb27f7..caeee7edefe 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -36,12 +36,10 @@ export default { }; }, computed: { - actions() { - if (!this.pipeline || !this.pipeline.details) { - return []; - } - const { details } = this.pipeline; - return [...(details.manual_actions || []), ...(details.scheduled_actions || [])]; + hasActions() { + return ( + this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions + ); }, isCancelling() { return this.cancelingPipeline === this.pipeline.id; @@ -75,7 +73,7 @@ export default { <template> <div class="gl-text-right"> <div class="btn-group"> - <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" /> + <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" /> <gl-button v-if="pipeline.flags.retryable" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index fe2ef2c2d71..7ad12d397e5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -144,14 +144,16 @@ export default { <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate"> <gl-link :href="commitUrl" - class="commit-row-message gl-text-gray-900" + class="commit-row-message gl-font-weight-bold gl-text-gray-900" data-testid="commit-title" @click="trackClick('click_commit_title')" >{{ commitTitle }}</gl-link > </tooltip-on-truncate> </span> - <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span> + <span v-else class="gl-text-gray-500">{{ + __("Can't find HEAD commit for this branch") + }}</span> </div> <div class="gl-mb-2"> <gl-link diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 640129b9c4c..c5537b7ad54 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -346,7 +346,7 @@ export default { </div> <div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex"> - <div class="row-content-block gl-display-flex gl-flex-grow-1"> + <div class="row-content-block gl-display-flex gl-flex-grow-1 gl-border-b-0"> <pipelines-filtered-search class="gl-display-flex gl-flex-grow-1 gl-mr-4" :project-id="projectId" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue index 50d34070e61..262e82677a7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -8,8 +8,10 @@ import Tracking from '~/tracking'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; +import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql'; export default { + name: 'PipelinesManualActions', directives: { GlTooltip: GlTooltipDirective, }, @@ -18,22 +20,52 @@ export default { GlDropdown, GlDropdownItem, GlIcon, + GlLoadingIcon, }, mixins: [Tracking.mixin()], + inject: ['fullPath', 'manualActionsLimit'], props: { - actions: { - type: Array, + iid: { + type: Number, required: true, }, }, + apollo: { + actions: { + query: getPipelineActionsQuery, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + limit: this.manualActionsLimit, + }; + }, + skip() { + return !this.hasDropdownBeenShown; + }, + update({ project }) { + return project?.pipeline?.jobs?.nodes || []; + }, + }, + }, data() { return { isLoading: false, + actions: [], + hasDropdownBeenShown: false, }; }, + computed: { + isActionsLoading() { + return this.$apollo.queries.actions.loading; + }, + isDropdownLimitReached() { + return this.actions.length === this.manualActionsLimit; + }, + }, methods: { async onClickAction(action) { - if (action.scheduled_at) { + if (action.scheduledAt) { const confirmationMessage = sprintf( s__( 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', @@ -54,12 +86,12 @@ export default { * Ideally, the component would not make an api call directly. * However, in order to use the eventhub and know when to * toggle back the `isLoading` property we'd need an ID - * to track the request with a wacther - since this component + * to track the request with a watcher - since this component * is rendered at least 20 times in the same page, moving the * api call directly here is the most performant solution */ axios - .post(`${action.path}.json`) + .post(`${action.playPath}.json`) .then(() => { this.isLoading = false; eventHub.$emit('updateTable'); @@ -69,12 +101,12 @@ export default { createAlert({ message: __('An error occurred while making the request.') }); }); }, - isActionDisabled(action) { - if (action.playable === undefined) { - return false; - } + fetchActions() { + this.hasDropdownBeenShown = true; + + this.$apollo.queries.actions.refetch(); - return !action.playable; + this.trackClick(); }, trackClick() { this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table }); @@ -91,21 +123,37 @@ export default { right lazy icon="play" - @shown="trackClick" + @shown="fetchActions" > + <gl-dropdown-item v-if="isActionsLoading"> + <div class="gl-display-flex"> + <gl-loading-icon class="mr-2" /> + <span>{{ __('Loading...') }}</span> + </div> + </gl-dropdown-item> + <gl-dropdown-item v-for="action in actions" - :key="action.path" - :disabled="isActionDisabled(action)" + v-else + :key="action.id" + :disabled="!action.canPlayJob" @click="onClickAction(action)" > <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> {{ action.name }} - <span v-if="action.scheduled_at"> + <span v-if="action.scheduledAt"> <gl-icon name="clock" /> - <gl-countdown :end-date-string="action.scheduled_at" /> + <gl-countdown :end-date-string="action.scheduledAt" /> </span> </div> </gl-dropdown-item> + + <template #footer> + <gl-dropdown-item v-if="isDropdownLimitReached"> + <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg"> + {{ __('Showing first 50 actions.') }} + </span> + </gl-dropdown-item> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 365572f194b..b2da0df17c0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -15,7 +15,7 @@ import PipelinesStatusBadge from './pipelines_status_badge.vue'; const DEFAULT_TD_CLASS = 'gl-p-5!'; const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!'; + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 960af030421..e15676849da 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -41,7 +41,7 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-direction-column time-ago"> + <div class="gl-display-flex gl-flex-direction-column gl-font-sm time-ago"> <span v-if="showInProgress" class="gl-display-inline-flex gl-align-items-center" diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql new file mode 100644 index 00000000000..d1878c01e91 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql @@ -0,0 +1,24 @@ +query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs( + first: $limit + whenExecuted: ["manual", "delayed"] + retried: false + statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED] + ) { + nodes { + id + name + canPlayJob + manualJob + scheduledAt + scheduled + playPath + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index d94602c23b4..27debec7bb3 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -29,7 +29,7 @@ export const createAppOptions = (selector, apolloProvider, router) => { exposeLicenseScanningData, failedJobsCount, failedJobsSummary, - fullPath, + projectPath, graphqlResourceEtag, pipelineIid, pipelineProjectPath, @@ -50,8 +50,6 @@ export const createAppOptions = (selector, apolloProvider, router) => { testsCount, } = dataset; - // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved - const projectPath = fullPath; const defaultTabValue = getPipelineDefaultTab(window.location.href); return { @@ -83,7 +81,6 @@ export const createAppOptions = (selector, apolloProvider, router) => { exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData), failedJobsCount, failedJobsSummary: JSON.parse(failedJobsSummary), - fullPath, graphqlResourceEtag, pipelineIid, pipelineProjectPath, diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 6dccdb1a3e6..49e2e1644e2 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -1,5 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean, historyReplaceState, @@ -13,6 +15,11 @@ import PipelinesStore from './stores/pipelines_store'; Vue.use(Translate); Vue.use(GlToast); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const el = document.querySelector(selector); @@ -38,22 +45,22 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { projectId, defaultBranchName, params, - ciRunnerSettingsPath, - anyRunnersAvailable, iosRunnersAvailable, registrationToken, + fullPath, } = el.dataset; return new Vue({ el, + apolloProvider, provide: { pipelineEditorPath, artifactsEndpoint, artifactsEndpointPlaceholder, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), - ciRunnerSettingsPath, - anyRunnersAvailable: parseBoolean(anyRunnersAvailable), iosRunnersAvailable: parseBoolean(iosRunnersAvailable), + fullPath, + manualActionsLimit: 50, }, data() { return { diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue index 76fb13919df..21f8a2d3500 100644 --- a/app/assets/javascripts/profile/components/overview_tab.vue +++ b/app/assets/javascripts/profile/components/overview_tab.vue @@ -1,18 +1,44 @@ <script> -import { GlTab } from '@gitlab/ui'; +import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; import ActivityCalendar from './activity_calendar.vue'; export default { i18n: { title: s__('UserProfile|Overview'), + personalProjects: s__('UserProfile|Personal projects'), + viewAll: s__('UserProfile|View all'), + }, + components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList }, + props: { + personalProjects: { + type: Array, + required: true, + }, + personalProjectsLoading: { + type: Boolean, + required: true, + }, }, - components: { GlTab, ActivityCalendar }, }; </script> <template> <gl-tab :title="$options.i18n.title"> <activity-calendar /> + <div class="gl-mx-n3 gl-display-flex gl-flex-wrap"> + <div class="gl-px-3 gl-w-full gl-lg-w-half"></div> + <div class="gl-px-3 gl-w-full gl-lg-w-half" data-testid="personal-projects-section"> + <div + class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" + > + <h4 class="gl-flex-grow-1">{{ $options.i18n.personalProjects }}</h4> + <gl-link href="">{{ $options.i18n.viewAll }}</gl-link> + </div> + <gl-loading-icon v-if="personalProjectsLoading" class="gl-mt-5" size="md" /> + <projects-list v-else :projects="personalProjects" /> + </div> + </div> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue index b39bfabb832..25b94d7dc7f 100644 --- a/app/assets/javascripts/profile/components/profile_tabs.vue +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -1,6 +1,10 @@ <script> import { GlTabs } from '@gitlab/ui'; +import { getUserProjects } from '~/rest_api'; +import { s__ } from '~/locale'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; import OverviewTab from './overview_tab.vue'; import ActivityTab from './activity_tab.vue'; import GroupsTab from './groups_tab.vue'; @@ -12,6 +16,11 @@ import FollowersTab from './followers_tab.vue'; import FollowingTab from './following_tab.vue'; export default { + i18n: { + personalProjectsErrorMessage: s__( + 'UserProfile|An error occurred loading the personal projects. Please refresh the page to try again.', + ), + }, components: { GlTabs, OverviewTab, @@ -62,6 +71,22 @@ export default { component: FollowingTab, }, ], + inject: ['userId'], + data() { + return { + personalProjectsLoading: true, + personalProjects: [], + }; + }, + async mounted() { + try { + const response = await getUserProjects(this.userId, { per_page: 10 }); + this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true }); + this.personalProjectsLoading = false; + } catch (error) { + createAlert({ message: this.$options.i18n.personalProjectsErrorMessage }); + } + }, }; </script> @@ -72,6 +97,8 @@ export default { v-for="{ key, component } in $options.tabs" :key="key" class="container-fluid container-limited" + :personal-projects="personalProjects" + :personal-projects-loading="personalProjectsLoading" /> </gl-tabs> </template> diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 050b004f657..107bfd159dd 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -3,6 +3,8 @@ import $ from 'jquery'; import 'cropper'; import { isString } from 'lodash'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; import { loadCSSFile } from '../lib/utils/css_utils'; (() => { @@ -139,11 +141,20 @@ import { loadCSSFile } from '../lib/utils/css_utils'; } readFile(input) { - const _this = this; const reader = new FileReader(); reader.onload = () => { - _this.modalCropImg.attr('src', reader.result); - return _this.modalCrop.modal('show'); + this.modalCropImg.attr('src', reader.result); + import(/* webpackChunkName: 'bootstrapModal' */ 'bootstrap/js/dist/modal') + .then(() => { + this.modalCrop.modal('show'); + }) + .catch(() => { + createAlert({ + message: s__( + 'UserProfile|Failed to set avatar. Please reload the page to try again.', + ), + }); + }); }; return reader.readAsDataURL(input.files[0]); } diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js index fbe0e3534d8..101e52c873e 100644 --- a/app/assets/javascripts/profile/index.js +++ b/app/assets/javascripts/profile/index.js @@ -13,15 +13,17 @@ export const initProfileTabs = () => { if (!el) return false; - const { followees, followers, userCalendarPath, utcOffset } = el.dataset; + const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset; return new Vue({ el, + name: 'ProfileRoot', provide: { followees: parseInt(followers, 10), followers: parseInt(followees, 10), userCalendarPath, utcOffset, + userId, }, render(createElement) { return createElement(ProfileTabs); diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue index 0fd31381ba6..a4edc988d67 100644 --- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue @@ -93,7 +93,7 @@ export default { data-testid="email-patches-link" data-qa-selector="email_patches" > - {{ s__('DownloadCommit|Email Patches') }} + {{ __('Patches') }} </gl-dropdown-item> <gl-dropdown-item :href="plainDiffPath" diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js index f56884f605f..3179fcb14fd 100644 --- a/app/assets/javascripts/projects/commits/index.js +++ b/app/assets/javascripts/projects/commits/index.js @@ -35,6 +35,10 @@ export const initCommitsRefSwitcher = () => { const { projectId, ref, commitsPath, refType } = el.dataset; const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0]; + const generateRefDestinationUrl = (selectedRef, selectedRefType) => { + const commitsPathSuffix = selectedRefType ? `?ref_type=${selectedRefType}` : ''; + return `${commitsPathPrefix}/${encodeURIComponent(selectedRef)}${commitsPathSuffix}`; + }; const useSymbolicRefNames = Boolean(refType); return new Vue({ el, @@ -48,15 +52,11 @@ export const initCommitsRefSwitcher = () => { }, on: { input(selected) { - if (useSymbolicRefNames) { - const matches = selected.match(/refs\/(heads|tags)\/(.+)/); - if (matches) { - visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`); - } else { - visitUrl(`${commitsPathPrefix}/${selected}`); - } + const matches = selected.match(/refs\/(heads|tags)\/(.+)/); + if (useSymbolicRefNames && matches) { + visitUrl(generateRefDestinationUrl(matches[2], matches[1])); } else { - visitUrl(`${commitsPathPrefix}/${selected}`); + visitUrl(generateRefDestinationUrl(selected)); } }, }, diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index a44855c14d5..fb201576e85 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -121,4 +121,8 @@ export default { text: s__('ProjectTemplates|TYPO3 Distribution'), icon: '.template-option .icon-typo3', }, + laravel: { + text: s__('ProjectTemplates|Laravel Framework'), + icon: '.template-option .icon-laravel', + }, }; diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 1599661505f..0a160a357e5 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -59,6 +59,10 @@ export default { SafeHtml, }, props: { + rootPath: { + type: String, + required: true, + }, projectsUrl: { type: String, required: true, @@ -92,12 +96,14 @@ export default { computed: { initialBreadcrumbs() { - return [ - this.parentGroupUrl - ? { text: this.parentGroupName, href: this.parentGroupUrl } - : { text: s__('ProjectsNew|Projects'), href: this.projectsUrl }, - { text: s__('ProjectsNew|New project'), href: '#' }, - ]; + const breadcrumbs = this.parentGroupUrl + ? [{ text: this.parentGroupName, href: this.parentGroupUrl }] + : [ + { text: s__('Navigation|Your work'), href: this.rootPath }, + { text: s__('ProjectsNew|Projects'), href: this.projectsUrl }, + ]; + breadcrumbs.push({ text: s__('ProjectsNew|New project'), href: '#' }); + return breadcrumbs; }, availablePanels() { return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL); diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index 7330874eefe..5ec50355a82 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -18,6 +18,7 @@ export function initNewProjectCreation() { parentGroupUrl, parentGroupName, projectsUrl, + rootPath, } = el.dataset; const props = { @@ -27,6 +28,7 @@ export function initNewProjectCreation() { parentGroupUrl, parentGroupName, projectsUrl, + rootPath, }; const provide = { diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index b0abe7ac463..dbcb77b67f3 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -187,6 +187,7 @@ export default { :roles="pushAccessLevels.roles" :users="pushAccessLevels.users" :groups="pushAccessLevels.groups" + data-qa-selector="allowed_to_push_content" /> <!-- Allowed to merge --> @@ -197,6 +198,7 @@ export default { :roles="mergeAccessLevels.roles" :users="mergeAccessLevels.users" :groups="mergeAccessLevels.groups" + data-qa-selector="allowed_to_merge_content" /> <!-- Force push --> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue index 721248e53e3..3a5b3409596 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue @@ -101,7 +101,13 @@ export default { <div v-if="statusCheckUrl" class="gl-ml-7 gl-flex-grow-1">{{ statusCheckUrl }}</div> - <div v-for="(item, index) in accessLevels" :key="index" data-testid="access-level"> + <div + v-for="(item, index) in accessLevels" + :key="index" + data-testid="access-level" + data-qa-selector="access_level_content" + :data-qa-role="item.accessLevelDescription" + > <span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span> {{ item.accessLevelDescription }} </div> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index 7709419b6f8..dcf5155644d 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -69,9 +69,14 @@ export default { <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div> - <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{ - $options.i18n.addBranchRule - }}</gl-button> + <gl-button + v-gl-modal="$options.modalId" + class="gl-mt-5" + data-qa-selector="add_branch_rule_button" + category="secondary" + variant="info" + >{{ $options.i18n.addBranchRule }}</gl-button + > <gl-modal :ref="$options.modalId" diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index b565bda247d..a5ff478a826 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -153,7 +153,11 @@ export default { </script> <template> - <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"> + <div + class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between" + data-qa-selector="branch_content" + :data-qa-branch-name="name" + > <div> <strong class="gl-font-monospace">{{ name }}</strong> @@ -169,7 +173,7 @@ export default { <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> </ul> </div> - <gl-button class="gl-align-self-start" :href="detailsPath"> + <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath"> {{ $options.i18n.detailsButtonLabel }}</gl-button > </div> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index b79b3fa4573..79ece99e6ec 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -8,7 +8,7 @@ import ServiceDeskSetting from './service_desk_setting.vue'; export default { customEmailHelpPath: helpPagePath('/user/project/service_desk.html', { - anchor: 'using-a-custom-email-address', + anchor: 'use-a-custom-email-address', }), components: { GlAlert, diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 85550e262e6..8af2e787740 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -102,12 +102,12 @@ export default { }, emailSuffixHelpUrl() { return helpPagePath('user/project/service_desk.html', { - anchor: 'configuring-a-custom-email-address-suffix', + anchor: 'configure-a-custom-email-address-suffix', }); }, customEmailAddressHelpUrl() { return helpPagePath('user/project/service_desk.html', { - anchor: 'using-a-custom-email-address', + anchor: 'use-a-custom-email-address', }); }, }, diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js index b5d00cb7e82..5342874250c 100644 --- a/app/assets/javascripts/protected_branches/constants.js +++ b/app/assets/javascripts/protected_branches/constants.js @@ -9,3 +9,9 @@ export const LEVEL_TYPES = { GROUP: 'group', DEPLOY_KEY: 'deploy_key', }; + +export const BRANCH_RULES_ANCHOR = '#branch-rules'; + +export const IS_PROTECTED_BRANCH_CREATED = 'is_protected_branch_created'; + +export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings'; diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index cd37c0de6a5..cdbe39fd5e0 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,17 +1,24 @@ import $ from 'jquery'; import CreateItemDropdown from '~/create_item_dropdown'; -import { createAlert } from '~/alert'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import AccessDropdown from '~/projects/settings/access_dropdown'; import { initToggle } from '~/toggles'; -import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; +import { expandSection } from '~/settings_panels'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { + BRANCH_RULES_ANCHOR, + PROTECTED_BRANCHES_ANCHOR, + IS_PROTECTED_BRANCH_CREATED, + ACCESS_LEVELS, + LEVEL_TYPES, +} from './constants'; export default class ProtectedBranchCreate { constructor(options) { this.hasLicense = options.hasLicense; - this.$form = $('.js-new-protected-branch'); this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); this.currentProjectUserDefaults = {}; @@ -22,7 +29,7 @@ export default class ProtectedBranchCreate { if (this.hasLicense) { this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle')); } - + this.showSuccessAlertIfNeeded(); this.bindEvents(); } @@ -81,6 +88,49 @@ export default class ProtectedBranchCreate { callback(gon.open_branches); } + // eslint-disable-next-line class-methods-use-this + expandAndScroll(anchor) { + expandSection(anchor); + scrollToElement(anchor); + } + + hasProtectedBranchSuccessAlert() { + return ( + window.gon?.features?.branchRules && + this.isLocalStorageAvailable && + localStorage.getItem(IS_PROTECTED_BRANCH_CREATED) + ); + } + + createSuccessAlert() { + this.alert = createAlert({ + variant: VARIANT_SUCCESS, + containerSelector: '.js-alert-protected-branch-created-container', + title: s__('ProtectedBranch|View protected branches as branch rules'), + message: s__('ProtectedBranch|Manage branch related settings in one area with branch rules.'), + primaryButton: { + text: s__('ProtectedBranch|View branch rule'), + clickHandler: () => { + this.expandAndScroll(BRANCH_RULES_ANCHOR); + }, + }, + secondaryButton: { + text: __('Dismiss'), + clickHandler: () => this.alert.dismiss(), + }, + }); + } + + showSuccessAlertIfNeeded() { + if (!this.hasProtectedBranchSuccessAlert()) { + return; + } + this.expandAndScroll(PROTECTED_BRANCHES_ANCHOR); + + this.createSuccessAlert(); + localStorage.removeItem(IS_PROTECTED_BRANCH_CREATED); + } + getFormData() { const formData = { authenticity_token: this.$form.find('input[name="authenticity_token"]').val(), @@ -127,6 +177,9 @@ export default class ProtectedBranchCreate { axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData()) .then(() => { + if (this.isLocalStorageAvailable) { + localStorage.setItem(IS_PROTECTED_BRANCH_CREATED, 'true'); + } window.location.reload(); }) .catch(() => diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 9826124912b..9a84726d42f 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -42,6 +42,11 @@ export default { required: false, default: '', }, + queryParams: { + type: Object, + required: false, + default: () => {}, + }, refType: { type: String, required: false, @@ -93,6 +98,7 @@ export default { matches: (state) => state.matches, lastQuery: (state) => state.query, selectedRef: (state) => state.selectedRef, + params: (state) => state.params, }), ...mapGetters(['isLoading', 'isQueryPossiblyASha']), i18n() { @@ -186,6 +192,7 @@ export default { this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); + this.setParams(this.queryParams); this.$watch( 'enabledRefTypes', @@ -206,6 +213,7 @@ export default { ...mapActions([ 'setEnabledRefTypes', 'setUseSymbolicRefNames', + 'setParams', 'setProjectId', 'setSelectedRef', ]), diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js index a6019f21e73..3d6b46abf52 100644 --- a/app/assets/javascripts/ref/stores/actions.js +++ b/app/assets/javascripts/ref/stores/actions.js @@ -5,6 +5,8 @@ import * as types from './mutation_types'; export const setEnabledRefTypes = ({ commit }, refTypes) => commit(types.SET_ENABLED_REF_TYPES, refTypes); +export const setParams = ({ commit }, params) => commit(types.SET_PARAMS, params); + export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) => commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames); @@ -29,7 +31,7 @@ export const search = ({ state, dispatch, commit }, query) => { export const searchBranches = ({ commit, state }) => { commit(types.REQUEST_START); - Api.branches(state.projectId, state.query) + Api.branches(state.projectId, state.query, state.params) .then((response) => { commit(types.RECEIVE_BRANCHES_SUCCESS, response); }) diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js index 2bebffc19ab..fb2196fa1d0 100644 --- a/app/assets/javascripts/ref/stores/index.js +++ b/app/assets/javascripts/ref/stores/index.js @@ -14,3 +14,11 @@ export default () => mutations, state: createState(), }); + +export const createRefModule = () => ({ + namespaced: true, + actions, + getters, + mutations, + state: createState(), +}); diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js index 4c602908cae..6178106fe00 100644 --- a/app/assets/javascripts/ref/stores/mutation_types.js +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -1,5 +1,6 @@ export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES'; export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES'; +export const SET_PARAMS = 'SET_PARAMS'; export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_SELECTED_REF = 'SET_SELECTED_REF'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js index 9846ac0adb7..43c4318ad6c 100644 --- a/app/assets/javascripts/ref/stores/mutations.js +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -10,6 +10,9 @@ export default { [types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) { state.useSymbolicRefNames = useSymbolicRefNames; }, + [types.SET_PARAMS](state, params) { + state.params = params; + }, [types.SET_PROJECT_ID](state, projectId) { state.projectId = projectId; }, diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js index 3affa8f8d03..1619b43c02e 100644 --- a/app/assets/javascripts/ref/stores/state.js +++ b/app/assets/javascripts/ref/stores/state.js @@ -15,5 +15,6 @@ export default () => ({ commits: createRefTypeState(), }, selectedRef: null, + params: null, requestCount: 0, }); diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index 043d925198c..24b350c7f18 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -194,9 +194,11 @@ export default { 'gl-border-b-1': isOpen, 'gl-border-b-0': !isOpen, }" - class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100" + class="gl-display-flex gl-justify-content-space-between gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100" > - <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1"> + <h3 + class="card-title h5 gl-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24" + > <gl-link id="user-content-related-issues" class="anchor position-absolute gl-text-decoration-none" diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue new file mode 100644 index 00000000000..44269bccec9 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_create.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { uniqueId } from 'lodash'; +import { __, s__ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlFormTextarea, + RefSelector, + }, + model: { + prop: 'value', + event: 'change', + }, + props: { + value: { type: String, required: true }, + }, + data() { + return { + nameId: uniqueId('tag-name-'), + refId: uniqueId('ref-'), + messageId: uniqueId('message-'), + }; + }, + computed: { + ...mapState('editNew', ['projectId', 'release', 'createFrom']), + }, + methods: { + ...mapActions('editNew', ['updateReleaseTagMessage', 'updateCreateFrom']), + }, + i18n: { + tagNameLabel: __('Tag name'), + refLabel: __('Create from'), + messageLabel: s__('CreateGitTag|Set tag message'), + messagePlaceholder: s__( + 'CreateGitTag|Add a message to the tag. Leaving this blank creates a lightweight tag.', + ), + create: __('Save'), + cancel: s__('Release|Select another tag'), + refSelector: { + noRefSelected: __('No source selected'), + searchPlaceholder: __('Search branches, tags, and commits'), + dropdownHeader: __('Select source'), + }, + }, +}; +</script> +<template> + <div class="gl-p-3" data-testid="create-from-field"> + <gl-form-group + class="gl-mb-3" + :label="$options.i18n.tagNameLabel" + :label-for="nameId" + label-sr-only + > + <gl-form-input :id="nameId" :value="value" autofocus @input="$emit('change', $event)" /> + </gl-form-group> + <gl-form-group class="gl-mb-3" :label="$options.i18n.refLabel" :label-for="refId" label-sr-only> + <ref-selector + :id="refId" + :project-id="projectId" + :value="createFrom" + :translations="$options.i18n.refSelector" + @input="updateCreateFrom" + /> + </gl-form-group> + <gl-form-group + class="gl-mb-3" + :label="$options.i18n.messageLabel" + :label-for="messageId" + label-sr-only + > + <gl-form-textarea + :id="messageId" + :placeholder="$options.i18n.messagePlaceholder" + :no-resize="false" + :value="release.tagMessage" + @input="updateReleaseTagMessage" + /> + </gl-form-group> + <gl-button class="gl-mr-3" variant="confirm" @click="$emit('create')"> + {{ $options.i18n.create }} + </gl-button> + <gl-button @click="$emit('cancel')">{{ $options.i18n.cancel }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 2ac61988393..ec058cc3603 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -1,225 +1,133 @@ <script> -import { - GlCollapse, - GlLink, - GlFormGroup, - GlFormTextarea, - GlDropdownItem, - GlSprintf, -} from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__ } from '~/locale'; -import RefSelector from '~/ref/components/ref_selector.vue'; -import { REF_TYPE_TAGS } from '~/ref/constants'; -import FormFieldContainer from './form_field_container.vue'; + +import TagSearch from './tag_search.vue'; +import TagCreate from './tag_create.vue'; export default { - name: 'TagFieldNew', components: { - GlCollapse, + GlDropdown, GlFormGroup, - GlFormTextarea, - GlLink, - RefSelector, - FormFieldContainer, - GlDropdownItem, - GlSprintf, + GlPopover, + TagSearch, + TagCreate, }, data() { - return { - // Keeps track of whether or not the user has interacted with - // the input field. This is used to avoid showing validation - // errors immediately when the page loads. - isInputDirty: false, - }; + return { id: 'release-tag-name', newTagName: '', show: false, isInputDirty: false }; }, computed: { - ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']), - ...mapGetters('editNew', ['validationErrors']), - tagName: { - get() { - return this.release.tagName; - }, - set(tagName) { - this.updateReleaseTagName(tagName); - - // This setter is used by the `v-model` on the `RefSelector`. - // 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.updateShowCreateFrom(false); - }, - }, - tagMessage: { - get() { - return this.release.tagMessage; - }, - set(tagMessage) { - this.updateReleaseTagMessage(tagMessage); - }, - }, - createFromModel: { - get() { - return this.createFrom; - }, - set(createFrom) { - this.updateCreateFrom(createFrom); - }, + ...mapState('editNew', ['release', 'showCreateFrom']), + ...mapGetters('editNew', ['validationErrors', 'isSearching', 'isCreating']), + title() { + return this.isCreating ? this.$options.i18n.createTitle : this.$options.i18n.selectTitle; }, showTagNameValidationError() { return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid; }, - tagNameInputId() { - return uniqueId('tag-name-input-'); - }, - createFromSelectorId() { - return uniqueId('create-from-selector-'); - }, tagFeedback() { return this.validationErrors.tagNameValidation.validationErrors[0]; }, + buttonText() { + return this.release?.tagName || s__('Release|Search or create tag name'); + }, + buttonVariant() { + return this.showTagNameValidationError ? 'danger' : 'default'; + }, + createText() { + return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew; + }, }, methods: { ...mapActions('editNew', [ + 'setSearching', + 'setCreating', + 'setNewTag', + 'setExistingTag', 'updateReleaseTagName', - 'updateReleaseTagMessage', - 'updateCreateFrom', 'fetchTagNotes', - 'updateShowCreateFrom', ]), - markInputAsDirty() { - this.isInputDirty = true; + startCreate(query) { + this.newTagName = query; + this.setCreating(); }, - createTagClicked(newTagName) { - this.updateReleaseTagName(newTagName); + selected(tag) { + this.updateReleaseTagName(tag); - // 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.updateShowCreateFrom(true); - }, - shouldShowCreateTagOption(isLoading, matches, query) { - // Show the "create tag" option if: - return ( - // we're not currently loading any results, and - !isLoading && - // the search query isn't just whitespace, and - query.trim() && - // the `matches` object is non-null, and - matches && - // the tag name doesn't already exist - !matches.tags.list.some( - (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(), - ) - ); + if (this.isSearching) { + this.fetchTagNotes(tag); + this.setExistingTag(); + this.newTagName = ''; + } else { + this.setNewTag(); + } + + this.hidePopover(); }, - }, - translations: { - tagName: { - noRefSelected: __('No tag selected'), - dropdownHeader: __('Tag name'), - searchPlaceholder: __('Search or create tag'), - label: __('Tag name'), - labelDescription: __('*Required'), + markInputAsDirty() { + this.isInputDirty = true; }, - createFrom: { - noRefSelected: __('No source selected'), - searchPlaceholder: __('Search branches, tags, and commits'), - dropdownHeader: __('Select source'), - label: __('Create from'), - description: __('Existing branch name, tag, or commit SHA'), + showPopover() { + this.show = true; }, - annotatedTag: { - label: s__('CreateGitTag|Set tag message'), - description: s__( - 'CreateGitTag|Add a message to the tag. Leaving this blank creates a %{linkStart}lightweight tag%{linkEnd}.', - ), + hidePopover() { + this.show = false; }, }, - tagMessageId: uniqueId('tag-message-'), - - tagNameEnabledRefTypes: [REF_TYPE_TAGS], - gitTagDocsLink: 'https://git-scm.com/book/en/v2/Git-Basics-Tagging/', + i18n: { + selectTitle: __('Tags'), + createTitle: s__('Release|Create tag'), + label: __('Tag name'), + required: __('(required)'), + create: __('Create'), + cancel: __('Cancel'), + }, }; </script> <template> - <div> + <div class="row"> <gl-form-group - data-testid="tag-name-field" + class="col-md-4 col-sm-10" + :label="$options.i18n.label" + :label-for="id" + :optional-text="$options.i18n.required" :state="!showTagNameValidationError" :invalid-feedback="tagFeedback" - :label="$options.translations.tagName.label" - :label-for="tagNameInputId" - :label-description="$options.translations.tagName.labelDescription" + optional + data-testid="tag-name-field" > - <form-field-container> - <ref-selector - :id="tagNameInputId" - v-model="tagName" - :project-id="projectId" - :translations="$options.translations.tagName" - :enabled-ref-types="$options.tagNameEnabledRefTypes" - :state="!showTagNameValidationError" - @input="fetchTagNotes" - @hide.once="markInputAsDirty" - > - <template #footer="{ isLoading, matches, query }"> - <gl-dropdown-item - v-if="shouldShowCreateTagOption(isLoading, matches, query)" - is-check-item - :is-checked="tagName === query" - @click="createTagClicked(query)" - > - <gl-sprintf :message="__('Create tag %{tagName}')"> - <template #tagName> - <b>{{ query }}</b> - </template> - </gl-sprintf> - </gl-dropdown-item> - </template> - </ref-selector> - </form-field-container> + <gl-dropdown + :id="id" + :variant="buttonVariant" + :text="buttonText" + :toggle-class="['gl-text-gray-900!']" + category="secondary" + class="gl-w-30" + @show.prevent="showPopover" + /> + <gl-popover + :show="show" + :target="id" + :title="title" + :css-classes="['gl-z-index-200', 'release-tag-selector']" + placement="bottom" + triggers="manual" + container="content-body" + show-close-button + @close-button-clicked="hidePopover" + @hide.once="markInputAsDirty" + > + <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200"> + <tag-create + v-if="isCreating" + v-model="newTagName" + @create="selected(newTagName)" + @cancel="setSearching" + /> + <tag-search v-else v-model="newTagName" @create="startCreate" @select="selected" /> + </div> + </gl-popover> </gl-form-group> - <gl-collapse :visible="showCreateFrom"> - <div class="gl-pl-6 gl-border-l-1 gl-border-l-solid gl-border-gray-300"> - <gl-form-group - v-if="showCreateFrom" - :label="$options.translations.createFrom.label" - :label-for="createFromSelectorId" - data-testid="create-from-field" - > - <form-field-container> - <ref-selector - :id="createFromSelectorId" - v-model="createFromModel" - :project-id="projectId" - :translations="$options.translations.createFrom" - /> - </form-field-container> - <template #description>{{ $options.translations.createFrom.description }}</template> - </gl-form-group> - <gl-form-group - v-if="showCreateFrom" - :label="$options.translations.annotatedTag.label" - :label-for="$options.tagMessageId" - data-testid="annotated-tag-message-field" - > - <gl-form-textarea :id="$options.tagMessageId" v-model="tagMessage" /> - <template #description> - <gl-sprintf :message="$options.translations.annotatedTag.description"> - <template #link="{ content }"> - <gl-link - :href="$options.gitTagDocsLink" - rel="noopener noreferrer" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </template> - </gl-form-group> - </div> - </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue new file mode 100644 index 00000000000..33b44c90e1f --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_search.vue @@ -0,0 +1,121 @@ +<script> +import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { debounce } from 'lodash'; +import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants'; +import { __, s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlDropdownItem, + GlSearchBoxByType, + GlSprintf, + }, + model: { + prop: 'query', + event: 'change', + }, + props: { + query: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { tagName: '' }; + }, + computed: { + ...mapState('ref', ['matches']), + ...mapState('editNew', ['projectId', 'release']), + tags() { + return this.matches?.tags?.list || []; + }, + createText() { + return this.query ? this.$options.i18n.createTag : this.$options.i18n.typeNew; + }, + selectedNotShown() { + return this.release.tagName && !this.tags.some((tag) => tag.name === this.release.tagName); + }, + }, + created() { + this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); + }, + mounted() { + this.setProjectId(this.projectId); + this.setEnabledRefTypes([REF_TYPE_TAGS]); + this.search(this.query); + }, + methods: { + ...mapActions('ref', ['setEnabledRefTypes', 'setProjectId', 'search']), + onSearchBoxInput(searchQuery = '') { + const query = searchQuery.trim(); + this.$emit('change', query); + this.debouncedSearch(query); + }, + selected(tagName) { + return (this.release?.tagName ?? '') === tagName; + }, + }, + i18n: { + noResults: __('No results found'), + createTag: s__('Release|Create tag %{tag}'), + typeNew: s__('Release|Or type a new tag name'), + }, +}; +</script> +<template> + <div data-testid="tag-name-search"> + <gl-search-box-by-type + :value="query" + class="gl-border-b-solid gl-border-b-1 gl-border-gray-200" + borderless + autofocus + @input="onSearchBoxInput" + /> + <div class="gl-overflow-y-auto release-tag-list"> + <div v-if="tags.length || release.tagName"> + <gl-dropdown-item + v-if="selectedNotShown" + is-checked + is-check-item + class="gl-list-style-none" + > + {{ release.tagName }} + </gl-dropdown-item> + <gl-dropdown-item + v-for="tag in tags" + :key="tag.name" + :is-checked="selected(tag.name)" + is-check-item + class="gl-list-style-none" + @click="$emit('select', tag.name)" + > + {{ tag.name }} + </gl-dropdown-item> + </div> + <div + v-else + class="gl-my-5 gl-text-gray-500 gl-display-flex gl-font-base gl-justify-content-center" + > + {{ $options.i18n.noResults }} + </div> + </div> + <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200 gl-py-3"> + <gl-button + category="tertiary" + class="gl-justify-content-start! gl-rounded-0!" + block + :disabled="!query" + @click="$emit('create', query)" + > + <gl-sprintf :message="createText"> + <template #tag> + <span class="gl-font-weight-bold">{{ query }}</span> + </template> + </gl-sprintf> + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index 0a3f8b5e63b..efd82edcdf0 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { createRefModule } from '../ref/stores'; import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; import createEditNewModule from './stores/modules/edit_new'; @@ -12,6 +13,7 @@ export default () => { const store = createStore({ modules: { editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }), + ref: createRefModule(), }, }); 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 a7d8825ed33..f5191e000f7 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -274,3 +274,9 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => { }); }); }; + +export const setSearching = ({ commit }) => commit(types.SET_SEARCHING); +export const setCreating = ({ commit }) => commit(types.SET_CREATING); + +export const setExistingTag = ({ commit }) => commit(types.SET_EXISTING_TAG); +export const setNewTag = ({ commit }) => commit(types.SET_NEW_TAG); diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/constants.js b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js new file mode 100644 index 00000000000..0f12f150525 --- /dev/null +++ b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js @@ -0,0 +1,4 @@ +export const SEARCH = 'SEARCH'; +export const CREATE = 'CREATE'; +export const EXISTING_TAG = 'EXISTING_TAG'; +export const NEW_TAG = 'NEW_TAG'; 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 8ff479058f2..edf6c81c9e9 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -4,6 +4,7 @@ import { hasContent } from '~/lib/utils/text_utility'; import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility'; import { validateTag, ValidationResult } from '~/lib/utils/ref_validator'; import { i18n } from '~/releases/constants'; +import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants'; /** * @param {Object} link The link to test @@ -169,10 +170,23 @@ export const releaseDeleteMutationVariables = (state) => ({ }, }); -export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) => - includeTagNotes && tagNotes - ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n` +export const formattedReleaseNotes = ({ + includeTagNotes, + release: { description, tagMessage }, + tagNotes, + showCreateFrom, +}) => { + const notes = showCreateFrom ? tagMessage : tagNotes; + return includeTagNotes && notes + ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n` : description; +}; export const releasedAtChanged = ({ originalReleasedAt, release }) => originalReleasedAt !== release.releasedAt; + +export const isSearching = ({ step }) => step === SEARCH; +export const isCreating = ({ step }) => step === CREATE; + +export const isExistingTag = ({ tagStep }) => tagStep === EXISTING_TAG; +export const isNewTag = ({ tagStep }) => tagStep === NEW_TAG; 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 e52eccd6a21..fc450970cde 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 @@ -29,3 +29,9 @@ 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'; + +export const SET_SEARCHING = 'SET_SEARCHING'; +export const SET_CREATING = 'SET_CREATING'; + +export const SET_EXISTING_TAG = 'SET_EXISTING_TAG'; +export const SET_NEW_TAG = 'SET_NEW_TAG'; 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 ccd168aafc9..7ff18245a80 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -1,6 +1,7 @@ import { uniqueId, cloneDeep } from 'lodash'; import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants'; import * as types from './mutation_types'; +import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants'; const findReleaseLink = (release, id) => { return release.assets.links.find((l) => l.id === id); @@ -127,4 +128,17 @@ export default { [types.UPDATE_RELEASED_AT](state, releasedAt) { state.release.releasedAt = releasedAt; }, + + [types.SET_SEARCHING](state) { + state.step = SEARCH; + }, + [types.SET_CREATING](state) { + state.step = CREATE; + }, + [types.SET_EXISTING_TAG](state) { + state.tagStep = EXISTING_TAG; + }, + [types.SET_NEW_TAG](state) { + state.tagStep = NEW_TAG; + }, }; 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 3112becfa9e..7bd3968dd93 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -1,3 +1,5 @@ +import { SEARCH, EXISTING_TAG } from './constants'; + export default ({ isExistingRelease, projectId, @@ -62,4 +64,6 @@ export default ({ includeTagNotes: false, existingRelease: null, originalReleasedAt: new Date(), + step: SEARCH, + tagStep: EXISTING_TAG, }); diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 236351005e7..334e7964bc2 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -34,12 +34,14 @@ export default { ForkSuggestion, WebIdeLink, CodeIntelligence, + AiGenie: () => import('ee_component/ai/components/ai_genie.vue'), }, mixins: [getRefMixin, glFeatureFlagMixin()], inject: { originalBranch: { default: '', }, + explainCodeAvailable: { default: false }, }, apollo: { projectInfo: { @@ -142,6 +144,9 @@ export default { }; }, computed: { + shouldRenderGenie() { + return this.explainCodeAvailable; + }, isLoggedIn() { return isLoggedIn(); }, @@ -316,9 +321,9 @@ export default { </script> <template> - <div> + <div class="gl-relative"> <gl-loading-icon v-if="isLoading" size="sm" /> - <div v-if="blobInfo && !isLoading" class="file-holder"> + <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder"> <blob-header :blob="blobInfo" :hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs" @@ -393,5 +398,11 @@ export default { :wrap-text-nodes="glFeatures.highlightJs" /> </div> + <ai-genie + v-if="shouldRenderGenie" + container-id="fileHolder" + :file-path="path" + class="gl-ml-7" + /> </div> </template> diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 1a834ba1d82..f3dbf98312e 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -1,13 +1,15 @@ <script> import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; -import { createAlert } from '~/alert'; +import { createAlert, VARIANT_INFO } from '~/alert'; import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import eventHub from '../event_hub'; import { POLLING_INTERVAL_DEFAULT, POLLING_INTERVAL_BACKOFF, FIVE_MINUTES_IN_MS, + FORK_UPDATED_EVENT, } from '../constants'; import forkDetailsQuery from '../queries/fork_details.query.graphql'; import ConflictsModal from './fork_sync_conflicts_modal.vue'; @@ -22,7 +24,11 @@ export const i18n = { behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'), limitedVisibility: s__('ForksDivergence|Source project has a limited visibility.'), error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'), - sync: s__('ForksDivergence|Update fork'), + updateFork: s__('ForksDivergence|Update fork'), + createMergeRequest: s__('ForksDivergence|Create merge request'), + successMessage: s__( + 'ForksDivergence|Successfully fetched and merged from the upstream repository.', + ), }; export default { @@ -55,7 +61,16 @@ export default { }); }, result({ loading }) { - this.handlePolingInterval(loading); + if (!loading && this.isSyncing) { + this.increasePollInterval(); + } + if (this.isForkUpdated) { + createAlert({ + message: this.$options.i18n.successMessage, + variant: VARIANT_INFO, + }); + eventHub.$emit(FORK_UPDATED_EVENT); + } }, pollInterval() { return this.pollInterval; @@ -86,6 +101,11 @@ export default { required: false, default: '', }, + canSyncBranch: { + type: Boolean, + required: false, + default: false, + }, aheadComparePath: { type: String, required: false, @@ -96,12 +116,21 @@ export default { required: false, default: '', }, + createMrPath: { + type: String, + required: false, + default: '', + }, + canUserCreateMrInFork: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { project: {}, currentPollInterval: null, - isSyncTriggered: false, }; }, computed: { @@ -126,6 +155,9 @@ export default { isSyncing() { return this.forkDetails?.isSyncing; }, + isForkUpdated() { + return this.isUpToDate && this.currentPollInterval; + }, ahead() { return this.project?.forkDetails?.ahead; }, @@ -163,12 +195,16 @@ export default { hasBehindAheadMessage() { return this.behindAheadMessage.length > 0; }, - isSyncButtonAvailable() { + hasUpdateButton() { return ( this.glFeatures.synchronizeFork && + this.canSyncBranch && ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence) ); }, + hasCreateMrButton() { + return this.canUserCreateMrInFork && this.ahead && this.createMrPath; + }, forkDivergenceMessage() { if (!this.forkDetails) { return this.$options.i18n.limitedVisibility; @@ -186,9 +222,8 @@ export default { }, watch: { hasConflicts(newVal) { - if (newVal && this.isSyncTriggered) { + if (newVal && this.currentPollInterval) { this.showConflictsModal(); - this.isSyncTriggered = false; } }, }, @@ -227,7 +262,6 @@ export default { this.$refs.modal.show(); }, startSyncing() { - this.isSyncTriggered = true; this.syncForkWithPolling(); }, checkIfSyncIsPossible() { @@ -237,18 +271,11 @@ export default { this.startSyncing(); } }, - handlePolingInterval(loading) { - if (!loading && this.isSyncing) { - const backoff = POLLING_INTERVAL_BACKOFF; - const interval = this.currentPollInterval; - const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS); - this.currentPollInterval = this.currentPollInterval - ? newInterval - : POLLING_INTERVAL_DEFAULT; - } - if (this.currentPollInterval === FIVE_MINUTES_IN_MS) { - this.$apollo.queries.forkDetailsQuery.stopPolling(); - } + increasePollInterval() { + const backoff = POLLING_INTERVAL_BACKOFF; + const interval = this.currentPollInterval; + const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS); + this.currentPollInterval = this.currentPollInterval ? newInterval : POLLING_INTERVAL_DEFAULT; }, }, }; @@ -283,16 +310,29 @@ export default { > {{ $options.i18n.inaccessibleProject }} </div> - <gl-button - v-if="isSyncButtonAvailable" - :disabled="forkDetails.isSyncing" - @click="checkIfSyncIsPossible" - > - <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" /> - <span>{{ $options.i18n.sync }}</span> - </gl-button> + <div class="gl-display-flex gl-xs-display-none!"> + <gl-button + v-if="hasCreateMrButton" + class="gl-ml-4" + :href="createMrPath" + data-testid="create-mr-button" + > + <span>{{ $options.i18n.createMergeRequest }}</span> + </gl-button> + <gl-button + v-if="hasUpdateButton" + class="gl-ml-4" + :disabled="forkDetails.isSyncing" + data-testid="update-fork-button" + @click="checkIfSyncIsPossible" + > + <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" /> + <span>{{ $options.i18n.updateFork }}</span> + </gl-button> + </div> <conflicts-modal ref="modal" + :selected-branch="selectedBranch" :source-name="sourceName" :source-path="sourcePath" :source-default-branch="sourceDefaultBranch" diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue index 0bfb90bb3ec..ffe4fd4cd38 100644 --- a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue +++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue @@ -19,10 +19,9 @@ export const i18n = { "ForksDivergence|Fetch the latest changes from the upstream repository's default branch:", ), step2Text: s__( - "ForksDivergence|Check out to a new branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.", + "ForksDivergence|Check out to a branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.", ), step3Text: s__('ForksDivergence|Push the updates to remote:'), - step4Text: s__("ForksDivergence|Create a merge request to your project's default branch."), copyToClipboard: __('Copy to clipboard'), close: __('Close'), }; @@ -53,12 +52,20 @@ export default { required: false, default: '', }, + selectedBranch: { + type: String, + required: true, + default: '', + }, }, computed: { instructionsStep1() { const baseUrl = getBaseURL(); return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`; }, + instructionsStep2() { + return `git checkout ${this.selectedBranch}\ngit merge FETCH_HEAD`; + }, }, methods: { show() { @@ -69,9 +76,7 @@ export default { }, }, i18n, - instructionsStep2: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD', - instructionsStep2Clipboard: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD', - instructionsStep3: 'git commit\ngit push', + instructionsStep3: 'git push', }; </script> <template> @@ -100,14 +105,12 @@ export default { <b> {{ $options.i18n.step2 }}</b> {{ $options.i18n.step2Text }} </p> <div class="gl-display-flex gl-mb-4"> - <pre - class="gl-w-full gl-mb-0 gl-mr-3" - data-testid="resolve-conflict-instructions" - v-html="$options.instructionsStep2 /* eslint-disable-line vue/no-v-html */" - ></pre> + <pre class="gl-w-full gl-mb-0 gl-mr-3" data-testid="resolve-conflict-instructions">{{ + instructionsStep2 + }}</pre> <modal-copy-button modal-id="fork-sync-conflicts-modal" - :text="$options.instructionsStep2Clipboard" + :text="instructionsStep2" :title="$options.i18n.copyToClipboard" class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" /> @@ -127,9 +130,6 @@ export default { class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3" /> </div> - <p> - <b> {{ $options.i18n.step4 }}</b> {{ $options.i18n.step4Text }} - </p> <template #modal-footer> <gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button> </template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 2d2e21dfd92..82dd1fda2a0 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -12,6 +12,8 @@ import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_ima import SignatureBadge from '~/commit/components/signature_badge.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; +import eventHub from '../event_hub'; +import { FORK_UPDATED_EVENT } from '../constants'; export default { components: { @@ -97,10 +99,19 @@ export default { this.commit = null; }, }, + mounted() { + eventHub.$on(FORK_UPDATED_EVENT, this.refetchLastCommit); + }, + beforeDestroy() { + eventHub.$off(FORK_UPDATED_EVENT, this.refetchLastCommit); + }, methods: { toggleShowDescription() { this.showDescription = !this.showDescription; }, + refetchLastCommit() { + this.$apollo.queries.commit.refetch(); + }, }, defaultAvatarUrl, safeHtmlConfig: { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index a6191203b2f..b711f671850 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -112,3 +112,5 @@ export const POLLING_INTERVAL_DEFAULT = 2500; export const POLLING_INTERVAL_BACKOFF = 2; export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal'; + +export const FORK_UPDATED_EVENT = 'fork:updated'; diff --git a/app/assets/javascripts/repository/event_hub.js b/app/assets/javascripts/repository/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/repository/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 0db9dcb43df..5a3958d8e4a 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -32,7 +32,16 @@ Vue.use(PerformancePlugin, { export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; - const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; + const { + projectPath, + projectShortPath, + ref, + escapedRef, + fullName, + resourceId, + userId, + explainCodeAvailable, + } = dataset; const router = createRouter(projectPath, escapedRef); apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -70,11 +79,15 @@ export default function setupVueRepositoryList() { return null; } const { + selectedBranch, sourceName, sourcePath, sourceDefaultBranch, + createMrPath, + canSyncBranch, aheadComparePath, behindComparePath, + canUserCreateMrInFork, } = forkEl.dataset; return new Vue({ el: forkEl, @@ -82,13 +95,16 @@ export default function setupVueRepositoryList() { render(h) { return h(ForkInfo, { props: { + canSyncBranch: parseBoolean(canSyncBranch), projectPath, - selectedBranch: ref, + selectedBranch, sourceName, sourcePath, sourceDefaultBranch, aheadComparePath, behindComparePath, + createMrPath, + canUserCreateMrInFork, }, }); }, @@ -138,6 +154,7 @@ export default function setupVueRepositoryList() { projectId, value: refType ? joinPaths('refs', refType, ref) : ref, useSymbolicRefNames: true, + queryParams: { sort: 'updated_desc' }, }, on: { input(selectedRef) { @@ -151,8 +168,8 @@ export default function setupVueRepositoryList() { initLastCommitApp(); initBlobControlsApp(); - initForkInfo(); initRefSwitcher(); + initForkInfo(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); @@ -273,6 +290,7 @@ export default function setupVueRepositoryList() { store: createStore(), router, apolloProvider, + provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) }, render(h) { return h(App); }, diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue deleted file mode 100644 index 3ad5642afc7..00000000000 --- a/app/assets/javascripts/saved_replies/components/list_item.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlButton, GlModal, GlModalDirective, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql'; - -export default { - components: { - GlButton, - GlModal, - GlSprintf, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - props: { - reply: { - type: Object, - required: true, - }, - }, - data() { - return { - isDeleting: false, - modalId: uniqueId('delete-saved-reply-'), - }; - }, - computed: { - id() { - return getIdFromGraphQLId(this.reply.id); - }, - }, - methods: { - onDelete() { - this.isDeleting = true; - - this.$apollo.mutate({ - mutation: deleteSavedReplyMutation, - variables: { - id: this.reply.id, - }, - update: (cache) => { - const cacheId = cache.identify(this.reply); - cache.evict({ id: cacheId }); - }, - }); - }, - }, - actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } }, - actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } }, -}; -</script> - -<template> - <li class="gl-mb-5"> - <div class="gl-display-flex gl-align-items-center"> - <strong data-testid="saved-reply-name">{{ reply.name }}</strong> - <div class="gl-ml-auto"> - <gl-button - v-gl-tooltip - :to="{ name: 'edit', params: { id: id } }" - icon="pencil" - :title="__('Edit')" - :aria-label="__('Edit')" - class="gl-mr-3" - data-testid="saved-reply-edit-btn" - /> - <gl-button - v-gl-modal="modalId" - v-gl-tooltip - icon="remove" - :aria-label="__('Delete')" - :title="__('Delete')" - variant="danger" - category="secondary" - data-testid="saved-reply-delete-btn" - :loading="isDeleting" - /> - </div> - </div> - <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div> - <gl-modal - :title="__('Delete saved reply')" - :action-primary="$options.actionPrimary" - :action-secondary="$options.actionSecondary" - :modal-id="modalId" - size="sm" - @primary="onDelete" - > - <gl-sprintf - :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')" - > - <template #name - ><strong>{{ reply.name }}</strong></template - > - </gl-sprintf> - </gl-modal> - </li> -</template> diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index d71785d7fac..1e4b1e36514 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -15,6 +15,7 @@ export const initSearchApp = () => { const store = createStore({ query, navigation, + useNewNavigation: gon.use_new_navigation, }); initTopbar(store); diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 60de63c7d7a..317145d4cd1 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -1,21 +1,23 @@ <script> import { mapState, mapGetters } from 'vuex'; import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue'; +import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants'; import ResultsFilters from './results_filters.vue'; -import LanguageFilter from './language_filter.vue'; +import LanguageFilter from './language_filter/index.vue'; export default { name: 'GlobalSearchSidebar', components: { ResultsFilters, ScopeNavigation, + ScopeNewNavigation, LanguageFilter, + SidebarPortal, }, - mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['urlQuery']), + ...mapState(['urlQuery', 'useNewNavigation']), ...mapGetters(['currentScope']), showIssueAndMergeFilters() { return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS; @@ -23,12 +25,23 @@ export default { showBlobFilter() { return this.currentScope === SCOPE_BLOB; }, + showOldNavigation() { + return Boolean(this.currentScope); + }, }, }; </script> <template> + <section v-if="useNewNavigation"> + <sidebar-portal> + <scope-new-navigation /> + <results-filters v-if="showIssueAndMergeFilters" /> + <language-filter v-if="showBlobFilter" /> + </sidebar-portal> + </section> <section + v-else class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5" > <scope-navigation /> diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue index f7873a994aa..feff3f77dd2 100644 --- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue @@ -1,10 +1,15 @@ <script> +import Vue from 'vue'; import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { intersection } from 'lodash'; +import Tracking from '~/tracking'; import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants'; import { formatSearchResultCount } from '../../store/utils'; +export const TRACKING_LABEL_SET = 'set'; +export const TRACKING_LABEL_CHECKBOX = 'checkbox'; + export default { name: 'CheckboxFilter', components: { @@ -16,9 +21,13 @@ export default { type: Object, required: true, }, + trackingNamespace: { + type: String, + required: true, + }, }, computed: { - ...mapState(['query']), + ...mapState(['query', 'useNewNavigation']), ...mapGetters(['queryLanguageFilters']), dataFilters() { return Object.values(this.filtersData?.filters || []); @@ -30,8 +39,11 @@ export default { get() { return intersection(this.flatDataFilterValues, this.queryLanguageFilters); }, - set(value) { + async set(value) { this.setQuery({ key: this.filtersData?.filterParam, value }); + + await Vue.nextTick(); + this.trackSelectCheckbox(); }, }, labelCountClasses() { @@ -40,9 +52,15 @@ export default { }, methods: { ...mapActions(['setQuery']), - getFormatedCount(count) { + getFormattedCount(count) { return formatSearchResultCount(count); }, + trackSelectCheckbox() { + Tracking.event(this.trackingNamespace, TRACKING_LABEL_CHECKBOX, { + label: TRACKING_LABEL_SET, + property: this.selectedFilter, + }); + }, }, NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES, @@ -51,7 +69,7 @@ export default { <template> <div class="gl-mx-5"> - <h5 class="gl-mt-0">{{ filtersData.header }}</h5> + <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filtersData.header }}</h5> <gl-form-checkbox-group v-model="selectedFilter"> <gl-form-checkbox v-for="f in dataFilters" @@ -67,7 +85,7 @@ export default { {{ f.label }} </span> <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount"> - {{ getFormatedCount(f.count) }} + {{ getFormattedCount(f.count) }} </span> </span> </gl-form-checkbox> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index e7aa3d61409..56e44d454a1 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -1,5 +1,7 @@ <script> +import { mapState } from 'vuex'; import { confidentialFilterData } from '../constants/confidential_filter_data'; +import { HR_DEFAULT_CLASSES } from '../constants'; import RadioFilter from './radio_filter.vue'; export default { @@ -7,13 +9,17 @@ export default { components: { RadioFilter, }, + computed: { + ...mapState(['useNewNavigation']), + }, confidentialFilterData, + HR_DEFAULT_CLASSES, }; </script> <template> <div> <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" /> - <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> + <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/components/language_filter/data.js index df44a58a14b..df44a58a14b 100644 --- a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js +++ b/app/assets/javascripts/search/sidebar/components/language_filter/data.js diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index b2f8d3e1f5f..40b50f657f0 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -3,10 +3,18 @@ import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data'; -import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants'; -import { convertFiltersData } from '../utils'; -import CheckboxFilter from './checkbox_filter.vue'; +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; +import { convertFiltersData } from '../../utils'; +import CheckboxFilter from '../checkbox_filter.vue'; +import { + trackShowMore, + trackShowHasOverMax, + trackSubmitQuery, + trackResetQuery, + TRACKING_ACTION_SELECT, +} from './tracking'; + +import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data'; export default { name: 'LanguageFilter', @@ -30,7 +38,7 @@ export default { reset: s__('GlobalSearch|Reset filters'), }, computed: { - ...mapState(['aggregations', 'sidebarDirty']), + ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']), ...mapGetters([ 'languageAggregationBuckets', 'currentUrlQueryHasLanguageFilters', @@ -76,11 +84,21 @@ export default { ]), onShowMore() { this.showAll = true; + trackShowMore(); + + if (this.hasOverMax) { + trackShowHasOverMax(); + } + }, + submitQuery() { + trackSubmitQuery(); + this.applyQuery(); }, trimBuckets(length) { return this.languageAggregationBuckets.slice(0, length); }, cleanResetFilters() { + trackResetQuery(); if (this.currentUrlQueryHasLanguageFilters) { return this.resetLanguageQueryWithRedirect(); } @@ -89,6 +107,7 @@ export default { }, }, HR_DEFAULT_CLASSES, + TRACKING_ACTION_SELECT, }; </script> @@ -96,15 +115,18 @@ export default { <gl-form v-if="hasBuckets" class="gl-pt-5 gl-md-pt-0 language-filter-checkbox" - @submit.prevent="applyQuery" + @submit.prevent="submitQuery" > - <hr :class="dividerClasses" /> + <hr v-if="!useNewNavigation" :class="dividerClasses" /> <div v-if="!aggregations.error" class="gl-overflow-x-hidden gl-overflow-y-auto" :class="{ 'language-filter-max-height': showAll }" > - <checkbox-filter :filters-data="filtersData" /> + <checkbox-filter + :filters-data="filtersData" + :tracking-namespace="$options.TRACKING_ACTION_SELECT" + /> <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{ $options.i18n.showingMax }}</span> @@ -125,7 +147,7 @@ export default { </gl-button> </div> <div v-if="!aggregations.error"> - <hr :class="$options.HR_DEFAULT_CLASSES" /> + <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5" > diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js new file mode 100644 index 00000000000..db107830329 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js @@ -0,0 +1,39 @@ +import Tracking from '~/tracking'; +import { MAX_ITEM_LENGTH } from './data'; + +export const TRACKING_CATEGORY = 'Language filters'; +export const TRACKING_LABEL_FILTERS = 'Filters'; + +export const TRACKING_LABEL_MAX = 'Max Shown'; +export const TRACKING_LABEL_SHOW_MORE = 'Show More'; +export const TRACKING_LABEL_APPLY = 'Apply Filters'; +export const TRACKING_LABEL_RESET = 'Reset Filters'; +export const TRACKING_LABEL_ALL = 'All Filters'; +export const TRACKING_PROPERTY_MAX = `More than ${MAX_ITEM_LENGTH} filters to show`; + +export const TRACKING_ACTION_CLICK = 'search:agreggations:language:click'; +export const TRACKING_ACTION_SHOW = 'search:agreggations:language:show'; + +// select is imported and used in checkbox_filter.vue +export const TRACKING_ACTION_SELECT = 'search:agreggations:language:select'; + +export const trackShowMore = () => + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, { + label: TRACKING_LABEL_ALL, + }); + +export const trackShowHasOverMax = () => + Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, { + label: TRACKING_LABEL_MAX, + property: TRACKING_PROPERTY_MAX, + }); + +export const trackSubmitQuery = () => + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { + label: TRACKING_CATEGORY, + }); + +export const trackResetQuery = () => + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, { + label: TRACKING_CATEGORY, + }); diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue index 0733dc72d2e..477ba37dab7 100644 --- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -16,7 +16,7 @@ export default { }, }, computed: { - ...mapState(['query']), + ...mapState(['query', 'useNewNavigation']), ...mapGetters(['currentScope']), ANY() { return this.filterData.filters.ANY; @@ -56,7 +56,7 @@ export default { <template> <div> - <h5 class="gl-mt-0">{{ filterData.header }}</h5> + <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filterData.header }}</h5> <gl-form-radio-group v-model="selectedFilter"> <gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value"> {{ radioLabel(f) }} diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index 7d995f26684..24804baef44 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { HR_DEFAULT_CLASSES } from '../constants/index'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; @@ -15,7 +16,7 @@ export default { ConfidentialityFilter, }, computed: { - ...mapState(['urlQuery', 'sidebarDirty']), + ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), ...mapGetters(['currentScope']), showReset() { return this.urlQuery.state || this.urlQuery.confidential; @@ -26,6 +27,9 @@ export default { showStatusFilter() { return Object.values(stateFilterData.scopes).includes(this.currentScope); }, + hrClasses() { + return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; + }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -35,7 +39,7 @@ export default { <template> <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> - <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" /> + <hr v-if="!useNewNavigation" :class="hrClasses" /> <status-filter v-if="showStatusFilter" /> <confidentiality-filter v-if="showConfidentialityFilter" /> <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5"> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index 02a3870f499..fc41baee831 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -3,8 +3,8 @@ import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; -import { formatSearchResultCount } from '../../store/utils'; import { slugifyWithUnderscore } from '../../../lib/utils/text_utility'; export default { @@ -22,15 +22,17 @@ export default { ...mapState(['navigation', 'urlQuery']), }, created() { - this.fetchSidebarCount(); + if (this.urlQuery?.search) { + this.fetchSidebarCount(); + } }, methods: { ...mapActions(['fetchSidebarCount']), - showFormatedCount(count) { - return formatSearchResultCount(count); + showFormatedCount(countString) { + return formatSearchResultCount(countString); }, - isCountOverLimit(count) { - return count.includes('+'); + isCountOverLimit(countString) { + return Boolean(addCountOverLimit(countString)); }, handleClick(scope) { this.track('click_menu_item', { label: `vertical_navigation_${scope}` }); diff --git a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue new file mode 100644 index 00000000000..86b7cc577a6 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue @@ -0,0 +1,40 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; +import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; + +export default { + name: 'ScopeNewNavigation', + i18n: { + countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), + }, + components: { + NavItem, + }, + mixins: [Tracking.mixin()], + computed: { + ...mapState(['navigation', 'urlQuery']), + ...mapGetters(['navigationItems']), + }, + created() { + if (this.urlQuery?.search) { + this.fetchSidebarCount(); + } + }, + methods: { + ...mapActions(['fetchSidebarCount']), + }, + NAV_LINK_DEFAULT_CLASSES, + NAV_LINK_COUNT_DEFAULT_CLASSES, +}; +</script> + +<template> + <nav data-testid="search-filter" class="gl-py-2 gl-relative"> + <ul class="gl-px-2 gl-list-style-none"> + <nav-item v-for="item in navigationItems" :key="`menu-${item.title}`" :item="item" /> + </ul> + </nav> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index c3deabfcc26..44d6b537b7b 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -1,5 +1,7 @@ <script> +import { mapState } from 'vuex'; import { stateFilterData } from '../constants/state_filter_data'; +import { HR_DEFAULT_CLASSES } from '../constants'; import RadioFilter from './radio_filter.vue'; export default { @@ -7,13 +9,17 @@ export default { components: { RadioFilter, }, + computed: { + ...mapState(['useNewNavigation']), + }, stateFilterData, + HR_DEFAULT_CLASSES, }; </script> <template> <div> <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" /> - <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> + <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 19b1ad0905b..9519154a571 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -4,7 +4,7 @@ export const SCOPE_BLOB = 'blobs'; export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', - 'gl-flex-wrap-nowrap', + 'gl-flex-nowrap', 'gl-text-gray-900', ]; export const NAV_LINK_DEFAULT_CLASSES = [ @@ -14,3 +14,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [ export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal']; export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100']; export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block']; + +export const TRACKING_LABEL_CHECKBOX = 'Checkbox'; diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js index 4357d6202df..78e03fcdeee 100644 --- a/app/assets/javascripts/search/sidebar/utils.js +++ b/app/assets/javascripts/search/sidebar/utils.js @@ -1,4 +1,4 @@ -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; export const convertFiltersData = (rawBuckets) => rawBuckets.reduce( diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index da2bf4b602e..3d6ca2a6eee 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -4,7 +4,7 @@ import axios from '~/lib/utils/axios_utils'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; import { @@ -140,8 +140,7 @@ export const fetchLanguageAggregation = ({ commit, state }) => { commit(types.REQUEST_AGGREGATIONS); return axios .get(getAggregationsUrl()) - .then((result) => { - const { data } = result; + .then(({ data }) => { commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data)); }) .catch((e) => { diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index ba4fe85db9d..c8ee0a3f9d9 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,6 +1,6 @@ import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; export const MAX_FREQUENT_ITEMS = 5; @@ -17,3 +17,15 @@ export const SIDEBAR_PARAMS = [ ]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; + +export const ICON_MAP = { + blobs: 'code', + issues: 'issues', + merge_requests: 'merge-request', + commits: 'commit', + notes: 'comments', + milestones: 'tag', + users: 'users', + projects: 'project', + wiki_blobs: 'overview', +}; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index 36d98233e28..135c9a3d67c 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,7 +1,8 @@ import { findKey, has } from 'lodash'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; -import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants'; export const frequentGroups = (state) => { return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY]; @@ -26,3 +27,13 @@ export const queryLanguageFilters = (state) => state.query[languageFilterData.fi export const currentUrlQueryHasLanguageFilters = (state) => has(state.urlQuery, languageFilterData.filterParam) && state.urlQuery[languageFilterData.filterParam]?.length > 0; + +export const navigationItems = (state) => + Object.values(state.navigation).map((item) => ({ + title: item.label, + icon: ICON_MAP[item.scope] || '', + link: item.link, + is_active: Boolean(item?.active), + pill_count: `${formatSearchResultCount(item?.count)}${addCountOverLimit(item?.count)}` || '', + items: [], + })); diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index e20a43808cf..634f8f7a7fa 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -7,11 +7,11 @@ import createState from './state'; Vue.use(Vuex); -export const getStoreConfig = ({ query, navigation }) => ({ +export const getStoreConfig = ({ query, navigation, useNewNavigation }) => ({ actions, getters, mutations, - state: createState({ query, navigation }), + state: createState({ query, navigation, useNewNavigation }), }); const createStore = (config) => new Vuex.Store(getStoreConfig(config)); diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index d85a135bb4e..a62b6728819 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; -const createState = ({ query, navigation }) => ({ +const createState = ({ query, navigation, useNewNavigation }) => ({ urlQuery: cloneDeep(query), query, groups: [], @@ -14,6 +14,7 @@ const createState = ({ query, navigation }) => ({ }, sidebarDirty: false, navigation, + useNewNavigation, aggregations: { error: false, fetching: false, diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index 8e484e69646..2f02ef3475c 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -2,7 +2,7 @@ import { isEqual, orderBy } from 'lodash'; import AccessorUtilities from '~/lib/utils/accessor'; import { formatNumber } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, @@ -144,3 +144,7 @@ export const prepareSearchAggregations = (state, aggregationData) => return item; }); + +export const addCountOverLimit = (count = '') => { + return count.includes('+') ? '+' : ''; +}; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js deleted file mode 100644 index 94244eeb12e..00000000000 --- a/app/assets/javascripts/search_autocomplete.js +++ /dev/null @@ -1,520 +0,0 @@ -/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ - -import $ from 'jquery'; -import { escape, throttle } from 'lodash'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { s__, __, sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import axios from './lib/utils/axios_utils'; -import { spriteIcon } from './lib/utils/common_utils'; -import { - isInGroupsPage, - isInProjectPage, - getGroupSlug, - getProjectSlug, -} from './search_autocomplete_utils'; - -/** - * Search input in top navigation bar. - * On click, opens a dropdown - * As the user types it filters the results - * When the user clicks `x` button it cleans the input and closes the dropdown. - */ - -const KEYCODE = { - ESCAPE: 27, - BACKSPACE: 8, - ENTER: 13, - UP: 38, - DOWN: 40, -}; - -function setSearchOptions() { - const $projectOptionsDataEl = $('.js-search-project-options'); - const $groupOptionsDataEl = $('.js-search-group-options'); - const $dashboardOptionsDataEl = $('.js-search-dashboard-options'); - - if ($projectOptionsDataEl.length) { - gl.projectOptions = gl.projectOptions || {}; - - const projectPath = $projectOptionsDataEl.data('projectPath'); - - gl.projectOptions[projectPath] = { - name: $projectOptionsDataEl.data('name'), - issuesPath: $projectOptionsDataEl.data('issuesPath'), - issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'), - mrPath: $projectOptionsDataEl.data('mrPath'), - }; - } - - if ($groupOptionsDataEl.length) { - gl.groupOptions = gl.groupOptions || {}; - - const groupPath = $groupOptionsDataEl.data('groupPath'); - - gl.groupOptions[groupPath] = { - name: $groupOptionsDataEl.data('name'), - issuesPath: $groupOptionsDataEl.data('issuesPath'), - mrPath: $groupOptionsDataEl.data('mrPath'), - }; - } - - if ($dashboardOptionsDataEl.length) { - gl.dashboardOptions = { - name: s__('SearchAutocomplete|All GitLab'), - issuesPath: $dashboardOptionsDataEl.data('issuesPath'), - mrPath: $dashboardOptionsDataEl.data('mrPath'), - }; - } -} - -export class SearchAutocomplete { - constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) { - setSearchOptions(); - this.bindEventContext(); - this.wrap = wrap || $('.search'); - this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts'); - this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath'); - this.projectId = projectId || this.optsEl.data('autocompleteProjectId') || ''; - this.projectRef = projectRef || this.optsEl.data('autocompleteProjectRef') || ''; - this.dropdown = this.wrap.find('.dropdown'); - this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); - this.dropdownMenu = this.dropdown.find('.dropdown-menu'); - this.dropdownContent = this.dropdown.find('.dropdown-content'); - this.scopeInputEl = this.getElement('#scope'); - this.searchInput = this.getElement('.search-input'); - this.projectInputEl = this.getElement('#search_project_id'); - this.groupInputEl = this.getElement('#group_id'); - this.searchCodeInputEl = this.getElement('#search_code'); - this.repositoryInputEl = this.getElement('#repository_ref'); - this.clearInput = this.getElement('.js-clear-input'); - this.scrollFadeInitialized = false; - this.saveOriginalState(); - - // Only when user is logged in - if (gon.current_user_id) { - this.createAutocomplete(); - } - - this.bindEvents(); - this.dropdownToggle.dropdown(); - this.searchInput.addClass('js-autocomplete-disabled'); - } - - // Finds an element inside wrapper element - bindEventContext() { - this.onSearchInputBlur = this.onSearchInputBlur.bind(this); - this.onClearInputClick = this.onClearInputClick.bind(this); - this.onSearchInputFocus = this.onSearchInputFocus.bind(this); - this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); - this.onSearchInputChange = this.onSearchInputChange.bind(this); - this.setScrollFade = this.setScrollFade.bind(this); - } - getElement(selector) { - return this.wrap.find(selector); - } - - saveOriginalState() { - return (this.originalState = this.serializeState()); - } - - createAutocomplete() { - return initDeprecatedJQueryDropdown(this.searchInput, { - filterInputBlur: false, - filterable: true, - filterRemote: true, - highlight: true, - icon: true, - enterCallback: false, - filterInput: 'input#search', - search: { - fields: ['text'], - }, - id: this.getSearchText, - data: this.getData.bind(this), - selectable: true, - clicked: this.onClick.bind(this), - trackSuggestionClickedLabel: 'search_autocomplete_suggestion', - }); - } - - getSearchText(selectedObject) { - return selectedObject.id ? selectedObject.text : ''; - } - - getData(term, callback) { - if (!term) { - const contents = this.getCategoryContents(); - if (contents) { - const deprecatedJQueryDropdownInstance = this.searchInput.data('deprecatedJQueryDropdown'); - - if (deprecatedJQueryDropdownInstance) { - deprecatedJQueryDropdownInstance.filter.options.callback(contents); - } - this.enableAutocomplete(); - } - return; - } - - // Prevent multiple ajax calls - if (this.loadingSuggestions) { - return; - } - - this.loadingSuggestions = true; - - return axios - .get(this.autocompletePath, { - params: { - project_id: this.projectId, - project_ref: this.projectRef, - term, - }, - }) - .then((response) => { - const options = this.scopedSearchOptions(term); - - // List results - let lastCategory = null; - for (let i = 0, len = response.data.length; i < len; i += 1) { - const suggestion = response.data[i]; - // Add group header before list each group - if (lastCategory !== suggestion.category) { - options.push({ type: 'separator' }); - options.push({ - type: 'header', - content: suggestion.category, - }); - lastCategory = suggestion.category; - } - - // Add the suggestion - options.push({ - id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, - icon: this.getAvatar(suggestion), - category: suggestion.category, - text: suggestion.label, - url: suggestion.url, - }); - } - - callback(options); - - this.loadingSuggestions = false; - this.highlightFirstRow(); - this.setScrollFade(); - }) - .catch(() => { - this.loadingSuggestions = false; - }); - } - - getCategoryContents() { - const userName = gon.current_username; - const { projectOptions, groupOptions, dashboardOptions } = gl; - - // Get options - let options; - if (isInProjectPage() && projectOptions) { - options = projectOptions[getProjectSlug()]; - } else if (isInGroupsPage() && groupOptions) { - options = groupOptions[getGroupSlug()]; - } else if (dashboardOptions) { - options = dashboardOptions; - } - - const { issuesPath, mrPath, name, issuesDisabled } = options; - const baseItems = []; - - if (name) { - baseItems.push({ - type: 'header', - content: `${name}`, - }); - } - - const issueItems = [ - { - text: s__('SearchAutocomplete|Issues assigned to me'), - url: `${issuesPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Issues I've created"), - url: `${issuesPath}/?author_username=${userName}`, - }, - ]; - const mergeRequestItems = [ - { - text: s__('SearchAutocomplete|Merge requests assigned to me'), - url: `${mrPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"), - url: `${mrPath}/?reviewer_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests I've created"), - url: `${mrPath}/?author_username=${userName}`, - }, - ]; - - let items; - if (issuesDisabled) { - items = baseItems.concat(mergeRequestItems); - } else { - items = baseItems.concat(...issueItems, ...mergeRequestItems); - } - return items; - } - - // Add option to proceed with the search for each - // scope that is currently available, namely: - // - // - Search in this project - // - Search in this group (or project's group) - // - Search in all GitLab - scopedSearchOptions(term) { - const icon = spriteIcon('search', 's16 inline-search-icon'); - const projectId = this.projectInputEl.val(); - const groupId = this.groupInputEl.val(); - const options = []; - - if (projectId) { - const projectOptions = gl.projectOptions[getProjectSlug()]; - const url = groupId - ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}&nav_source=navbar` - : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&nav_source=navbar`; - - options.push({ - icon, - text: term, - template: sprintf( - s__(`SearchAutocomplete|in project %{projectName}`), - { - projectName: `<i>${projectOptions.name}</i>`, - }, - false, - ), - url, - }); - } - - if (groupId) { - const groupOptions = gl.groupOptions[getGroupSlug()]; - options.push({ - icon, - text: term, - template: sprintf( - s__(`SearchAutocomplete|in group %{groupName}`), - { - groupName: `<i>${groupOptions.name}</i>`, - }, - false, - ), - url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}&nav_source=navbar`, - }); - } - - options.push({ - icon, - text: term, - template: s__('SearchAutocomplete|in all GitLab'), - url: `${gon.relative_url_root}/search?search=${term}&nav_source=navbar`, - }); - - return options; - } - - serializeState() { - return { - // Search Criteria - search_project_id: this.projectInputEl.val(), - group_id: this.groupInputEl.val(), - search_code: this.searchCodeInputEl.val(), - repository_ref: this.repositoryInputEl.val(), - scope: this.scopeInputEl.val(), - }; - } - - bindEvents() { - this.searchInput.on('input', this.onSearchInputChange); - this.searchInput.on('keyup', this.onSearchInputKeyUp); - this.searchInput.on('focus', this.onSearchInputFocus); - this.searchInput.on('blur', this.onSearchInputBlur); - this.clearInput.on('click', this.onClearInputClick); - this.dropdownContent.on( - 'scroll', - throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - ); - - this.searchInput.on('click', (e) => { - e.stopPropagation(); - }); - } - - enableAutocomplete() { - this.setScrollFade(); - - // No need to enable anything if user is not logged in - if (!gon.current_user_id) { - return; - } - - // If the dropdown is closed, we'll open it - if (!this.dropdown.hasClass('show')) { - this.loadingSuggestions = false; - this.dropdownToggle.dropdown('toggle'); - - const trackEvent = 'click_search_bar'; - const trackCategory = undefined; // will be default set in event method - - Tracking.event(trackCategory, trackEvent, { - label: 'main_navigation', - property: 'navigation', - }); - - return this.searchInput.removeClass('js-autocomplete-disabled'); - } - } - - onSearchInputChange() { - this.enableAutocomplete(); - } - - onSearchInputKeyUp(e) { - switch (e.keyCode) { - case KEYCODE.ESCAPE: - this.restoreOriginalState(); - break; - case KEYCODE.ENTER: - this.disableAutocomplete(); - break; - default: - } - this.wrap.toggleClass('has-value', Boolean(e.target.value)); - } - - onSearchInputFocus() { - this.isFocused = true; - this.wrap.addClass('search-active'); - if (this.getValue() === '') { - return this.getData(); - } - } - - getValue() { - return this.searchInput.val(); - } - - onClearInputClick(e) { - e.preventDefault(); - this.wrap.toggleClass('has-value', Boolean(e.target.value)); - return this.searchInput.val('').focus(); - } - - onSearchInputBlur() { - this.isFocused = false; - this.wrap.removeClass('search-active'); - // If input is blank then restore state - if (this.searchInput.val() === '') { - this.restoreOriginalState(); - } - this.dropdownMenu.removeClass('show'); - } - - restoreOriginalState() { - const inputs = Object.keys(this.originalState); - for (let i = 0, len = inputs.length; i < len; i += 1) { - const input = inputs[i]; - this.getElement(`#${input}`).val(this.originalState[input]); - } - } - - resetSearchState() { - const inputs = Object.keys(this.originalState); - const results = []; - for (let i = 0, len = inputs.length; i < len; i += 1) { - const input = inputs[i]; - results.push(this.getElement(`#${input}`).val('')); - } - return results; - } - - disableAutocomplete() { - if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { - this.searchInput.addClass('js-autocomplete-disabled'); - this.dropdownToggle.dropdown('toggle'); - this.restoreMenu(); - } - } - - restoreMenu() { - const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; - return this.dropdownContent.html(html); - } - - onClick(item, $el, e) { - if (window.location.pathname.indexOf(item.url) !== -1) { - if (!e.metaKey) e.preventDefault(); - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - if (item.category === 'Projects') { - this.projectInputEl.val(item.id); - } - // eslint-disable-next-line @gitlab/require-i18n-strings - if (item.category === 'Groups') { - this.groupInputEl.val(item.id); - } - $el.removeClass('is-active'); - this.disableAutocomplete(); - return this.searchInput.val('').focus(); - } - } - - highlightFirstRow() { - this.searchInput.data('deprecatedJQueryDropdown').highlightRowAtIndex(null, 0); - } - - getAvatar(item) { - if (!Object.prototype.hasOwnProperty.call(item, 'avatar_url')) { - return false; - } - - const { label, id } = item; - const avatarUrl = item.avatar_url; - const avatar = avatarUrl - ? `<img class="search-item-avatar" src="${avatarUrl}" />` - : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( - escape(label), - )}</div>`; - - return avatar; - } - - isScrolledUp() { - const el = this.dropdownContent[0]; - const currentPosition = this.contentClientHeight + el.scrollTop; - - return currentPosition < this.maxPosition; - } - - initScrollFade() { - const el = this.dropdownContent[0]; - this.scrollFadeInitialized = true; - - this.contentClientHeight = el.clientHeight; - this.maxPosition = el.scrollHeight; - this.dropdownMenu.addClass('dropdown-content-faded-mask'); - } - - setScrollFade() { - this.initScrollFade(); - - this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp()); - } -} - -export default function initSearchAutocomplete(opts) { - return new SearchAutocomplete(opts); -} diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js deleted file mode 100644 index a9a0f941e93..00000000000 --- a/app/assets/javascripts/search_autocomplete_utils.js +++ /dev/null @@ -1,19 +0,0 @@ -import { getPagePath } from './lib/utils/common_utils'; - -export const isInGroupsPage = () => getPagePath() === 'groups'; - -export const isInProjectPage = () => getPagePath() === 'projects'; - -export const getProjectSlug = () => { - if (isInProjectPage()) { - return document?.body?.dataset?.project; - } - return null; -}; - -export const getGroupSlug = () => { - if (isInProjectPage() || isInGroupsPage()) { - return document?.body?.dataset?.group; - } - return null; -}; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index ccfaa678201..e96f71981e5 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,7 +1,6 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { parseErrorMessage } from '~/lib/utils/error_message'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; @@ -34,9 +33,6 @@ export const i18n = { 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.', ), securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), - genericErrorText: s__( - `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`, - ), }; export default { @@ -128,9 +124,8 @@ export default { dismissedProjects.add(this.projectFullPath); this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); }, - onError(error) { - const { message, userFacing } = parseErrorMessage(error); - this.errorMessage = userFacing ? message : i18n.genericErrorText; + onError(message) { + this.errorMessage = message; }, dismissAlert() { this.errorMessage = ''; diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 6beb6cd4d34..1d5ff5eb16f 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -6,6 +6,7 @@ import { REPORT_TYPE_SAST_IAC, REPORT_TYPE_DAST, REPORT_TYPE_DAST_PROFILES, + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, @@ -67,6 +68,30 @@ export const DAST_PROFILES_DESCRIPTION = s__( ); export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); +export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); +export const BAS_BADGE_TOOLTIP = s__( + 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', +); +export const BAS_DESCRIPTION = s__( + 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', +); +export const BAS_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', +); +export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); +export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); + +export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( + 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', +); +export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', + { anchor: 'extend-dynamic-application-security-testing-dast' }, +); +export const BAS_DAST_FEATURE_FLAG_NAME = s__( + 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', +); + export const SECRET_DETECTION_NAME = __('Secret Detection'); export const SECRET_DETECTION_DESCRIPTION = __( 'Analyze your source code and git history for secrets.', @@ -142,6 +167,7 @@ export const SCANNER_NAMES_MAP = { COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, SECRET_DETECTION: SECRET_DETECTION_NAME, DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, + BAS: BAS_SHORT_NAME, GENERIC: s__('ciReport|Manually added'), }; @@ -223,6 +249,25 @@ export const securityFeatures = [ configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, }, }, + { + anchor: 'bas', + badge: { + alwaysDisplay: true, + text: BAS_BADGE_TEXT, + tooltipText: BAS_BADGE_TOOLTIP, + variant: 'info', + }, + description: BAS_DESCRIPTION, + name: BAS_NAME, + helpPath: BAS_HELP_PATH, + secondary: { + configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, + description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, + name: BAS_DAST_FEATURE_FLAG_NAME, + }, + shortName: BAS_SHORT_NAME, + type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + }, ]; export const complianceFeatures = [ diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 19b412d66ca..d1b705fe2fc 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -1,7 +1,10 @@ <script> import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants'; +import { + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + REPORT_TYPE_SAST_IAC, +} from '~/vue_shared/security_reports/constants'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import FeatureCardBadge from './feature_card_badge.vue'; @@ -68,8 +71,7 @@ export default { }; }, hasSecondary() { - const { name, description, configurationText } = this.feature.secondary ?? {}; - return Boolean(name && description && configurationText); + return Boolean(this.feature.secondary); }, // This condition is a temporary hack to not display any wrong information // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307. @@ -78,7 +80,17 @@ export default { return this.feature.type !== REPORT_TYPE_SAST_IAC; }, hasBadge() { - return Boolean(this.available && this.feature.badge?.text); + const shouldDisplay = this.available || this.feature.badge?.alwaysDisplay; + return Boolean(shouldDisplay && this.feature.badge?.text); + }, + hasEnabledStatus() { + return ( + this.isNotSastIACTemporaryHack && + this.feature.type !== REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION + ); + }, + showSecondaryConfigurationHelpPath() { + return Boolean(this.available && this.feature.secondary?.configurationHelpPath); }, }, methods: { @@ -118,19 +130,25 @@ export default { :badge-href="feature.badge.badgeHref" /> - <template v-if="enabled"> - <span> - <gl-icon name="check-circle-filled" /> - <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> - </span> - </template> - - <template v-else-if="available"> - <span>{{ $options.i18n.notEnabled }}</span> + <template v-if="hasEnabledStatus"> + <template v-if="enabled"> + <span> + <gl-icon name="check-circle-filled" /> + <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> + </span> + </template> + + <template v-else-if="available"> + <span>{{ $options.i18n.notEnabled }}</span> + </template> + + <template v-else> + {{ $options.i18n.availableWith }} + </template> </template> - <template v-else> - {{ $options.i18n.availableWith }} + <template v-else-if="!available"> + <span>{{ $options.i18n.availableWith }}</span> </template> </div> </div> @@ -186,6 +204,16 @@ export default { > {{ feature.secondary.configurationText }} </gl-button> + + <gl-button + v-else-if="showSecondaryConfigurationHelpPath" + icon="external-link" + :href="feature.secondary.configurationHelpPath" + category="secondary" + class="gl-mt-5" + > + {{ $options.i18n.configurationGuide }} + </gl-button> </div> </gl-card> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 8b40b48b54a..c61c02c8b3a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,7 +1,7 @@ <script> -import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; -import { s__, sprintf } from '~/locale'; +import { __ } from '~/locale'; const AVAILABILITY_STATUS = { NOT_SET: 'NOT_SET', @@ -11,6 +11,7 @@ const AVAILABILITY_STATUS = { export default { components: { GlAvatarLabeled, + GlBadge, GlIcon, }, props: { @@ -25,30 +26,23 @@ export default { }, }, computed: { - userLabel() { - const { name, status } = this.user; - if (!status || status?.availability !== AVAILABILITY_STATUS.BUSY) { - return name; - } - return sprintf( - s__('UserAvailability|%{author} (Busy)'), - { - author: name, - }, - false, - ); + isBusy() { + return this.user?.status?.availability === AVAILABILITY_STATUS.BUSY; }, hasCannotMergeIcon() { return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge; }, }, + i18n: { + busy: __('Busy'), + }, }; </script> <template> <gl-avatar-labeled :size="32" - :label="userLabel" + :label="user.name" :sub-label="`@${user.username}`" :src="user.avatarUrl || user.avatar || user.avatar_url" class="gl-align-items-center gl-relative sidebar-participant" @@ -61,6 +55,9 @@ export default { class="merge-icon" :size="12" /> + <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2"> + {{ $options.i18n.busy }} + </gl-badge> </template> </gl-avatar-labeled> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue index bed84dc5706..72084fdafb1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue +++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue @@ -1,10 +1,11 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlBadge, GlSprintf } from '@gitlab/ui'; import { isUserBusy } from '~/set_status_modal/utils'; export default { name: 'UserNameWithStatus', components: { + GlBadge, GlSprintf, }, props: { @@ -40,17 +41,17 @@ export default { </script> <template> <span :class="containerClasses"> - <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')"> + <gl-sprintf :message="s__('UserAvailability|%{author}%{badgeStart}Busy%{badgeEnd}')"> <template #author - >{{ name }} - <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal" + ><span>{{ name }}</span + ><span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-ml-1" >({{ pronouns }})</span ></template > - <template #span="{ content }" - ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{ - content - }}</span> + <template #badge="{ content }"> + <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2"> + {{ content }} + </gl-badge> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 190b8c1de62..5a9545f3460 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -106,6 +106,19 @@ export default { ), }); }, + subscribeToMore: { + document() { + return this.dateQueries[this.issuableType].subscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return this.skipIssueDueDateSubscription; + }, + }, }, }, computed: { @@ -163,6 +176,12 @@ export default { dataTestId() { return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date'; }, + issuableId() { + return this.issuable.id; + }, + skipIssueDueDateSubscription() { + return this.issuableType !== TYPE_ISSUE || !this.issuableId || this.isLoading; + }, }, methods: { epicDatePopoverEl() { @@ -302,6 +321,7 @@ export default { v-if="!isLoading" ref="datePicker" class="gl-relative" + :value="parsedDate" :min-date="minDate" :max-date="maxDate" :default-date="parsedDate" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue index 227d85d952b..8535398decf 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue @@ -78,6 +78,7 @@ export default { v-model.trim="labelTitle" :placeholder="__('Name new label')" :autofocus="true" + data-testid="label-title" /> </div> <div class="dropdown-content px-2"> @@ -113,6 +114,7 @@ export default { category="primary" variant="confirm" class="float-left d-flex align-items-center" + data-testid="create-click" @click="handleCreateClick" > <gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" /> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js index 852ef0c6283..881d84a7d6e 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js @@ -1,5 +1,6 @@ export const SCOPED_LABEL_DELIMITER = '::'; export const DEBOUNCE_DROPDOWN_DELAY = 200; +export const DEFAULT_LABEL_COLOR = '#6699cc'; export const DropdownVariant = { Sidebar: 'sidebar', diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index 1174ec3f01e..30eeb0fbe31 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -13,6 +13,7 @@ import { WORKSPACE_GROUP } from '~/issues/constants'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '../../../constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; +import { DEFAULT_LABEL_COLOR } from './constants'; const errorMessage = __('Error creating label.'); @@ -44,11 +45,16 @@ export default { type: String, required: true, }, + searchKey: { + type: String, + required: false, + default: '', + }, }, data() { return { - labelTitle: '', - selectedColor: '', + labelTitle: this.searchKey, + selectedColor: DEFAULT_LABEL_COLOR, labelCreateInProgress: false, error: undefined, }; diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue index 76c47305369..581537264db 100644 --- a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue +++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue @@ -26,10 +26,11 @@ export default { }, }, methods: { - moveIssue(targetProject) { + async moveIssue(targetProject) { this.moveInProgress = true; - return this.$apollo - .mutate({ + + try { + const { data } = await this.$apollo.mutate({ mutation: moveIssueMutation, variables: { moveIssueInput: { @@ -38,24 +39,25 @@ export default { targetProjectPath: targetProject.full_path, }, }, - }) - .then(({ data = {} }) => { - if (!data.issueMove) return; + }); + + if (!data.issueMove) return; + + const { errors } = data.issueMove; + if (errors?.length > 0) { + throw new Error(`Error moving the issue. Error message: ${errors[0].message}`); + } - const { errors } = data.issueMove; - if (errors?.length > 0) { - throw new Error(`Error moving the issue. Error message: ${errors[0].message}`); - } - visitUrl(data.issueMove?.issue.webUrl); - }) - .catch((error) => { - this.moveInProgress = false; - createAlert({ - message: this.$options.i18n.moveErrorMessage, - captureError: true, - error, - }); + visitUrl(data.issueMove?.issue.webUrl); + } catch (error) { + createAlert({ + message: this.$options.i18n.moveErrorMessage, + captureError: true, + error, }); + } finally { + this.moveInProgress = false; + } }, }, }; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index a9d102eb303..bbd3cda0ad3 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -69,7 +69,7 @@ export default { }, participantLabel() { return sprintf( - n__('%{count} participant', '%{count} participants', this.participants.length), + n__('%{count} Participant', '%{count} Participants', this.participants.length), { count: this.loading ? '' : this.participantCount }, ); }, diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index a3710d9534e..99f9d5e872c 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -6,6 +6,7 @@ import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; const SUCCESS_STATE = 'success'; +const JUST_APPROVED = 'approved'; export default { i18n: { @@ -42,7 +43,7 @@ export default { }, watch: { users: { - handler(users) { + handler(users, previousUsers) { this.loadingStates = users.reduce( (acc, user) => ({ ...acc, @@ -50,14 +51,41 @@ export default { }), this.loadingStates, ); + if (previousUsers) { + users.forEach((user) => { + const userPreviousState = previousUsers.find(({ id }) => id === user.id); + if ( + userPreviousState && + user.mergeRequestInteraction.approved && + !userPreviousState.mergeRequestInteraction.approved + ) { + this.showApprovalAnimation(user.id); + } + }); + } }, immediate: true, }, }, methods: { + showApprovalAnimation(userId) { + this.loadingStates[userId] = JUST_APPROVED; + + setTimeout(() => { + this.loadingStates[userId] = null; + }, 1500); + }, + approveAnimation(userId) { + return { + 'merge-request-approved-icon': this.loadingStates[userId] === JUST_APPROVED, + }; + }, approvedByTooltipTitle(user) { return sprintf(s__('MergeRequest|Approved by @%{username}'), user); }, + reviewedButNotApprovedTooltip(user) { + return sprintf(s__('MergeRequest|Reviewed by @%{username} but not yet approved'), user); + }, toggleShowLess() { this.showLess = !this.showLess; }, @@ -105,35 +133,38 @@ export default { {{ user.name }} </div> </reviewer-avatar-link> - <gl-icon - v-if="user.mergeRequestInteraction.approved" - v-gl-tooltip.left - :size="16" - :title="approvedByTooltipTitle(user)" - name="status-success" - class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" - data-testid="re-approved" - /> - <gl-icon - v-if="loadingStates[user.id] === $options.SUCCESS_STATE" - :size="24" - name="check" - class="float-right gl-py-2 gl-mr-2 gl-text-green-500" - data-testid="re-request-success" - /> <gl-button - v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed" + v-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed" v-gl-tooltip.left :title="$options.i18n.reRequestReview" :aria-label="$options.i18n.reRequestReview" :loading="loadingStates[user.id] === $options.LOADING_STATE" - class="float-right gl-text-gray-500!" + class="float-right gl-text-gray-500! gl-mr-2" size="small" icon="redo" variant="link" data-testid="re-request-button" @click="reRequestReview(user.id)" /> + <gl-icon + v-if="user.mergeRequestInteraction.approved" + v-gl-tooltip.left + :size="16" + :title="approvedByTooltipTitle(user)" + name="status-success" + class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" + :class="approveAnimation(user.id)" + data-testid="approved" + /> + <gl-icon + v-else-if="user.mergeRequestInteraction.reviewed" + v-gl-tooltip.left + :size="16" + :title="reviewedButNotApprovedTooltip(user)" + name="dotted-circle" + class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0" + data-testid="reviewed-not-approved" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 19e72da65f2..4721c6fee61 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -81,7 +81,7 @@ export default { }, }, apollo: { - currentAttribute: { + issuable: { query() { const { current } = this.issuableAttributeQuery; const { query } = current[this.issuableType]; @@ -95,11 +95,12 @@ export default { }; }, update(data) { + return data.workspace?.issuable || {}; + }, + result({ data }) { if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; } - - return data?.workspace?.issuable.attribute; }, error(error) { createAlert({ @@ -108,13 +109,26 @@ export default { error, }); }, + subscribeToMore: { + document() { + return issuableAttributesQueries[this.issuableAttribute].subscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return this.shouldSkipRealTimeEpicLinkUpdates; + }, + }, }, }, data() { return { updating: false, selectedTitle: null, - currentAttribute: null, + issuable: {}, hasCurrentAttribute: false, editConfirmation: false, tracking: { @@ -125,6 +139,12 @@ export default { }; }, computed: { + currentAttribute() { + return this.issuable.attribute; + }, + issuableId() { + return this.issuable.id; + }, issuableAttributeQuery() { return this.issuableAttributesQueries[this.issuableAttribute]; }, @@ -135,7 +155,7 @@ export default { return this.currentAttribute?.webUrl; }, loading() { - return this.$apollo.queries.currentAttribute.loading; + return this.$apollo.queries.issuable.loading; }, attributeTypeTitle() { return this.widgetTitleText[this.issuableAttribute]; @@ -170,6 +190,9 @@ export default { ? !this.editConfirmation : false; }, + shouldSkipRealTimeEpicLinkUpdates() { + return !this.issuableId || this.issuableAttribute !== IssuableAttributeType.Epic; + }, }, methods: { updateAttribute({ id }) { diff --git a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue index 6dacf4e10d3..ba0bf783315 100644 --- a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue @@ -32,9 +32,22 @@ export default { return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }]; }, }, + watch: { + collapsed(value) { + this.updateLayout(value); + }, + }, + mounted() { + this.page = document.querySelector('.layout-page'); + }, methods: { toggle() { this.$emit('toggle'); + this.updateLayout(); + }, + updateLayout(collapsed) { + this.page?.classList.remove(collapsed ? 'right-sidebar-expanded' : 'right-sidebar-collapsed'); + this.page?.classList.add(collapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'); }, }, }; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 7bca83c4142..0f82182c6e2 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -13,6 +13,7 @@ import { WORKSPACE_PROJECT, } from '~/issues/constants'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import issuableDatesUpdatedSubscription from '../graphql_shared/subscriptions/work_item_dates.subscription.graphql'; import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; @@ -218,6 +219,7 @@ export const dueDateQueries = { [TYPE_ISSUE]: { query: issueDueDateQuery, mutation: updateIssueDueDateMutation, + subscription: issuableDatesUpdatedSubscription, }, [TYPE_EPIC]: { query: epicDueDateQuery, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 99c3fdf82d4..2828b9fbf1a 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -148,7 +148,7 @@ function mountSidebarAssigneesWidget() { name: 'SidebarAssigneesRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), directlyInviteMembers: Object.prototype.hasOwnProperty.call( el.dataset, 'directlyInviteMembers', @@ -162,7 +162,7 @@ function mountSidebarAssigneesWidget() { issuableType, issuableId: id, allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1, - editable, + editable: parseBoolean(editable), }, scopedSlots: { collapsed: ({ users }) => @@ -415,7 +415,7 @@ function mountSidebarDueDateWidget() { name: 'SidebarDueDateWidgetRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), }, render: (createElement) => createElement(SidebarDueDateWidget, { @@ -476,7 +476,7 @@ function mountIssuableLockForm(store) { render: (createElement) => createElement(IssuableLockForm, { props: { - isEditable: editable, + isEditable: parseBoolean(editable), }, }), }); @@ -523,7 +523,7 @@ function mountSidebarSubscriptionsWidget() { name: 'SidebarSubscriptionsWidgetRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), }, render: (createElement) => createElement(SidebarSubscriptionsWidget, { @@ -587,7 +587,7 @@ function mountSidebarSeverityWidget() { name: 'SidebarSeverityWidgetRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), }, render: (createElement) => createElement(SidebarSeverityWidget, { @@ -645,7 +645,7 @@ function mountCopyEmailToClipboard() { }); } -export function mountMoveIssuesButton() { +export async function mountMoveIssuesButton() { const el = document.querySelector('.js-move-issues'); if (!el) { @@ -658,7 +658,7 @@ export function mountMoveIssuesButton() { el, name: 'MoveIssuesRoot', apolloProvider: new VueApollo({ - defaultClient: gqlClient, + defaultClient: await gqlClient(), }), render: (createElement) => createElement(MoveIssuesButton, { diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index baf906bb96c..ea3b3633ea7 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -1,3 +1,5 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + export default class SidebarStore { constructor(options) { if (!SidebarStore.singleton) { @@ -12,7 +14,7 @@ export default class SidebarStore { const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options; this.currentUser = currentUser; this.rootPath = rootPath; - this.editable = editable; + this.editable = parseBoolean(editable); this.timeEstimate = 0; this.totalTimeSpent = 0; this.humanTimeEstimate = ''; diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js index 6b90fb80abf..a61b4e4f066 100644 --- a/app/assets/javascripts/sidebar/utils.js +++ b/app/assets/javascripts/sidebar/utils.js @@ -12,7 +12,7 @@ export const updateGlobalTodoCount = (additionalTodoCount) => { if (countContainer === null) return; - const currentCount = parseInt(countContainer.innerText, 10); + const currentCount = parseInt(countContainer.innerText, 10) || 0; const todoToggleEvent = new CustomEvent('todo:toggle', { detail: { diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index b613e356a7a..bab167bb7e4 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - import $ from 'jquery'; import { createAlert } from '~/alert'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -66,7 +64,7 @@ export default class SingleFileDiff { } else { this.$chevronDownIcon.removeClass('gl-display-none'); this.$chevronRightIcon.addClass('gl-display-none'); - return this.getContentHTML(cb); + return this.getContentHTML(cb); // eslint-disable-line consistent-return } } diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index e6aa3be0371..24dd978585c 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -42,7 +42,7 @@ export default { <label> {{ __('Visibility level') }} <gl-link v-if="helpLink" :href="helpLink" target="_blank" - ><gl-icon :size="12" name="question" + ><gl-icon :size="12" name="question-o" /></gl-link> </label> <gl-form-group id="visibility-level-setting" class="gl-mb-0"> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue index b3d4ecdda47..fa9da6cef9d 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -1,10 +1,9 @@ <script> import * as Sentry from '@sentry/browser'; -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { s__ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql'; -import { contextSwitcherItems } from '../mock_data'; import { trackContextAccess, formatContextSwitcherItems } from '../utils'; import NavItem from './nav_item.vue'; import ProjectsList from './projects_list.vue'; @@ -14,12 +13,13 @@ export default { i18n: { contextNavigation: s__('Navigation|Context navigation'), switchTo: s__('Navigation|Switch to...'), - searchPlaceholder: s__('Navigation|Search for projects or groups'), + searchPlaceholder: s__('Navigation|Search your projects or groups'), + searchingLabel: s__('Navigation|Retrieving search results'), + searchError: s__('Navigation|There was an error fetching search results.'), }, apollo: { groupsAndProjects: { query: searchUserProjectsAndGroups, - debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, manual: true, variables() { return { @@ -28,6 +28,7 @@ export default { }; }, result(response) { + this.hasError = false; try { const { data: { @@ -41,11 +42,11 @@ export default { this.projects = formatContextSwitcherItems(projects); this.groups = formatContextSwitcherItems(groups); } catch (e) { - Sentry.captureException(e); + this.handleError(e); } }, error(e) { - Sentry.captureException(e); + this.handleError(e); }, skip() { return !this.searchString; @@ -54,11 +55,17 @@ export default { }, components: { GlSearchBoxByType, + GlLoadingIcon, + GlAlert, NavItem, ProjectsList, GroupsList, }, props: { + persistentLinks: { + type: Array, + required: true, + }, username: { type: String, required: true, @@ -82,19 +89,36 @@ export default { searchString: '', projects: [], groups: [], + hasError: false, }; }, computed: { isSearch() { return Boolean(this.searchString); }, + isSearching() { + return this.$apollo.queries.groupsAndProjects.loading; + }, }, - contextSwitcherItems, created() { if (this.currentContext.namespace) { trackContextAccess(this.username, this.currentContext); } }, + methods: { + /** + * This needs to be exposed publicly so that we can auto-focus the search input when the parent + * GlCollapse is shown. + */ + focusInput() { + this.$refs['search-box'].focusInput(); + }, + handleError(e) { + Sentry.captureException(e); + this.hasError = true; + }, + }, + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, }; </script> @@ -102,21 +126,36 @@ export default { <div> <div class="gl-p-1 gl-border-b gl-border-gray-50 gl-bg-white"> <gl-search-box-by-type + ref="search-box" v-model="searchString" class="context-switcher-search-box" :placeholder="$options.i18n.searchPlaceholder" + :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS" borderless /> </div> - <nav :aria-label="$options.i18n.contextNavigation"> + <gl-loading-icon + v-if="isSearching" + class="gl-mt-5" + size="md" + :label="$options.i18n.searchingLabel" + /> + <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2"> + {{ $options.i18n.searchError }} + </gl-alert> + <nav v-else :aria-label="$options.i18n.contextNavigation"> <ul class="gl-p-0 gl-list-style-none"> <li v-if="!isSearch"> <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> {{ $options.i18n.switchTo }} </div> <ul :aria-label="$options.i18n.switchTo" class="gl-p-0"> - <nav-item :item="$options.contextSwitcherItems.yourWork" /> - <nav-item :item="$options.contextSwitcherItems.explore" /> + <nav-item + v-for="item in persistentLinks" + :key="item.link" + :item="item" + :link-classes="{ [item.link_classes]: item.link_classes }" + /> </ul> </li> <projects-list diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue index e0b6870872c..e56ef9e410b 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -38,13 +38,13 @@ export default { <button v-collapse-toggle.context-switcher type="button" - class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8" + class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8" > <span v-if="context.icon" class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4" > - <gl-icon :name="context.icon" :size="16" /> + <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" /> </span> <gl-avatar v-else @@ -55,11 +55,11 @@ export default { :src="context.avatar" class="gl-mr-3 gl-ml-4" /> - <div class="gl-overflow-auto"> + <div class="gl-overflow-auto gl-text-gray-900"> <gl-truncate :text="context.title" /> </div> <span class="gl-flex-grow-1 gl-text-right gl-mr-4"> - <gl-icon :name="collapseIcon" /> + <gl-icon class="gl-text-gray-400" :name="collapseIcon" /> </span> </button> </template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index e79b609545e..98417b7cd25 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -7,7 +7,7 @@ export default { }, props: { count: { - type: Number, + type: [Number, String], required: true, }, href: { diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index d3bb31a69fa..4cff4642cf7 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -1,6 +1,10 @@ <script> import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; import { __ } from '~/locale'; +import { DROPDOWN_Y_OFFSET } from '../constants'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -147; export default { components: { @@ -16,7 +20,22 @@ export default { required: true, }, }, + data() { + return { + dropdownOpen: false, + }; + }, toggleId: 'create-menu-toggle', + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], + }, }; </script> @@ -30,9 +49,17 @@ export default { text-sr-only :toggle-text="$options.i18n.createNew" :toggle-id="$options.toggleId" + :popper-options="$options.popperOptions" data-qa-selector="new_menu_toggle" + @shown="dropdownOpen = true" + @hidden="dropdownOpen = false" /> - <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar"> + <gl-tooltip + v-if="!dropdownOpen" + :target="`#${$options.toggleId}`" + placement="bottom" + container="#super-sidebar" + > {{ $options.i18n.createNew }} </gl-tooltip> </div> diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue index 5269c7f8d5e..56143a29f52 100644 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue @@ -1,13 +1,20 @@ <script> +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import { s__ } from '~/locale'; import AccessorUtilities from '~/lib/utils/accessor'; import { getTopFrequentItems, formatContextSwitcherItems } from '../utils'; import ItemsList from './items_list.vue'; export default { components: { + GlIcon, + GlButton, ItemsList, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { title: { type: String, @@ -29,12 +36,16 @@ export default { data() { return { cachedFrequentItems: [], + isItemsListEditable: false, }; }, computed: { isEmpty() { return !this.cachedFrequentItems.length; }, + allowItemsEditing() { + return !this.isEmpty && AccessorUtilities.canUseLocalStorage(); + }, }, created() { this.getItemsFromLocalStorage(); @@ -52,6 +63,27 @@ export default { Sentry.captureException(e); } }, + toggleItemsListEditablity() { + this.isItemsListEditable = !this.isItemsListEditable; + }, + handleItemRemove(item) { + try { + // Remove item from local storage + const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey)); + localStorage.setItem( + this.storageKey, + JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)), + ); + + // Update the list + this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); + } catch (e) { + Sentry.captureException(e); + } + }, + }, + i18n: { + frequentItemsEditToggle: s__('Navigation|Toggle edit mode'), }, }; </script> @@ -61,14 +93,32 @@ export default { <div data-testid="list-title" aria-hidden="true" - class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" + class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" > - {{ title }} + <span class="gl-flex-grow-1">{{ title }}</span> + <gl-button + v-if="allowItemsEditing" + v-gl-tooltip.left + size="small" + category="tertiary" + :aria-label="$options.i18n.frequentItemsEditToggle" + :title="$options.i18n.frequentItemsEditToggle" + :class="{ 'gl-bg-gray-100!': isItemsListEditable }" + class="gl-p-2!" + @click="toggleItemsListEditablity" + > + <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" /> + </gl-button> </div> <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3"> {{ pristineText }} </div> - <items-list :aria-label="title" :items="cachedFrequentItems"> + <items-list + :aria-label="title" + :items="cachedFrequentItems" + :editable="isItemsListEditable" + @remove-item="handleItemRemove" + > <template #view-all-items> <slot name="view-all-items"></slot> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index 6798607b954..e8a54b0515e 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -6,73 +6,64 @@ import { GlToken, GlTooltipDirective, GlResizeObserverDirective, + GlModal, } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import { debounce } from 'lodash'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { debounce, clamp } from 'lodash'; import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { + MIN_SEARCH_TERM, SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, } from '~/vue_shared/global_search/constants'; import { - FIRST_DROPDOWN_INDEX, - SEARCH_BOX_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, IS_SEARCHING, - IS_FOCUSED, - IS_NOT_FOCUSED, + SEARCH_MODAL_ID, + SEARCH_INPUT_SELECTOR, + SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; -import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from './global_search_default_items.vue'; -import HeaderSearchScopedItems from './global_search_scoped_items.vue'; +import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from './global_search_default_items.vue'; +import GlobalSearchScopedItems from './global_search_scoped_items.vue'; export default { - name: 'HeaderSearchApp', + name: 'GlobalSearchModal', + SEARCH_MODAL_ID, i18n: { SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, + MIN_SEARCH_TERM, }, directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, - HeaderSearchDefaultItems, - HeaderSearchScopedItems, - HeaderSearchAutocompleteItems, - DropdownKeyboardNavigation, + GlobalSearchDefaultItems, + GlobalSearchScopedItems, + GlobalSearchAutocompleteItems, GlIcon, GlToken, - }, - data() { - return { - showDropdown: false, - isFocused: false, - currentFocusIndex: SEARCH_BOX_INDEX, - }; + GlModal, }, computed: { ...mapState(['search', 'loading', 'searchContext']), - ...mapGetters(['searchQuery', 'searchOptions']), + ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), searchText: { get() { return this.search; @@ -81,51 +72,26 @@ export default { this.setSearch(value); }, }, - currentFocusedOption() { - return this.searchOptions[this.currentFocusIndex]; - }, - currentFocusedId() { - return this.currentFocusedOption?.html_id; - }, - isLoggedIn() { - return Boolean(gon?.current_username); - }, - showSearchDropdown() { - if (!this.showDropdown || !this.isLoggedIn) { - return false; - } - return this.searchOptions?.length > 0; - }, showDefaultItems() { return !this.searchText; }, searchTermOverMin() { return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, - defaultIndex() { - if (this.showDefaultItems) { - return SEARCH_BOX_INDEX; - } - return FIRST_DROPDOWN_INDEX; - }, - - searchInputDescribeBy() { - if (this.isLoggedIn) { - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; - } - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + showScopedSearchItems() { + return this.searchTermOverMin && this.scopedSearchOptions.length > 1; }, - dropdownResultsDescription() { - if (!this.showSearchDropdown) { - return ''; // This allows aria-live to see register an update when the dropdown is shown - } - + searchResultsDescription() { if (this.showDefaultItems) { return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { count: this.searchOptions.length, }); } + if (!this.searchTermOverMin) { + return this.$options.i18n.MIN_SEARCH_TERM; + } + return this.loading ? this.$options.i18n.SEARCH_RESULTS_LOADING : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { @@ -135,12 +101,10 @@ export default { searchBarClasses() { return { [IS_SEARCHING]: this.searchTermOverMin, - [IS_FOCUSED]: this.isFocused, - [IS_NOT_FOCUSED]: !this.isFocused, }; }, showScopeHelp() { - return this.searchTermOverMin && this.isFocused; + return this.searchTermOverMin; }, searchBarItem() { return this.searchOptions?.[0]; @@ -159,47 +123,7 @@ export default { }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), - openDropdown() { - this.showDropdown = true; - - // check isFocused state to avoid firing duplicate events - if (!this.isFocused) { - this.isFocused = true; - this.$emit('expandSearchBar', true); - - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - } - }, - closeDropdown() { - this.showDropdown = false; - }, - collapseAndCloseSearchBar() { - // we need a delay on this method - // for the search bar not to remove - // the clear button from dom - // and register clicks on dropdown items - setTimeout(() => { - this.showDropdown = false; - this.isFocused = false; - this.$emit('collapseSearchBar'); - - Tracking.event(undefined, 'blur_input', { - label: 'global_search', - property: 'navigation_top', - }); - }, 200); - }, - 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) { - this.openDropdown(); if (!searchTerm) { this.clearAutocomplete(); } else { @@ -216,105 +140,174 @@ export default { } inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; }, + getFocusableOptions() { + return Array.from( + this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [], + ); + }, + onKeydown(event) { + const { code, target } = event; + + let stop = true; + + const elements = this.getFocusableOptions(); + if (elements.length < 1) return; + + const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); + + if (code === HOME_KEY) { + this.focusItem(0, elements); + } else if (code === END_KEY) { + this.focusItem(elements.length - 1, elements); + } else if (code === ARROW_UP_KEY) { + if (isSearchInput) return; + + if (elements.indexOf(target) === 0) { + this.focusSearchInput(); + return; + } + this.focusNextItem(event, elements, -1); + } else if (code === ARROW_DOWN_KEY) { + this.focusNextItem(event, elements, 1); + } else if (code === ESC_KEY) { + this.$refs.searchModal.close(); + } else { + stop = false; + } + + if (stop) { + event.preventDefault(); + } + }, + focusSearchInput() { + this.$refs.searchInputBox.$el.querySelector('input').focus(); + }, + focusNextItem(event, elements, offset) { + const { target } = event; + const currentIndex = elements.indexOf(target); + const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1); + + this.focusItem(nextIndex, elements); + }, + focusItem(index, elements) { + this.nextFocusedItemIndex = index; + + elements[index]?.focus(); + }, + submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return; + } + visitUrl(this.searchQuery); + }, }, - SEARCH_BOX_INDEX, - FIRST_DROPDOWN_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <form - v-outside="closeDropdown" - role="search" - :aria-label="$options.i18n.SEARCH_GITLAB" - class="header-search gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" - data-testid="header-search-form" + <gl-modal + ref="searchModal" + :modal-id="$options.SEARCH_MODAL_ID" + hide-header + hide-footer + hide-header-close + scrollable + body-class="gl-p-0!" + modal-class="global-search-modal" + :centered="false" > - <gl-search-box-by-type - id="search" - ref="searchInputBox" - v-model="searchText" - role="searchbox" - class="gl-z-index-1" - data-qa-selector="search_term_field" - autocomplete="off" - :placeholder="$options.i18n.SEARCH_GITLAB" - :aria-activedescendant="currentFocusedId" - :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focus="openDropdown" - @click="openDropdown" - @blur="collapseAndCloseSearchBar" - @input="getAutocompleteOptions" - @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="closeDropdown" - /> - <gl-token - v-if="showScopeHelp" - 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.SEARCH_RESULTS_SCOPE, { - scope: infieldHelpContent, - }), - ) - }} - </gl-token> - <kbd - v-show="!isFocused" - 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.KBD_HELP" - >/</kbd - > - <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ - searchInputDescribeBy - }}</span> - <span - role="region" - :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" - class="gl-sr-only" - aria-live="polite" - aria-atomic="true" + <form + role="search" + :aria-label="$options.i18n.SEARCH_GITLAB" + class="gl-relative gl-rounded-base gl-w-full" + :class="searchBarClasses" + data-testid="global-search-form" > - {{ dropdownResultsDescription }} - </span> - <div - v-if="showSearchDropdown" - data-testid="header-search-dropdown-menu" - class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3" - > - <div class="header-search-dropdown-content gl-py-2"> - <dropdown-keyboard-navigation - v-model="currentFocusIndex" - :max="searchOptions.length - 1" - :min="$options.FIRST_DROPDOWN_INDEX" - :default-index="defaultIndex" - @tab="closeDropdown" - /> - <header-search-default-items - v-if="showDefaultItems" - :current-focused-option="currentFocusedOption" + <div class="gl-p-1"> + <gl-search-box-by-type + id="search" + ref="searchInputBox" + v-model="searchText" + role="searchbox" + data-testid="global-search-input" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_GITLAB" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + borderless + @input="getAutocompleteOptions" + @keydown.enter.stop.prevent="submitSearch" + @keydown="onKeydown" /> - <template v-else> - <header-search-scoped-items - v-if="searchTermOverMin" - :current-focused-option="currentFocusedOption" + <gl-token + v-if="showScopeHelp" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help gl-sm-display-block gl-display-none" + view-only + :title="scopeTokenTitle" + > + <gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" /> - <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> + {{ + getTruncatedScope( + sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), + ) + }} + </gl-token> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> + {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} + </span> + </div> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ searchResultsDescription }} + </span> + <div + ref="resultsList" + data-testid="global-search-results" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + @keydown="onKeydown" + > + <global-search-default-items v-if="showDefaultItems" /> + <template v-else> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> </template> </div> - </div> - </form> + + <template v-if="searchContext"> + <input + v-if="searchContext.group" + type="hidden" + name="group_id" + :value="searchContext.group.id" + /> + <input + v-if="searchContext.project" + type="hidden" + name="project_id" + :value="searchContext.project.id" + /> + + <template v-if="searchContext.group || searchContext.project"> + <input type="hidden" name="scope" :value="searchContext.scope" /> + <input type="hidden" name="search_code" :value="searchContext.code_search" /> + </template> + + <input type="hidden" name="snippets" :value="searchContext.for_snippets" /> + <input type="hidden" name="repository_ref" :value="searchContext.ref" /> + </template> + </form> + </gl-modal> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue index 1838214def6..cd623200b03 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -1,113 +1,36 @@ <script> -import { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlAvatar, - GlAlert, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { - GROUPS_CATEGORY, - PROJECTS_CATEGORY, - MERGE_REQUEST_CATEGORY, - ISSUES_CATEGORY, - RECENT_EPICS_CATEGORY, - AUTOCOMPLETE_ERROR_MESSAGE, -} from '~/vue_shared/global_search/constants'; -import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; +import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchAutocompleteItems', + name: 'GlobalSearchAutocompleteItems', i18n: { AUTOCOMPLETE_ERROR_MESSAGE, }, components: { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, GlAvatar, GlAlert, GlLoadingIcon, + GlDisclosureDropdownGroup, }, directives: { SafeHtml, }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, computed: { - ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']), - ...mapGetters(['autocompleteGroupedSearchOptions']), - }, - watch: { - currentFocusedOption() { - const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; - - if (focusedElement) { - focusedElement.scrollIntoView(false); - } + ...mapState(['search', 'loading', 'autocompleteError']), + ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), + isPrecededByScopedOptions() { + return this.scopedSearchOptions.length > 1; }, }, methods: { - truncateNamespace(string) { - if (string.split(' / ').length > 2) { - return truncateNamespace(string); - } - - return string; - }, highlightedName(val) { return highlight(val, this.search); }, - avatarSize(data) { - if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) { - return LARGE_AVATAR_PX; - } - - return SMALL_AVATAR_PX; - }, - isOptionFocused(data) { - return this.currentFocusedOption?.html_id === data.html_id; - }, - isProjectsCategory(data) { - return data.category === PROJECTS_CATEGORY; - }, - getEntityId(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_id || data.id || this.searchContext?.group?.id; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_id || data.id || this.searchContext?.project?.id; - default: - return data.id; - } - }, - getEntitytName(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_name || data.value || data.label || this.searchContext?.group?.name; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_name || data.value || data.label || this.searchContext?.project?.name; - default: - return data.label; - } - }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -115,46 +38,46 @@ export default { <template> <div> - <template v-if="!loading"> - <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category"> - <gl-dropdown-divider v-if="index > 0" /> - <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="data in option.data" - :id="data.html_id" - :ref="data.html_id" - :key="data.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(data) }" - :aria-selected="isOptionFocused(data)" - :aria-label="data.label" - tabindex="-1" - :href="data.url" - > - <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> + <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> + <gl-disclosure-dropdown-group + v-for="group in autocompleteGroupedSearchOptions" + :key="group.name" + :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :group="group" + bordered + > + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> <gl-avatar - v-if="data.avatar_url !== undefined" - :src="data.avatar_url" - :entity-id="getEntityId(data)" - :entity-name="getEntitytName(data)" - :size="avatarSize(data)" + v-if="item.avatar_url !== undefined" + class="gl-mr-3" + :src="item.avatar_url" + :entity-id="item.entity_id" + :entity-name="item.entity_name" + :size="item.avatar_size" :shape="$options.AVATAR_SHAPE_OPTION_RECT" + aria-hidden="true" /> <span class="gl-display-flex gl-flex-direction-column"> <span - v-safe-html="highlightedName(data.value || data.label)" + v-safe-html="highlightedName(item.text)" class="gl-text-gray-900" + data-testid="autocomplete-item-name" ></span> <span - v-if="data.value" - v-safe-html="truncateNamespace(data.label)" + v-if="item.value" + v-safe-html="item.namespace" class="gl-font-sm gl-text-gray-500" + data-testid="autocomplete-item-namespace" ></span> </span> </div> - </gl-dropdown-item> - </div> - </template> + </template> + </gl-disclosure-dropdown-group> + </ul> + <gl-loading-icon v-else size="lg" class="my-4" /> + <gl-alert v-if="autocompleteError" class="gl-text-body gl-mt-2" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue index f0d398297e9..239c61fd750 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue @@ -1,23 +1,15 @@ <script> -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchDefaultItems', + name: 'GlobalSearchDefaultItems', i18n: { ALL_GITLAB, }, components: { - GlDropdownSectionHeader, - GlDropdownItem, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['searchContext']), @@ -29,30 +21,18 @@ export default { this.$options.i18n.ALL_GITLAB ); }, - }, - methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; + defaultItemsGroup() { + return { + name: this.sectionHeader, + items: this.defaultSearchOptions, + }; }, }, }; </script> <template> - <div> - <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="option in defaultSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="option.title" - tabindex="-1" - :href="option.url" - > - <span aria-hidden="true">{{ option.title }}</span> - </gl-dropdown-item> - </div> + <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" /> + </ul> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue index 1ef88492b23..76600f829f6 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue @@ -1,47 +1,26 @@ <script> -import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; +import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; -import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants'; import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; export default { - name: 'HeaderSearchScopedItems', - i18n: { - SCOPED_SEARCH_ITEM_ARIA_LABEL, - }, + name: 'GlobalSearchScopedItems', components: { - GlDropdownItem, GlIcon, GlToken, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['search']), - ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']), + ...mapGetters(['scopedSearchGroup']), }, methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; - }, - ariaLabel(option) { - return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, { - search: this.search, - description: option.description || option.icon, - scope: option.scope || '', - }); - }, - titleLabel(option) { + titleLabel(item) { return sprintf(s__('GlobalSearch|in %{scope}'), { search: this.search, - scope: option.scope || option.description, + scope: item.scope || item.description, }); }, getTruncatedScope(scope) { @@ -53,35 +32,23 @@ export default { <template> <div> - <gl-dropdown-item - v-for="option in scopedSearchOptions" - :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 - 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" + <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!"> + <template #list-item="{ item }"> + <span + class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full" > - <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-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" /> + <span class="gl-flex-grow-1"> + <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(item)) }}</span> + </gl-token> + {{ search }} + </span> + </span> + </template> + </gl-disclosure-dropdown-group> + </ul> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js index b9bb4e573fd..cb267df6122 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -8,10 +8,6 @@ export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; -export const FIRST_DROPDOWN_INDEX = 0; - -export const SEARCH_BOX_INDEX = -1; - export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; @@ -20,14 +16,13 @@ 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']; +export const INPUT_FIELD_PADDING = 84; export const IS_SEARCHING = 'is-searching'; -export const IS_FOCUSED = 'is-focused'; -export const IS_NOT_FOCUSED = 'is-not-focused'; export const FETCH_TYPES = ['generic', 'search']; +export const SEARCH_MODAL_ID = 'super-sidebar-search-modal'; + +export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless'; -export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; +export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js index f86463b94d1..4a42f416206 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js @@ -1,6 +1,5 @@ import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; - import { MSG_ISSUES_ASSIGNED_TO_ME, MSG_ISSUES_IVE_CREATED, @@ -10,8 +9,10 @@ import { MSG_IN_ALL_GITLAB, PROJECTS_CATEGORY, GROUPS_CATEGORY, - DROPDOWN_ORDER, + SEARCH_RESULTS_ORDER, } from '~/vue_shared/global_search/constants'; +import { getFormattedItem } from '../utils'; + import { ICON_GROUP, ICON_SUBGROUP, @@ -62,32 +63,27 @@ export const defaultSearchOptions = (state, getters) => { const issues = [ { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${getters.scopedIssuesPath}/?author_username=${userName}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, ]; const mergeRequests = [ { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${getters.scopedMRPath}/?assignee_username=${userName}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, + text: MSG_MR_IM_REVIEWER, + href: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${getters.scopedMRPath}/?author_username=${userName}`, + text: MSG_MR_IVE_CREATED, + href: `${getters.scopedMRPath}/?author_username=${userName}`, }, ]; return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests]; @@ -145,58 +141,64 @@ export const allUrl = (state) => { }; export const scopedSearchOptions = (state, getters) => { - const options = []; + const items = []; if (state.searchContext?.project) { - options.push({ - html_id: 'scoped-in-project', + items.push({ + text: 'scoped-in-project', scope: state.searchContext.project?.name || '', scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: getters.projectUrl, + href: getters.projectUrl, }); } if (state.searchContext?.group) { - options.push({ - html_id: 'scoped-in-group', + items.push({ + text: 'scoped-in-group', scope: state.searchContext.group?.name || '', scopeCategory: GROUPS_CATEGORY, icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, - url: getters.groupUrl, + href: getters.groupUrl, }); } - options.push({ - html_id: 'scoped-in-all', + items.push({ + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: getters.allUrl, + href: getters.allUrl, }); - return options; + return items; +}; + +export const scopedSearchGroup = (state, getters) => { + const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : []; + return { items }; }; export const autocompleteGroupedSearchOptions = (state) => { const groupedOptions = {}; const results = []; - state.autocompleteOptions.forEach((option) => { - const category = groupedOptions[option.category]; + state.autocompleteOptions.forEach((item) => { + const group = groupedOptions[item.category]; + const formattedItem = getFormattedItem(item, state.searchContext); - if (category) { - category.data.push(option); + if (group) { + group.items.push(formattedItem); } else { - groupedOptions[option.category] = { - category: option.category, - data: [option], + groupedOptions[item.category] = { + name: formattedItem.category, + items: [formattedItem], }; - results.push(groupedOptions[option.category]); + results.push(groupedOptions[formattedItem.category]); } }); return results.sort( - (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category), + (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name), ); }; @@ -206,8 +208,8 @@ export const searchOptions = (state, getters) => { } const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( - (options, group) => { - return [...options, ...group.data]; + (items, group) => { + return [...items, ...group.items]; }, [], ); @@ -216,5 +218,5 @@ export const searchOptions = (state, getters) => { return sortedAutocompleteOptions; } - return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); + return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions); }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js index 6e65345757f..d7d9ebecd16 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js @@ -2,5 +2,4 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; - export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js index 19b4d4ec330..9936c3f59d8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js @@ -8,11 +8,7 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = [...state.autocompleteOptions].concat( - data.map((d, i) => { - return { html_id: `autocomplete-${d.category}-${i}`, ...d }; - }), - ); + state.autocompleteOptions = [...state.autocompleteOptions].concat(data); state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js new file mode 100644 index 00000000000..11d1fa1ab95 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -0,0 +1,81 @@ +import { pickBy } from 'lodash'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; +import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; + +const getTruncatedNamespace = (string) => { + if (string.split(' / ').length > 2) { + return truncateNamespace(string); + } + + return string; +}; +const getAvatarSize = (category) => { + if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) { + return LARGE_AVATAR_PX; + } + + return SMALL_AVATAR_PX; +}; + +const getEntityId = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_id || item.id || searchContext?.group?.id; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_id || item.id || searchContext?.project?.id; + default: + return item.id; + } +}; +const getEntityName = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_name || item.value || item.label || searchContext?.group?.name; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_name || item.value || item.label || searchContext?.project?.name; + default: + return item.label; + } +}; + +export const getFormattedItem = (item, searchContext) => { + const { id, category, value, label, url: href, avatar_url } = item; + let namespace; + const text = value || label; + if (value) { + namespace = getTruncatedNamespace(label); + } + const avatarSize = getAvatarSize(category); + const entityId = getEntityId(item, searchContext); + const entityName = getEntityName(item, searchContext); + + return pickBy( + { + id, + category, + value, + label, + text, + href, + avatar_url, + avatar_size: avatarSize, + namespace, + entity_id: entityId, + entity_name: entityName, + }, + (val) => val !== undefined, + ); +}; diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue index 78b5ed2d31e..4fa15f1cd76 100644 --- a/app/assets/javascripts/super_sidebar/components/groups_list.vue +++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue @@ -36,16 +36,19 @@ export default { storageKey() { return `${this.username}/frequent-groups`; }, - viewAllItem() { + viewAllProps() { return { - link: this.viewAllLink, - title: s__('Navigation|View all groups'), - icon: 'group', + item: { + link: this.viewAllLink, + title: s__('Navigation|View all your groups'), + icon: 'group', + }, + linkClasses: { 'dashboard-shortcuts-groups': true }, }; }, }, i18n: { - title: s__('Navigation|Frequent groups'), + title: s__('Navigation|Frequently visited groups'), searchTitle: s__('Navigation|Groups'), pristineText: s__('Navigation|Groups you visit often will appear here.'), noResultsText: s__('Navigation|No group matches found'), @@ -61,7 +64,7 @@ export default { :search-results="searchResults" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </search-results> <frequent-items-list @@ -72,7 +75,7 @@ export default { :pristine-text="$options.i18n.pristineText" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </frequent-items-list> </template> diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index fb23a4f2deb..01b214f4b2b 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -5,6 +5,11 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { __ } from '~/locale'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; +import Tracking from '~/tracking'; +import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS } from '../constants'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -4; export default { components: { @@ -14,6 +19,7 @@ export default { GlDisclosureDropdownGroup, GitlabVersionCheckBadge, }, + mixins: [Tracking.mixin({ property: 'nav_help_menu' })], i18n: { help: __('Help'), support: __('Support'), @@ -46,21 +52,63 @@ export default { text: this.$options.i18n.version, href: helpPagePath('update/index'), version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`, + extraAttrs: { + ...this.trackingAttrs('version_help_dropdown'), + }, }, ], }, helpLinks: { items: [ - { text: this.$options.i18n.help, href: helpPagePath() }, - { text: this.$options.i18n.support, href: this.sidebarData.support_path }, - { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' }, - { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` }, - { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' }, + { + text: this.$options.i18n.help, + href: helpPagePath(), + extraAttrs: { + ...this.trackingAttrs('help'), + }, + }, + { + text: this.$options.i18n.support, + href: this.sidebarData.support_path, + extraAttrs: { + ...this.trackingAttrs('support'), + }, + }, + { + text: this.$options.i18n.docs, + href: 'https://docs.gitlab.com', + extraAttrs: { + ...this.trackingAttrs('gitlab_documentation'), + }, + }, + { + text: this.$options.i18n.plans, + href: `${PROMO_URL}/pricing`, + extraAttrs: { + ...this.trackingAttrs('compare_gitlab_plans'), + }, + }, + { + text: this.$options.i18n.forum, + href: 'https://forum.gitlab.com/', + extraAttrs: { + ...this.trackingAttrs('community_forum'), + }, + }, { text: this.$options.i18n.contribute, href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + extraAttrs: { + ...this.trackingAttrs('contribute_to_gitlab'), + }, + }, + { + text: this.$options.i18n.feedback, + href: 'https://about.gitlab.com/submit-feedback', + extraAttrs: { + ...this.trackingAttrs('submit_feedback'), + }, }, - { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, ], }, helpActions: { @@ -70,6 +118,9 @@ export default { action: this.showKeyboardShortcuts, extraAttrs: { class: 'js-shortcuts-modal-trigger', + 'data-track-action': 'click_button', + 'data-track-label': 'keyboard_shortcuts_help', + 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'], }, shortcut: '?', }, @@ -79,6 +130,11 @@ export default { count: this.showWhatsNewNotification && this.sidebarData.whats_new_most_recent_release_items_count, + extraAttrs: { + 'data-track-action': 'click_button', + 'data-track-label': 'whats_new', + 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'], + }, }, ].filter(Boolean), }, @@ -118,12 +174,40 @@ export default { this.toggleWhatsNewDrawer(); } }, + + trackingAttrs(label) { + return { + ...HELP_MENU_TRACKING_DEFAULTS, + 'data-track-label': label, + }; + }, + + trackDropdownToggle(show) { + this.track('click_toggle', { + label: show ? 'show_help_dropdown' : 'hide_help_dropdown', + }); + }, + }, + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], }, }; </script> <template> - <gl-disclosure-dropdown ref="dropdown"> + <gl-disclosure-dropdown + ref="dropdown" + :popper-options="$options.popperOptions" + @shown="trackDropdownToggle(true)" + @hidden="trackDropdownToggle(false)" + > <template #toggle> <gl-button category="tertiary" icon="question-o" class="btn-with-notification"> <span v-if="showWhatsNewNotification" class="notification-dot-info"></span> diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue index 0a72105fcc4..8ee7d57c47c 100644 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -1,18 +1,29 @@ <script> +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import NavItem from './nav_item.vue'; export default { components: { + GlIcon, + GlButton, ProjectAvatar, NavItem, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { items: { type: Array, required: false, default: () => [], }, + editable: { + type: Boolean, + required: false, + default: false, + }, }, }; </script> @@ -34,6 +45,21 @@ export default { aria-hidden="true" /> </template> + <template #actions> + <gl-button + v-if="editable" + v-gl-tooltip.left + size="small" + category="tertiary" + :aria-label="__('Remove')" + :title="__('Remove')" + class="gl-align-self-center gl-p-1! gl-absolute gl-right-4" + data-testid="item-remove" + @click.stop.prevent="$emit('remove-item', item)" + > + <gl-icon name="close" /> + </gl-button> + </template> </nav-item> <slot name="view-all-items"></slot> </ul> diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue index 94fc6aedcc0..d37e863bed9 100644 --- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue @@ -16,7 +16,12 @@ export default { </script> <template> - <gl-disclosure-dropdown :items="items" placement="center"> + <gl-disclosure-dropdown + :items="items" + placement="center" + @shown="$emit('shown')" + @hidden="$emit('hidden')" + > <template #toggle> <slot></slot> </template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index cd5363ad7a5..223fbe6d078 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -1,15 +1,41 @@ <script> import { kebabCase } from 'lodash'; -import { GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; +import { GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + CLICK_MENU_ITEM_ACTION, + TRACKING_UNKNOWN_ID, + TRACKING_UNKNOWN_PANEL, +} from '~/super_sidebar/constants'; export default { + i18n: { + pinItem: s__('Navigation|Pin item'), + unpinItem: s__('Navigation|Unpin item'), + }, name: 'NavItem', components: { + GlButton, GlCollapse, GlIcon, GlBadge, }, + inject: { + pinnedItemIds: { default: { ids: [] } }, + panelSupportsPins: { default: false }, + panelType: { default: '' }, + }, props: { + draggable: { + type: Boolean, + required: false, + default: false, + }, + isStatic: { + type: Boolean, + required: false, + default: false, + }, item: { type: Object, required: true, @@ -53,25 +79,53 @@ export default { } return this.item.is_active; }, + isPinnable() { + return this.panelSupportsPins && !this.isSection && !this.isStatic; + }, + isPinned() { + return this.pinnedItemIds.ids.includes(this.item.id); + }, + trackingProps() { + // Set extra event data to debug missing IDs / Panel Types + const extraData = + !this.item.id || !this.panelType + ? { 'data-track-extra': JSON.stringify({ title: this.item.title }) } + : {}; + + return { + 'data-track-action': CLICK_MENU_ITEM_ACTION, + 'data-track-label': this.item.id ?? TRACKING_UNKNOWN_ID, + 'data-track-property': this.panelType + ? `nav_panel_${this.panelType}` + : TRACKING_UNKNOWN_PANEL, + ...extraData, + }; + }, linkProps() { if (this.isSection) { return { 'aria-controls': this.itemId, 'aria-expanded': String(this.expanded), + 'data-qa-menu-item': this.item.title, }; } return { ...this.$attrs, + ...this.trackingProps, href: this.item.link, 'aria-current': this.isActive ? 'page' : null, + 'data-qa-submenu-item': this.item.title, }; }, computedLinkClasses() { return { // Reset user agent styles on <button> 'gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left': this.isSection, - 'gl-w-full gl-focus': this.isSection, + 'gl-w-full gl-focus--focus': this.isSection, + 'nav-item-link': !this.isSection, 'gl-bg-t-gray-a-08': this.isActive, + 'gl-py-2': this.isPinnable, + 'gl-py-3': !this.isPinnable, ...this.linkClasses, }; }, @@ -92,11 +146,10 @@ export default { <component :is="elem" v-bind="linkProps" - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-text-decoration-none!" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none!" :class="computedLinkClasses" - data-qa-selector="sidebar_menu_link" + data-qa-selector="nav_item_link" data-testid="nav-item-link" - :data-qa-menu-item="item.title" @click="click" > <div @@ -107,26 +160,50 @@ export default { ></div> <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> + <gl-icon + v-else-if="draggable" + name="grip" + class="gl-text-gray-400 gl-ml-2 draggable-icon" + /> </slot> </div> - <div class="gl-pr-3 gl-text-gray-900"> + <div class="gl-pr-3 gl-text-gray-900 gl-truncate-end"> {{ item.title }} - <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500"> + <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end"> {{ item.subtitle }} </div> </div> - <span v-if="isSection || hasPill" class="gl-flex-grow-1 gl-text-right gl-mr-3"> + <slot name="actions"></slot> + <span v-if="isSection || hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3"> <gl-badge v-if="hasPill" size="sm" variant="info"> {{ pillData }} </gl-badge> <gl-icon v-else-if="isSection" :name="collapseIcon" /> + <gl-button + v-else-if="isPinnable && !isPinned" + size="small" + category="tertiary" + icon="thumbtack" + :aria-label="$options.i18n.pinItem" + @click.prevent="$emit('pin-add', item.id)" + /> + <gl-button + v-else-if="isPinnable && isPinned" + size="small" + category="tertiary" + :aria-label="$options.i18n.unpinItem" + icon="thumbtack-solid" + @click.prevent="$emit('pin-remove', item.id)" + /> </span> </component> <gl-collapse v-if="isSection" :id="itemId" v-model="expanded" + data-qa-selector="menu_section" + :data-qa-section="item.title" :aria-label="item.title" class="gl-list-style-none gl-p-0" tag="ul" @@ -135,6 +212,8 @@ export default { v-for="subItem of item.items" :key="`${item.title}-${subItem.title}`" :item="subItem" + @pin-add="(itemId) => $emit('pin-add', itemId)" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" /> </gl-collapse> </li> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue new file mode 100644 index 00000000000..9595bdfb632 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -0,0 +1,104 @@ +<script> +import { GlCollapse, GlIcon } from '@gitlab/ui'; +import Draggable from 'vuedraggable'; +import { s__ } from '~/locale'; +import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../constants'; +import NavItem from './nav_item.vue'; + +export default { + i18n: { + pinned: s__('Navigation|Pinned'), + emptyHint: s__('Navigation|Your pinned items appear here.'), + }, + name: 'PinnedSection', + components: { + Draggable, + GlCollapse, + GlIcon, + NavItem, + }, + props: { + items: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false', + draggableItems: this.items, + }; + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + itemIds() { + return this.draggableItems.map((item) => item.id); + }, + }, + watch: { + expanded(newExpanded) { + setCookie(SIDEBAR_PINS_EXPANDED_COOKIE, newExpanded, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }, + items(newItems) { + this.draggableItems = newItems; + }, + }, + methods: { + handleDrag(event) { + if (event.oldIndex === event.newIndex) return; + this.$emit( + 'pin-reorder', + this.items[event.oldIndex].id, + this.items[event.newIndex].id, + event.oldIndex < event.newIndex, + ); + }, + }, +}; +</script> + +<template> + <section class="gl-mx-2"> + <a + href="#" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none!" + @click.prevent="expanded = !expanded" + > + <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> + <gl-icon name="thumbtack" class="gl-ml-2 item-icon" /> + </div> + + <span class="gl-font-weight-bold gl-font-sm gl-flex-grow-1">{{ $options.i18n.pinned }}</span> + <gl-icon :name="collapseIcon" class="gl-mr-3" /> + </a> + <gl-collapse v-model="expanded"> + <draggable + v-if="items.length > 0" + v-model="draggableItems" + class="gl-p-0 gl-m-0" + data-testid="pinned-nav-items" + handle=".draggable-icon" + tag="ul" + @end="handleDrag" + > + <nav-item + v-for="item of draggableItems" + :key="item.id" + draggable + :item="item" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + </draggable> + <div v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> + {{ $options.i18n.emptyHint }} + </div> + </gl-collapse> + <hr aria-hidden="true" class="gl-my-2 gl-mx-4" /> + </section> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue index a545de06bd4..78860e35eb1 100644 --- a/app/assets/javascripts/super_sidebar/components/projects_list.vue +++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue @@ -36,16 +36,19 @@ export default { storageKey() { return `${this.username}/frequent-projects`; }, - viewAllItem() { + viewAllProps() { return { - link: this.viewAllLink, - title: s__('Navigation|View all projects'), - icon: 'project', + item: { + link: this.viewAllLink, + title: s__('Navigation|View all your projects'), + icon: 'project', + }, + linkClasses: { 'dashboard-shortcuts-projects': true }, }; }, }, i18n: { - title: s__('Navigation|Frequent projects'), + title: s__('Navigation|Frequently visited projects'), searchTitle: s__('Navigation|Projects'), pristineText: s__('Navigation|Projects you visit often will appear here.'), noResultsText: s__('Navigation|No project matches found'), @@ -62,7 +65,7 @@ export default { :search-results="searchResults" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </search-results> <frequent-items-list @@ -73,7 +76,7 @@ export default { :pristine-text="$options.i18n.pristineText" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </frequent-items-list> </template> diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue index 7c172110bad..cfd6184bc47 100644 --- a/app/assets/javascripts/super_sidebar/components/search_results.vue +++ b/app/assets/javascripts/super_sidebar/components/search_results.vue @@ -1,10 +1,17 @@ <script> +import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; import ItemsList from './items_list.vue'; export default { components: { + GlCollapse, + GlIcon, ItemsList, }, + directives: { + CollapseToggle: GlCollapseToggleDirective, + }, props: { title: { type: String, @@ -20,30 +27,69 @@ export default { default: () => [], }, }, + data() { + return { + expanded: true, + }; + }, computed: { isEmpty() { return !this.searchResults.length; }, + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + }, + created() { + this.collapseId = uniqueId('expandable-section-'); }, + buttonClasses: [ + // Reset user agent styles + 'gl-appearance-none', + 'gl-border-0', + 'gl-bg-transparent', + // Text styles + 'gl-text-left', + 'gl-text-transform-uppercase', + 'gl-text-secondary', + 'gl-font-weight-bold', + 'gl-font-xs', + 'gl-line-height-12', + 'gl-letter-spacing-06em', + // Border + 'gl-border-t', + 'gl-border-gray-50', + // Spacing + 'gl-my-3', + 'gl-pt-2', + 'gl-w-full', + // Layout + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-align-items-center', + ], }; </script> <template> - <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3"> - <div - data-testid="list-title" - aria-hidden="true" - class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" + <li class="gl-border-t gl-border-gray-50 gl-mx-3"> + <button + v-collapse-toggle="collapseId" + :class="$options.buttonClasses" + data-testid="search-results-toggle" > {{ title }} - </div> - <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3"> - {{ noResultsText }} - </div> - <items-list :aria-label="title" :items="searchResults"> - <template #view-all-items> - <slot name="view-all-items"></slot> - </template> - </items-list> + <gl-icon :name="collapseIcon" :size="16" /> + </button> + <gl-collapse :id="collapseId" v-model="expanded"> + <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3"> + {{ noResultsText }} + </div> + <items-list :aria-label="title" :items="searchResults"> + <template #view-all-items> + <slot name="view-all-items"></slot> + </template> + </items-list> + </gl-collapse> </li> </template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index fc8968c50ea..ca165dd8602 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -1,24 +1,157 @@ <script> +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { PANELS_WITH_PINS } from '../constants'; import NavItem from './nav_item.vue'; +import PinnedSection from './pinned_section.vue'; export default { name: 'SidebarMenu', components: { NavItem, + PinnedSection, + }, + + provide() { + return { + pinnedItemIds: this.changedPinnedItemIds, + panelSupportsPins: this.supportsPins, + panelType: this.panelType, + }; }, props: { items: { type: Array, required: true, }, + pinnedItemIds: { + type: Array, + required: false, + default: () => [], + }, + panelType: { + type: String, + required: false, + default: '', + }, + updatePinsUrl: { + type: String, + required: true, + }, + }, + + data() { + return { + // This is used as a provide and injected into the nav items. + // Note: It has to be an object to be reactive. + changedPinnedItemIds: { ids: this.pinnedItemIds }, + }; + }, + + computed: { + // Returns the list of items that we want to have static at the top. + // Only sidebars that support pins also support a static section. + staticItems() { + if (!this.supportsPins) return []; + return this.items.filter((item) => !item.items || item.items.length === 0); + }, + + // Returns only the items that aren't static at the top and makes sure no + // section shows as active (and expanded) when one of its items is pinned. + nonStaticItems() { + if (!this.supportsPins) return this.items; + + return this.items + .filter((item) => item.items && item.items.length > 0) + .map((item) => { + const hasActivePinnedChild = item.items.some((childItem) => { + return childItem.is_active && this.changedPinnedItemIds.ids.includes(childItem.id); + }); + const showAsActive = item.is_active && !hasActivePinnedChild; + + return { ...item, is_active: showAsActive }; + }); + }, + + // Returns a flat list of all items that are in sections, but not the sections. + // Only items from sections (item.items) can be pinned. + flatPinnableItems() { + return this.nonStaticItems.flatMap((item) => item.items).filter(Boolean); + }, + + pinnedItems() { + return this.changedPinnedItemIds.ids + .map((id) => this.flatPinnableItems.find((item) => item.id === id)) + .filter(Boolean); + }, + supportsPins() { + return PANELS_WITH_PINS.includes(this.panelType); + }, + }, + methods: { + createPin(itemId) { + this.changedPinnedItemIds.ids.push(itemId); + this.updatePins(); + }, + destroyPin(itemId) { + this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId); + this.updatePins(); + }, + movePin(fromId, toId, isDownwards) { + const fromIndex = this.changedPinnedItemIds.ids.indexOf(fromId); + this.changedPinnedItemIds.ids.splice(fromIndex, 1); + + let toIndex = this.changedPinnedItemIds.ids.indexOf(toId); + + // If the item was moved downwards, we insert it *after* the item it was dragged on to. + // This matches how vuedraggable previews the change while still dragging. + if (isDownwards) toIndex += 1; + + this.changedPinnedItemIds.ids.splice(toIndex, 0, fromId); + + this.updatePins(); + }, + updatePins() { + axios + .put(this.updatePinsUrl, { + panel: this.panelType, + menu_item_ids: this.changedPinnedItemIds.ids, + }) + .then((response) => { + this.changedPinnedItemIds.ids = response.data; + }) + .catch((e) => { + Sentry.captureException(e); + }); + }, }, }; </script> <template> <nav class="gl-py-2 gl-relative"> + <section v-if="staticItems.length > 0" class="gl-mx-2"> + <ul class="gl-p-0 gl-m-0"> + <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> + </ul> + <hr aria-hidden="true" class="gl-my-2 gl-mx-4" /> + </section> + + <pinned-section + v-if="supportsPins" + :items="pinnedItems" + @pin-remove="destroyPin" + @pin-reorder="movePin" + /> + <ul class="gl-px-2 gl-list-style-none"> - <nav-item v-for="item in items" :key="`menu-${item.title}`" :item="item" /> + <nav-item + v-for="item in nonStaticItems" + :key="item.id" + :item="item" + @pin-add="createPin" + @pin-remove="destroyPin" + /> </ul> </nav> </template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index e8df534346b..4b54e317639 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -1,7 +1,13 @@ <script> import { GlButton, GlCollapse } from '@gitlab/ui'; import { __ } from '~/locale'; -import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + sidebarState, + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, +} from '../constants'; +import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; import UserBar from './user_bar.vue'; import SidebarPortalTarget from './sidebar_portal_target.vue'; import ContextSwitcherToggle from './context_switcher_toggle.vue'; @@ -20,6 +26,7 @@ export default { SidebarMenu, SidebarPortalTarget, }, + mixins: [glFeatureFlagsMixin()], i18n: { skipToMainContent: __('Skip to main content'), }, @@ -30,10 +37,7 @@ export default { }, }, data() { - return { - contextSwitcherOpened: false, - isCollapased: isCollapsed(), - }; + return sidebarState; }, computed: { menuItems() { @@ -44,6 +48,37 @@ export default { collapseSidebar() { toggleSuperSidebarCollapsed(true, false); }, + onContextSwitcherShown() { + this.$refs['context-switcher'].focusInput(); + }, + onHoverAreaMouseEnter() { + this.openPeekTimer = setTimeout(this.openPeek, SUPER_SIDEBAR_PEEK_OPEN_DELAY); + }, + onHoverAreaMouseLeave() { + clearTimeout(this.openPeekTimer); + }, + onSidebarMouseEnter() { + clearTimeout(this.closePeekTimer); + }, + onSidebarMouseLeave() { + this.closePeekTimer = setTimeout(this.closePeek, SUPER_SIDEBAR_PEEK_CLOSE_DELAY); + }, + closePeek() { + if (this.isPeek) { + this.isPeek = false; + this.isCollapsed = true; + } + }, + openPeek() { + this.isPeek = true; + this.isCollapsed = false; + + // Cancel and start the timer to close sidebar, in case the user moves + // the cursor fast enough away to not trigger a mouseenter event. + // This is cancelled if the user moves the cursor into the sidebar. + this.onSidebarMouseEnter(); + this.onSidebarMouseLeave(); + }, }, }; </script> @@ -51,14 +86,22 @@ export default { <template> <div> <div class="super-sidebar-overlay" @click="collapseSidebar"></div> + <div + v-if="!isPeek && glFeatures.superSidebarPeek" + class="super-sidebar-hover-area gl-fixed gl-left-0 gl-top-0 gl-bottom-0 gl-w-3" + data-testid="super-sidebar-hover-area" + @mouseenter="onHoverAreaMouseEnter" + @mouseleave="onHoverAreaMouseLeave" + ></div> <aside id="super-sidebar" - :aria-hidden="String(isCollapased)" class="super-sidebar" + :class="{ 'super-sidebar-peek': isPeek }" data-testid="super-sidebar" data-qa-selector="navbar" - :inert="isCollapased" - tabindex="-1" + :inert="isCollapsed" + @mouseenter="onSidebarMouseEnter" + @mouseleave="onSidebarMouseLeave" > <gl-button class="super-sidebar-skip-to gl-sr-only-focusable gl-absolute gl-left-3 gl-right-3 gl-top-3" @@ -67,23 +110,36 @@ export default { > {{ $options.i18n.skipToMainContent }} </gl-button> - <user-bar :sidebar-data="sidebarData" /> + <user-bar :has-collapse-button="!isPeek" :sidebar-data="sidebarData" /> <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> <div class="gl-flex-grow-1 gl-overflow-auto"> <context-switcher-toggle :context="sidebarData.current_context_header" - :expanded="contextSwitcherOpened" + :expanded="contextSwitcherOpen" + data-qa-selector="context_switcher" /> - <gl-collapse id="context-switcher" v-model="contextSwitcherOpened"> + <gl-collapse + id="context-switcher" + v-model="contextSwitcherOpen" + data-qa-selector="context_section" + @shown="onContextSwitcherShown" + > <context-switcher + ref="context-switcher" + :persistent-links="sidebarData.context_switcher_links" :username="sidebarData.username" :projects-path="sidebarData.projects_path" :groups-path="sidebarData.groups_path" :current-context="sidebarData.current_context" /> </gl-collapse> - <gl-collapse :visible="!contextSwitcherOpened"> - <sidebar-menu :items="menuItems" /> + <gl-collapse :visible="!contextSwitcherOpen"> + <sidebar-menu + :items="menuItems" + :panel-type="sidebarData.panel_type" + :pinned-item-ids="sidebarData.pinned_items" + :update-pins-url="sidebarData.update_pins_url" + /> <sidebar-portal-target /> </gl-collapse> </div> @@ -92,5 +148,14 @@ export default { </div> </div> </aside> + <a + v-for="shortcutLink in sidebarData.shortcut_links" + :key="shortcutLink.href" + :href="shortcutLink.href" + :class="shortcutLink.css_class" + class="gl-display-none" + > + {{ shortcutLink.title }} + </a> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue new file mode 100644 index 00000000000..3064b91ca7d --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -0,0 +1,80 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants'; +import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tooltipContainer: { + type: String, + required: false, + default: null, + }, + tooltipPlacement: { + type: String, + required: false, + default: 'right', + }, + }, + i18n: { + collapseSidebar: __('Collapse sidebar'), + expandSidebar: __('Expand sidebar'), + navigationSidebar: __('Navigation sidebar'), + }, + data() { + return sidebarState; + }, + computed: { + tooltipTitle() { + if (this.isPeek) return ''; + + return this.isCollapsed + ? this.$options.i18n.expandSidebar + : this.$options.i18n.collapseSidebar; + }, + tooltip() { + return { + placement: this.tooltipPlacement, + container: this.tooltipContainer, + title: this.tooltipTitle, + }; + }, + ariaExpanded() { + return String(!this.isCollapsed); + }, + }, + methods: { + toggle() { + toggleSuperSidebarCollapsed(!this.isCollapsed, true); + this.focusOtherToggle(); + }, + focusOtherToggle() { + this.$nextTick(() => { + const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; + const otherToggle = document.querySelector(`.${classSelector}`); + otherToggle?.focus(); + }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover="tooltip" + aria-controls="super-sidebar" + :aria-expanded="ariaExpanded" + :aria-label="$options.i18n.navigationSidebar" + icon="sidebar" + category="tertiary" + :disabled="isPeek" + @click="toggle" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index e27acb60372..f311c5242f5 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,19 +1,24 @@ <script> -import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import { highCountTrim } from '~/lib/utils/text_utility'; import logo from '../../../../views/shared/_logo.svg'; -import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; +import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants'; import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; import MergeRequestMenu from './merge_request_menu.vue'; import UserMenu from './user_menu.vue'; +import SuperSidebarToggle from './super_sidebar_toggle.vue'; +import { SEARCH_MODAL_ID } from './global_search/constants'; export default { // "GitLab Next" is a proper noun, so don't translate "Next" /* eslint-disable-next-line @gitlab/require-i18n-strings */ NEXT_LABEL: 'Next', logo, + JS_TOGGLE_COLLAPSE_CLASS, + SEARCH_MODAL_ID, components: { Counter, CreateMenu, @@ -21,29 +26,63 @@ export default { GlButton, MergeRequestMenu, UserMenu, + SearchModal: () => + import( + /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue' + ), + SuperSidebarToggle, }, i18n: { - collapseSidebar: __('Collapse sidebar'), createNew: __('Create new...'), + homepage: __('Homepage'), issues: __('Issues'), mergeRequests: __('Merge requests'), search: __('Search'), + searchKbdHelp: sprintf( + s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), todoList: __('To-Do list'), + stopImpersonating: __('Stop impersonating'), }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, SafeHtml, }, - inject: ['rootPath'], + inject: ['rootPath', 'isImpersonating'], props: { + hasCollapseButton: { + default: true, + type: Boolean, + required: false, + }, sidebarData: { type: Object, required: true, }, }, + data() { + return { + mrMenuShown: false, + todoCount: this.sidebarData.todos_pending_count, + }; + }, + computed: { + formattedTodoCount() { + return highCountTrim(this.todoCount); + }, + }, + mounted() { + document.addEventListener('todo:toggle', this.updateTodos); + }, + beforeDestroy() { + document.removeEventListener('todo:toggle', this.updateTodos); + }, methods: { - collapseSidebar() { - toggleSuperSidebarCollapsed(true, true, true); + updateTodos(e) { + this.todoCount = e.detail.count || 0; }, }, }; @@ -51,8 +90,15 @@ export default { <template> <div class="user-bar"> - <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-2"> - <a :href="rootPath"> + <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2"> + <a + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" + :href="rootPath" + :title="$options.i18n.homepage" + data-track-action="click_link" + data-track-label="gitlab_logo_link" + data-track-property="nav_core_menu" + > <img v-if="sidebarData.logo_url" data-testid="brand-header-custom-logo" @@ -66,54 +112,86 @@ export default { variant="success" :href="sidebarData.canary_toggle_com_url" size="sm" - >{{ $options.NEXT_LABEL }}</gl-badge + class="gl-ml-2" > + {{ $options.NEXT_LABEL }} + </gl-badge> <div class="gl-flex-grow-1"></div> - <gl-button - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.collapseSidebar" - :aria-label="$options.i18n.collapseSidebar" - icon="sidebar" - category="tertiary" - @click="collapseSidebar" + <super-sidebar-toggle + v-if="hasCollapseButton" + :class="$options.JS_TOGGLE_COLLAPSE_CLASS" + tooltip-placement="bottom" + tooltip-container="super-sidebar" + data-testid="super-sidebar-collapse-button" /> <create-menu :groups="sidebarData.create_new_menu_groups" /> + <gl-button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="$options.i18n.searchKbdHelp" + v-gl-modal="$options.SEARCH_MODAL_ID" + data-testid="super-sidebar-search-button" icon="search" :aria-label="$options.i18n.search" category="tertiary" - href="/search" /> + <search-modal /> + <user-menu :data="sidebarData" /> + + <gl-button + v-if="isImpersonating" + v-gl-tooltip + :href="sidebarData.stop_impersonation_path" + :title="$options.i18n.stopImpersonating" + :aria-label="$options.i18n.stopImpersonating" + icon="incognito" + variant="confirm" + category="tertiary" + data-method="delete" + data-testid="stop-impersonation-btn" + /> </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> <counter v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" - class="gl-flex-basis-third" + class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" :count="sidebarData.assigned_open_issues_count" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" + data-track-action="click_link" + data-track-label="issues_link" + data-track-property="nav_core_menu" /> <merge-request-menu class="gl-flex-basis-third gl-display-block!" :items="sidebarData.merge_request_menu" + @shown="mrMenuShown = true" + @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests" + v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" class="gl-w-full" icon="merge-request-open" :count="sidebarData.total_merge_requests_count" :label="$options.i18n.mergeRequests" + data-track-action="click_dropdown" + data-track-label="merge_requests_menu" + data-track-property="nav_core_menu" /> </merge-request-menu> <counter v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" - class="gl-flex-basis-third" + class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" - :count="sidebarData.todos_pending_count" + :count="formattedTodoCount" href="/dashboard/todos" :label="$options.i18n.todoList" data-qa-selector="todos_shortcut_button" + data-track-action="click_link" + data-track-label="todos_link" + data-track-property="nav_core_menu" /> </div> </div> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 34bbb3ce177..c90d1ad9c3e 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -11,13 +11,17 @@ import { s__, __, sprintf } from '~/locale'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; +import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants'; import UserNameGroup from './user_name_group.vue'; +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -211; + export default { - feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391533', + feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/403059', i18n: { newNavigation: { - badgeLabel: s__('NorthstarNavigation|Alpha'), + badgeLabel: s__('NorthstarNavigation|Beta'), sectionTitle: s__('NorthstarNavigation|Navigation redesign'), }, setStatus: s__('SetStatusModal|Set status'), @@ -72,6 +76,10 @@ export default { return { text: this.$options.i18n.startTrial, href: this.data.trial.url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'start_trial', + }, }; }, editProfileItem() { @@ -80,6 +88,8 @@ export default { href: this.data.settings.profile_path, extraAttrs: { 'data-qa-selector': 'edit_profile_link', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_edit_profile', }, }; }, @@ -87,6 +97,10 @@ export default { return { text: this.$options.i18n.preferences, href: this.data.settings.profile_preferences_path, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_preferences', + }, }; }, addBuyPipelineMinutesMenuItem() { @@ -99,6 +113,8 @@ export default { href: this.data.pipeline_minutes?.buy_pipeline_minutes_path, extraAttrs: { class: 'js-follow-link', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'buy_pipeline_minutes', }, }; }, @@ -106,6 +122,10 @@ export default { return { text: this.$options.i18n.gitlabNext, href: this.data.canary_toggle_com_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'switch_to_canary', + }, }; }, feedbackItem() { @@ -114,6 +134,8 @@ export default { href: this.$options.feedbackUrl, extraAttrs: { target: '_blank', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'provide_nav_beta_feedback', }, }; }, @@ -139,9 +161,12 @@ export default { 'data-default-emoji': 'speech_balloon', }; - if (!this.data.status.customized) { + const { busy, customized } = this.data.status; + + if (!busy && !customized) { return defaultData; } + return { ...defaultData, 'data-current-emoji': this.data.status.emoji, @@ -164,15 +189,20 @@ export default { }, methods: { onShow() { - this.trackEvents(); - this.initCallout(); + this.initBuyCIMinsCallout(); + }, + closeDropdown() { + this.$refs.userDropdown.close(); }, - initCallout() { + initBuyCIMinsCallout() { if (this.showNotificationDot) { PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el); } }, - trackEvents() { + /* We're not sure this event is tracked by anyone + whether it stays will depend on the outcome of this discussion: + https://gitlab.com/gitlab-org/gitlab/-/issues/402713#note_1343072135 */ + trackBuyCIMins() { if (this.addBuyPipelineMinutesMenuItem) { const { 'track-action': trackAction, @@ -182,6 +212,22 @@ export default { this.track(trackAction, { label, property }); } }, + trackSignOut() { + this.track(USER_MENU_TRACKING_DEFAULTS['data-track-action'], { + label: 'user_sign_out', + property: USER_MENU_TRACKING_DEFAULTS['data-track-property'], + }); + }, + }, + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], }, }; </script> @@ -189,7 +235,8 @@ export default { <template> <div> <gl-disclosure-dropdown - placement="right" + ref="userDropdown" + :popper-options="$options.popperOptions" data-testid="user-dropdown" data-qa-selector="user_menu" @shown="onShow" @@ -220,6 +267,7 @@ export default { v-if="data.status.can_update" :item="statusItem" data-testid="status-item" + @action="closeDropdown" /> <gl-disclosure-dropdown-item @@ -243,6 +291,7 @@ export default { :item="buyPipelineMinutesItem" v-bind="buyPipelineMinutesCalloutData" data-testid="buy-pipeline-minutes-item" + @action="trackBuyCIMins" > <template #list-item> <span class="gl-display-flex gl-flex-direction-column"> @@ -279,6 +328,7 @@ export default { bordered :group="signOutGroup" data-testid="sign-out-group" + @action="trackSignOut" /> </gl-disclosure-dropdown> diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue index 2489f462122..57958a03edd 100644 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -1,16 +1,22 @@ <script> -import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui'; +import { + GlBadge, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlTooltip, +} from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; - import { s__ } from '~/locale'; +import { USER_MENU_TRACKING_DEFAULTS } from '../constants'; export default { i18n: { user: { - busy: s__('UserProfile|(Busy)'), + busy: s__('UserProfile|Busy'), }, }, components: { + GlBadge, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip, @@ -31,7 +37,13 @@ export default { }; if (this.user.has_link_to_profile) { item.href = this.user.link_to_profile; + + item.extraAttrs = { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_profile', + }; } + return item; }, }, @@ -47,9 +59,9 @@ export default { <span class="gl-font-weight-bold"> {{ user.name }} </span> - <span v-if="user.status.busy" class="gl-text-gray-500">{{ - $options.i18n.user.busy - }}</span> + <gl-badge v-if="user.status.busy" size="sm" variant="warning"> + {{ $options.i18n.user.busy }} + </gl-badge> </span> <span class="gl-text-gray-400">@{{ user.username }}</span> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index acc03bc48c7..4f5b027c138 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -5,10 +5,44 @@ import Vue from 'vue'; export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount'; +export const JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse'; +export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand'; export const portalState = Vue.observable({ ready: false, }); +export const sidebarState = Vue.observable({ + contextSwitcherOpen: false, + isCollapsed: false, + isPeek: false, + openPeekTimer: null, + closePeekTimer: null, +}); + export const MAX_FREQUENT_PROJECTS_COUNT = 5; export const MAX_FREQUENT_GROUPS_COUNT = 3; + +export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200; +export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500; + +export const TRACKING_UNKNOWN_ID = 'item_without_id'; +export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown'; +export const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; + +export const PANELS_WITH_PINS = ['group', 'project']; + +export const USER_MENU_TRACKING_DEFAULTS = { + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', +}; + +export const HELP_MENU_TRACKING_DEFAULTS = { + 'data-track-property': 'nav_help_menu', + 'data-track-action': 'click_link', +}; + +export const SIDEBAR_PINS_EXPANDED_COOKIE = 'sidebar_pinned_section_expanded'; +export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10; + +export const DROPDOWN_Y_OFFSET = 4; diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js deleted file mode 100644 index 5e5ad97eb68..00000000000 --- a/app/assets/javascripts/super_sidebar/mock_data.js +++ /dev/null @@ -1,54 +0,0 @@ -import { s__ } from '~/locale'; - -export const contextSwitcherItems = { - yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' }, - explore: { title: s__('Navigation|Explore'), link: '/explore', icon: 'compass' }, - recentProjects: [ - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Orange', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64', - }, - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Lemon', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64', - }, - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Coconut', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64', - }, - ], - recentGroups: [ - { - title: 'Developer Evangelism at GitLab', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64', - }, - { - title: 'security-products', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64', - }, - { - title: 'Tanuki-Workshops', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64', - }, - ], -}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 4395cc2f5f0..fdd29a1719c 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,12 +1,16 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; +import { JS_TOGGLE_EXPAND_CLASS } from './constants'; +import createStore from './components/global_search/store'; import { bindSuperSidebarCollapsedEvents, initSuperSidebarCollapsedState, } from './super_sidebar_collapsed_state_manager'; import SuperSidebar from './components/super_sidebar.vue'; +import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; Vue.use(VueApollo); @@ -23,6 +27,11 @@ export const initSuperSidebar = () => { initSuperSidebarCollapsedState(); const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset; + const sidebarData = JSON.parse(sidebar); + const searchData = convertObjectPropsToCamelCase(sidebarData.search); + + const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; + const isImpersonating = parseBoolean(sidebarData.is_impersonating); return new Vue({ el, @@ -31,15 +40,48 @@ export const initSuperSidebar = () => { provide: { rootPath, toggleNewNavEndpoint, + isImpersonating, }, + store: createStore({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search: '', + }), render(h) { return h(SuperSidebar, { props: { - sidebarData: JSON.parse(sidebar), + sidebarData, }, }); }, }); }; +/** + * Guard against multiple instantiations, since the js-* class is persisted + * in the Vue component. + */ +let toggleInstantiated = false; + +export const initSuperSidebarToggle = () => { + const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`); + + if (!el || toggleInstantiated) return false; + + toggleInstantiated = true; + + return new Vue({ + el, + name: 'SuperSidebarToggleRoot', + render(h) { + // Copy classes from HAML-defined button to ensure same positioning, + // including JS_TOGGLE_EXPAND_CLASS. + return h(SuperSidebarToggle, { class: el.className }); + }, + }); +}; + requestIdleCallback(initStatusTriggers); diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js index 549c6c17e44..17e07146678 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js @@ -1,14 +1,15 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { debounce } from 'lodash'; import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { sidebarState } from './constants'; export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed'; export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed'; export const SIDEBAR_COLLAPSED_COOKIE_EXPIRATION = 365 * 10; +export const SIDEBAR_TRANSITION_DURATION = 200; export const findPage = () => document.querySelector('.page-with-super-sidebar'); export const findSidebar = () => document.querySelector('.super-sidebar'); -export const findToggles = () => document.querySelectorAll('.js-super-sidebar-toggle'); export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS); @@ -19,15 +20,15 @@ export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true'; -export const toggleSuperSidebarCollapsed = (collapsed, saveCookie, isUserAction) => { - const sidebar = findSidebar(); - sidebar.ariaHidden = collapsed; - sidebar.inert = collapsed; - - if (!collapsed && isUserAction) sidebar.focus(); +export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => { + clearTimeout(sidebarState.openPeekTimer); + clearTimeout(sidebarState.closePeekTimer); findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed); + sidebarState.isPeek = false; + sidebarState.isCollapsed = collapsed; + if (saveCookie && isDesktopBreakpoint()) { setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, { expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION, @@ -41,11 +42,5 @@ export const initSuperSidebarCollapsedState = () => { }; export const bindSuperSidebarCollapsedEvents = () => { - findToggles().forEach((elem) => { - elem.addEventListener('click', () => { - toggleSuperSidebarCollapsed(!isCollapsed(), true, true); - }); - }); - window.addEventListener('resize', debounce(initSuperSidebarCollapsedState, 100)); }; diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index 065e1080897..d79252f6bb7 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - // Syntax Highlighter // // Applies a syntax highlighting color scheme CSS class to any element with the @@ -14,6 +12,7 @@ export default function syntaxHighlight($els = null) { if (!$els || $els.length === 0) return; const els = $els.get ? $els.get() : $els; + // eslint-disable-next-line consistent-return const handler = (el) => { if (el.classList === undefined) { return el; diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql new file mode 100644 index 00000000000..3ba0ab29530 --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql @@ -0,0 +1,69 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query timeTrackingReport( + $startDate: Time + $endDate: Time + $projectId: ProjectID + $groupId: GroupID + $username: String + $first: Int + $last: Int + $before: String + $after: String +) { + timelogs( + startDate: $startDate + endDate: $endDate + projectId: $projectId + groupId: $groupId + username: $username + first: $first + last: $last + after: $after + before: $before + sort: SPENT_AT_DESC + ) { + count + totalSpentTime + nodes { + id + project { + id + webUrl + fullPath + nameWithNamespace + } + timeSpent + user { + id + name + username + avatarUrl + webPath + } + spentAt + note { + id + body + } + summary + issue { + id + title + webUrl + state + reference + } + mergeRequest { + id + title + webUrl + state + reference + } + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue new file mode 100644 index 00000000000..33b0ac4b58e --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue @@ -0,0 +1,50 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { IssuableStatusText } from '~/issues/constants'; + +export default { + components: { + GlLink, + }, + props: { + timelog: { + type: Object, + required: true, + }, + }, + computed: { + subject() { + const { issue, mergeRequest } = this.timelog; + return issue || mergeRequest; + }, + issuableStatus() { + return IssuableStatusText[this.subject.state]; + }, + issuableFullReference() { + return this.timelog.project.fullPath + this.subject.reference; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-gap-2 gl-text-left!"> + <gl-link + :href="subject.webUrl" + class="gl-text-gray-900 gl-hover-text-gray-900 gl-font-weight-bold" + data-testid="title-container" + > + {{ subject.title }} + </gl-link> + <span> + <gl-link + :href="subject.webUrl" + class="gl-text-gray-900 gl-hover-text-gray-900" + data-testid="reference-container" + > + {{ issuableFullReference }} + </gl-link> + • <span data-testid="state-container">{{ issuableStatus }}</span> + </span> + </div> +</template> diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue new file mode 100644 index 00000000000..2069e4a6722 --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue @@ -0,0 +1,229 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { + GlButton, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlKeysetPagination, + GlDatepicker, +} from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { formatTimeSpent } from '~/lib/utils/datetime_utility'; +import { s__ } from '~/locale'; +import getTimelogsQuery from './queries/get_timelogs.query.graphql'; +import TimelogsTable from './timelogs_table.vue'; + +const ENTRIES_PER_PAGE = 20; + +// Define initial dates to current date and time +const INITIAL_TO_DATE = new Date(); +const INITIAL_FROM_DATE = new Date(); + +// Set the initial 'from' date to 30 days before the current date +INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30); + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlKeysetPagination, + GlDatepicker, + TimelogsTable, + }, + props: { + limitToHours: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projectId: null, + groupId: null, + username: null, + timeSpentFrom: INITIAL_FROM_DATE, + timeSpentTo: INITIAL_TO_DATE, + cursor: { + first: ENTRIES_PER_PAGE, + after: null, + last: null, + before: null, + }, + queryVariables: { + startDate: INITIAL_FROM_DATE, + endDate: INITIAL_TO_DATE, + projectId: null, + groupId: null, + username: null, + }, + pageInfo: {}, + report: [], + totalSpentTime: 0, + }; + }, + apollo: { + report: { + query: getTimelogsQuery, + variables() { + return { + ...this.queryVariables, + ...this.cursor, + }; + }, + update({ timelogs: { nodes = [], pageInfo = {}, totalSpentTime = 0 } = {} }) { + this.pageInfo = pageInfo; + this.totalSpentTime = totalSpentTime; + return nodes; + }, + error(error) { + createAlert({ message: s__('TimeTrackingReport|Something went wrong. Please try again.') }); + Sentry.captureException(error); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.report.loading; + }, + showPagination() { + return this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage; + }, + formattedTotalSpentTime() { + return formatTimeSpent(this.totalSpentTime, this.limitToHours); + }, + }, + methods: { + nullIfBlank(value) { + return value === '' ? null : value; + }, + runReport() { + this.cursor = { + first: ENTRIES_PER_PAGE, + after: null, + last: null, + before: null, + }; + + this.queryVariables = { + startDate: this.nullIfBlank(this.timeSpentFrom), + endDate: this.nullIfBlank(this.timeSpentTo), + projectId: this.nullIfBlank(this.projectId), + groupId: this.nullIfBlank(this.groupId), + username: this.nullIfBlank(this.username), + }; + }, + nextPage(item) { + this.cursor = { + first: ENTRIES_PER_PAGE, + after: item, + last: null, + before: null, + }; + }, + prevPage(item) { + this.cursor = { + first: null, + after: null, + last: ENTRIES_PER_PAGE, + before: item, + }; + }, + clearTimeSpentFromDate() { + this.timeSpentFrom = null; + }, + clearTimeSpentToDate() { + this.timeSpentTo = null; + }, + }, + i18n: { + username: s__('TimeTrackingReport|Username'), + from: s__('TimeTrackingReport|From'), + to: s__('TimeTrackingReport|To'), + runReport: s__('TimeTrackingReport|Run report'), + totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-gap-5 gl-mt-5"> + <form + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3" + @submit.prevent="runReport" + > + <gl-form-group + :label="$options.i18n.username" + label-for="timelog-form-username" + class="gl-mb-0 gl-md-form-input-md gl-w-full" + > + <gl-form-input + id="timelog-form-username" + v-model="username" + data-testid="form-username" + class="gl-w-full" + /> + </gl-form-group> + <gl-form-group + key="time-spent-from" + :label="$options.i18n.from" + class="gl-mb-0 gl-md-form-input-md gl-w-full" + > + <gl-datepicker + v-model="timeSpentFrom" + :target="null" + show-clear-button + autocomplete="off" + data-testid="form-from-date" + class="gl-max-w-full!" + @clear="clearTimeSpentFromDate" + /> + </gl-form-group> + <gl-form-group + key="time-spent-to" + :label="$options.i18n.to" + class="gl-mb-0 gl-md-form-input-md gl-w-full" + > + <gl-datepicker + v-model="timeSpentTo" + :target="null" + show-clear-button + autocomplete="off" + data-testid="form-to-date" + class="gl-max-w-full!" + @clear="clearTimeSpentToDate" + /> + </gl-form-group> + <gl-button + class="gl-align-self-end gl-w-full gl-md-w-auto" + variant="confirm" + @click="runReport" + >{{ $options.i18n.runReport }}</gl-button + > + </form> + <div + v-if="!isLoading" + data-testid="table-container" + class="gl-display-flex gl-flex-direction-column" + > + <div v-if="report.length" class="gl-display-flex gl-gap-2 gl-border-t gl-py-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.totalTimeSpentText }}</span> + <span data-testid="total-time-spent-container">{{ formattedTotalSpentTime }}</span> + </div> + + <timelogs-table :limit-to-hours="limitToHours" :entries="report" /> + + <gl-keyset-pagination + v-if="showPagination" + v-bind="pageInfo" + class="gl-mt-3 gl-align-self-center" + @prev="prevPage" + @next="nextPage" + /> + </div> + <gl-loading-icon v-else size="lg" class="gl-mt-5" /> + </div> +</template> diff --git a/app/assets/javascripts/time_tracking/components/timelogs_table.vue b/app/assets/javascripts/time_tracking/components/timelogs_table.vue new file mode 100644 index 00000000000..b2efb44f56f --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/timelogs_table.vue @@ -0,0 +1,105 @@ +<script> +import { GlTable } from '@gitlab/ui'; +import { formatDate, formatTimeSpent } from '~/lib/utils/datetime_utility'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { s__ } from '~/locale'; +import TimelogSourceCell from './timelog_source_cell.vue'; + +const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; + +export default { + components: { + GlTable, + UserAvatarLink, + TimelogSourceCell, + }, + props: { + entries: { + type: Array, + required: true, + }, + limitToHours: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + fields: [ + { + key: 'spentAt', + label: s__('TimeTrackingReport|Spent at'), + tdClass: 'gl-md-w-30', + }, + { + key: 'source', + label: s__('TimeTrackingReport|Source'), + }, + { + key: 'user', + label: s__('TimeTrackingReport|User'), + tdClass: 'gl-md-w-20', + }, + { + key: 'timeSpent', + label: s__('TimeTrackingReport|Time spent'), + tdClass: 'gl-md-w-15', + }, + { + key: 'summary', + label: s__('TimeTrackingReport|Summary'), + }, + ], + }; + }, + methods: { + formatDate(date) { + return formatDate(date, TIME_DATE_FORMAT); + }, + formatTimeSpent(seconds) { + return formatTimeSpent(seconds, this.limitToHours); + }, + extractTimelogSummary(timelog) { + const { note, summary } = timelog; + return note?.body || summary; + }, + }, +}; +</script> + +<template> + <gl-table :items="entries" :fields="fields" stacked="md" show-empty> + <template #cell(spentAt)="{ item: { spentAt } }"> + <div data-testid="date-container" class="gl-text-left!">{{ formatDate(spentAt) }}</div> + </template> + + <template #cell(source)="{ item }"> + <timelog-source-cell :timelog="item" /> + </template> + + <template #cell(user)="{ item: { user } }"> + <user-avatar-link + class="gl-display-flex gl-text-gray-900 gl-hover-text-gray-900" + :link-href="user.webPath" + :img-src="user.avatarUrl" + :img-size="16" + :img-alt="user.name" + :tooltip-text="user.name" + :username="user.name" + /> + </template> + + <template #cell(timeSpent)="{ item: { timeSpent } }"> + <div data-testid="time-spent-container" class="gl-text-left!"> + {{ formatTimeSpent(timeSpent) }} + </div> + </template> + + <template #cell(summary)="{ item }"> + <div data-testid="summary-container" class="gl-text-left!"> + {{ extractTimelogSummary(item) }} + </div> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/time_tracking/index.js b/app/assets/javascripts/time_tracking/index.js new file mode 100644 index 00000000000..9cff01799d9 --- /dev/null +++ b/app/assets/javascripts/time_tracking/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import TimelogsApp from './components/timelogs_app.vue'; + +Vue.use(VueApollo); + +export default () => { + const el = document.getElementById('js-timelogs-app'); + if (!el) { + return false; + } + + const { limitToHours } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(TimelogsApp, { + props: { + limitToHours: parseBoolean(limitToHours), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 2593fbe6ed1..7f4c7a91b20 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -5,14 +5,11 @@ export const DEFAULT_SNOWPLOW_OPTIONS = { hostname: window.location.hostname, cookieDomain: window.location.hostname, appId: '', - userFingerprint: false, respectDoNotTrack: true, - forceSecureTracker: true, eventMethod: 'post', contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, - pageUnloadTimer: 10, formTrackingConfig: { forms: { allow: [] }, fields: { allow: [] }, diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js index 5daeaf1d85b..89d90cf89be 100644 --- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -26,7 +26,14 @@ export function dispatchSnowplowEvent( } try { - window.snowplow('trackStructEvent', category, action, label, property, value, contexts); + window.snowplow('trackStructEvent', { + category, + action, + label, + property, + value, + context: contexts, + }); return true; } catch (error) { Sentry.captureException(error); diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index d60eb37a9a2..472ce3c5bbf 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -7,7 +7,7 @@ export { Tracking as default }; /** * Tracker initialization as defined in: - * https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/. + * https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v3/tracker-setup/initialization-options/. * It also dispatches any event emitted before its execution. * * @returns {undefined} @@ -42,13 +42,19 @@ export function initDefaultTrackers() { // must be before initializing the trackers Tracking.setAnonymousUrls(); - window.snowplow('enableActivityTracking', 30, 30); + window.snowplow('enableActivityTracking', { + minimumVisitLength: 30, + heartbeatDelay: 30, + }); // must be after enableActivityTracking const standardContext = getStandardContext(); const experimentContexts = getAllExperimentContexts(); // To not expose personal identifying information, the page title is hardcoded as `GitLab` // See: https://gitlab.com/gitlab-org/gitlab/-/issues/345243 - window.snowplow('trackPageView', 'GitLab', [standardContext, ...experimentContexts]); + window.snowplow('trackPageView', { + title: 'GitLab', + context: [standardContext, ...experimentContexts], + }); window.snowplow('setDocumentTitle', 'GitLab'); if (window.snowplowOptions.formTracking) { diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js index 85f4979e752..b69b1714952 100644 --- a/app/assets/javascripts/tracking/tracker.js +++ b/app/assets/javascripts/tracking/tracker.js @@ -207,14 +207,18 @@ export const Tracker = { const mappedConfig = {}; if (config.forms) { - mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); + mappedConfig.forms = renameKey(config.forms, 'allow', 'allowlist'); } if (config.fields) { - mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); + mappedConfig.fields = renameKey(config.fields, 'allow', 'allowlist'); } - const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); + const enabler = () => + window.snowplow('enableFormTracking', { + options: mappedConfig, + context: userProvidedContexts, + }); if (document.readyState === 'complete') { enabler(); diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue index 2b97886e650..beff3b4c0c3 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue @@ -9,9 +9,6 @@ import { PROJECT_TABLE_LABEL_USAGE, containerRegistryId, containerRegistryPopoverId, - uploadsId, - uploadsPopoverId, - uploadsPopoverContent, } from '../constants'; import { descendingStorageUsageSort } from '../utils'; import StorageTypeIcon from './storage_type_icon.vue'; @@ -40,10 +37,6 @@ export default { popoverId: containerRegistryPopoverId, popoverContent: this.containerRegistryPopoverContent, }, - [uploadsId]: { - popoverId: uploadsPopoverId, - popoverContent: this.$options.i18n.uploadsPopoverContent, - }, }; return this.storageTypes @@ -77,9 +70,6 @@ export default { thClass: thWidthPercent(10), }, ], - i18n: { - uploadsPopoverContent, - }, }; </script> <template> @@ -100,7 +90,7 @@ export default { :aria-label="helpLinkAriaLabel(item.storageType.name)" :data-testid="`${item.storageType.id}-help-link`" > - <gl-icon name="question" :size="12" /> + <gl-icon name="question-o" :size="12" /> </gl-link> </p> <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue index bc7cd42df1e..5142c2c0915 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue @@ -16,7 +16,6 @@ export default { const storageTypeIconMap = { lfsObjectsSize: 'doc-image', snippetsSize: 'snippet', - uploadsSize: 'upload', repositorySize: 'infrastructure-registry', packagesSize: 'package', }; diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue index 7e001685060..e9683924ff8 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -1,17 +1,10 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PROJECT_STORAGE_TYPES } from '../constants'; import { descendingStorageUsageSort } from '../utils'; export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, mixins: [glFeatureFlagMixin()], props: { rootStorageStatistics: { @@ -35,9 +28,7 @@ export default { storageSize, wikiSize, snippetsSize, - uploadsSize, } = this.rootStorageStatistics; - const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; if (storageSize === 0) { return null; @@ -70,9 +61,15 @@ export default { }, { id: 'buildArtifactsSize', - style: this.usageStyle(this.barRatio(artifactsSize)), - class: 'gl-bg-data-viz-green-600', - size: artifactsSize, + style: this.usageStyle(this.barRatio(buildArtifactsSize)), + class: 'gl-bg-data-viz-green-500', + size: buildArtifactsSize, + }, + { + id: 'pipelineArtifactsSize', + style: this.usageStyle(this.barRatio(pipelineArtifactsSize)), + class: 'gl-bg-data-viz-green-800', + size: pipelineArtifactsSize, }, { id: 'wikiSize', @@ -86,12 +83,6 @@ export default { class: 'gl-bg-data-viz-orange-800', size: snippetsSize, }, - { - id: 'uploadsSize', - style: this.usageStyle(this.barRatio(uploadsSize)), - class: 'gl-bg-data-viz-aqua-700', - size: uploadsSize, - }, ] .filter((data) => data.size !== 0) .sort(descendingStorageUsageSort('size')) @@ -99,11 +90,10 @@ export default { const storageTypeExtraData = PROJECT_STORAGE_TYPES.find( (type) => storageType.id === type.id, ); - const { name, tooltip } = storageTypeExtraData || {}; + const name = storageTypeExtraData?.name; return { name, - tooltip, ...storageType, }; }); @@ -155,15 +145,6 @@ export default { <span class="gl-text-gray-500 gl-font-sm"> {{ formatSize(storageType.size) }} </span> - <span - v-if="storageType.tooltip" - v-gl-tooltip - :title="storageType.tooltip" - :aria-label="storageType.tooltip" - class="gl-ml-2" - > - <gl-icon name="question" :size="12" /> - </span> </div> </div> </div> diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js index bd8cd372ecf..8e3eaff4496 100644 --- a/app/assets/javascripts/usage_quotas/storage/constants.js +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -8,7 +8,7 @@ export const LEARN_MORE_LABEL = __('Learn more.'); export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); export const TOTAL_USAGE_SUBTITLE = s__( - 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', + 'UsageQuota|Includes artifacts, repositories, wiki, and other items.', ); export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.'); export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); @@ -20,11 +20,6 @@ export const projectContainerRegistryPopoverContent = s__( export const containerRegistryId = 'containerRegistrySize'; export const containerRegistryPopoverId = 'container-registry-popover'; -export const uploadsId = 'uploadsSize'; -export const uploadsPopoverId = 'uploads-popover'; -export const uploadsPopoverContent = s__( - 'NamespaceStorage|Uploads are not counted in namespace storage quotas.', -); export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type'); export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); @@ -32,45 +27,44 @@ export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); export const PROJECT_STORAGE_TYPES = [ { id: 'containerRegistrySize', - name: s__('UsageQuota|Container Registry'), + name: __('Container Registry'), description: s__( 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.', ), }, { id: 'buildArtifactsSize', - name: s__('UsageQuota|Artifacts'), - description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), - tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), + name: __('Job artifacts'), + description: s__('UsageQuota|Job artifacts created by CI/CD.'), + }, + { + id: 'pipelineArtifactsSize', + name: __('Pipeline artifacts'), + description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'), }, { id: 'lfsObjectsSize', - name: s__('UsageQuota|LFS storage'), + name: __('LFS'), description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), }, { id: 'packagesSize', - name: s__('UsageQuota|Packages'), + name: __('Packages'), description: s__('UsageQuota|Code packages and container images.'), }, { id: 'repositorySize', - name: s__('UsageQuota|Repository'), + name: __('Repository'), description: s__('UsageQuota|Git repository.'), }, { id: 'snippetsSize', - name: s__('UsageQuota|Snippets'), + name: __('Snippets'), description: s__('UsageQuota|Shared bits of code and text.'), }, { - id: 'uploadsSize', - name: s__('UsageQuota|Uploads'), - description: s__('UsageQuota|File attachments and smaller design graphics.'), - }, - { id: 'wikiSize', - name: s__('UsageQuota|Wiki'), + name: __('Wiki'), description: s__('UsageQuota|Wiki content.'), }, ]; @@ -86,6 +80,9 @@ export const projectHelpPaths = { buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', { anchor: 'when-job-artifacts-are-deleted', }), + pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', { + anchor: 'when-pipeline-artifacts-are-deleted', + }), packages: helpPagePath('user/packages/package_registry/index.md', { anchor: 'reduce-storage-usage', }), diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql index 6637e5e0865..d254f576219 100644 --- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql +++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql @@ -10,7 +10,6 @@ query getProjectStorageStatistics($fullPath: ID!) { repositorySize snippetsSize storageSize - uploadsSize wikiSize } } diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index a401a9bbf2f..66e54b59187 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -467,6 +467,8 @@ function UsersSelect(currentUser, els, options = {}) { // display:block overrides the hide-collapse rule $value.css('display', ''); } + + $('.dropdown-input-field', $block).val(''); }, multiSelect: $dropdown.hasClass('js-multiselect'), inputMeta: $dropdown.data('inputMeta'), @@ -694,17 +696,18 @@ UsersSelect.prototype.renderRow = function ( : ''; const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : ''; - const name = + const busyBadge = user?.availability && isUserBusy(user.availability) - ? sprintf(__('%{name} (Busy)'), { name: user.name }) - : user.name; + ? `<span class="badge badge-warning badge-pill gl-badge sm">${__('Busy')}</span>` + : ''; return ` <li data-user-id=${user.id} ${dataUserSuggested}> <a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}> ${this.renderRowAvatar(issuableType, user, img)} <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)} + ${escape(user.name)} + ${busyBadge} </strong> ${ username diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js index 77736fb6ef5..e30982985b3 100644 --- a/app/assets/javascripts/visibility_level/constants.js +++ b/app/assets/javascripts/visibility_level/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private'; export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal'; export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public'; @@ -18,3 +20,33 @@ export const VISIBILITY_LEVELS_INTEGER_TO_STRING = { [VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING, [VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING, }; + +export const GROUP_VISIBILITY_TYPE = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: __( + 'Public - The group and any public projects can be viewed without any authentication.', + ), + [VISIBILITY_LEVEL_INTERNAL_STRING]: __( + 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', + ), + [VISIBILITY_LEVEL_PRIVATE_STRING]: __( + 'Private - The group and its projects can only be viewed by members.', + ), +}; + +export const PROJECT_VISIBILITY_TYPE = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: __( + 'Public - The project can be accessed without any authentication.', + ), + [VISIBILITY_LEVEL_INTERNAL_STRING]: __( + 'Internal - The project can be accessed by any logged in user except external users.', + ), + [VISIBILITY_LEVEL_PRIVATE_STRING]: __( + '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 = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth', + [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', + [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue index f377a185879..5090081d281 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -1,6 +1,7 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { escape } from 'lodash'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { n__, s__, sprintf } from '~/locale'; @@ -49,7 +50,7 @@ export default { }, computed: { isMerged() { - return this.state === 'merged'; + return this.state === STATUS_MERGED; }, targetBranchEscaped() { return escape(this.targetBranch); @@ -67,7 +68,7 @@ export default { ); }, message() { - if (this.state === 'closed') { + if (this.state === STATUS_CLOSED) { return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.'); } else if (this.isMerged) { return s__( diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 74922dd922c..25cf5335fb5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -1,15 +1,15 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; import { createAlert } from '~/alert'; +import { STATUS_MERGED } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; -import MrWidgetContainer from '../mr_widget_container.vue'; -import MrWidgetIcon from '../mr_widget_icon.vue'; +import StateContainer from '../state_container.vue'; import { INVALID_RULES_DOCS_PATH } from '../../constants'; import ApprovalsSummary from './approvals_summary.vue'; import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; @@ -18,14 +18,17 @@ import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; export default { name: 'MRWidgetApprovals', components: { - MrWidgetContainer, - MrWidgetIcon, ApprovalsSummary, ApprovalsSummaryOptional, + StateContainer, GlButton, GlSprintf, }, mixins: [approvalsMixin, glFeatureFlagsMixin()], + provide: { + expandDetailsTooltip: __('Expand eligible approvers'), + collapseDetailsTooltip: __('Collapse eligible approvers'), + }, props: { mr: { type: Object, @@ -55,6 +58,11 @@ export default { required: false, default: false, }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -76,13 +84,22 @@ export default { return Boolean(this.action); }, invalidRules() { - return this.approvals.approvalState?.invalidApproversRules || []; + return this.approvals.approvalState?.rules?.filter((rule) => rule.invalid) || []; + }, + invalidApprovedRules() { + return this.invalidRules.filter((rule) => rule.allowMergeWhenInvalid); + }, + invalidFailedRules() { + return this.invalidRules.filter((rule) => !rule.allowMergeWhenInvalid); }, hasInvalidRules() { return this.mr.mergeRequestApproversAvailable && this.invalidRules.length; }, - invalidRulesText() { - return this.invalidRules.length; + hasInvalidApprovedRules() { + return this.mr.mergeRequestApproversAvailable && this.invalidApprovedRules.length; + }, + hasInvalidFailedRules() { + return this.mr.mergeRequestApproversAvailable && this.invalidFailedRules.length; }, approvedBy() { return this.approvals.approvedBy?.nodes || []; @@ -99,7 +116,7 @@ export default { return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; }, showUnapprove() { - return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; + return this.userHasApproved && !this.userCanApprove && this.mr.state !== STATUS_MERGED; }, approvalText() { return this.isApproved && this.approvedBy.length > 0 @@ -125,11 +142,29 @@ export default { return null; }, - pluralizedRuleText() { - return this.invalidRules.length > 1 + pluralizedApprovedRuleText() { + return this.invalidApprovedRules.length > 1 ? this.$options.i18n.invalidRulesPlural : this.$options.i18n.invalidRuleSingular; }, + pluralizedFailedRuleText() { + return this.invalidFailedRules.length > 1 + ? this.$options.i18n.invalidFailedRulesPlural + : this.$options.i18n.invalidFailedRuleSingular; + }, + pluralizedRuleText() { + return [ + this.hasInvalidFailedRules + ? sprintf(this.pluralizedFailedRuleText, { rules: this.invalidFailedRules.length }) + : null, + this.hasInvalidApprovedRules + ? sprintf(this.pluralizedApprovedRuleText, { rules: this.invalidApprovedRules.length }) + : null, + ] + .filter((text) => Boolean(text)) + .join(', ') + .concat('.'); + }, }, methods: { approve() { @@ -197,21 +232,30 @@ export default { FETCH_LOADING, linkToInvalidRules: INVALID_RULES_DOCS_PATH, i18n: { - invalidRuleSingular: s__( - 'mrWidget|%{rules} invalid rule has been approved automatically, as no one can approve it.', + invalidRuleSingular: s__('mrWidget|%{rules} invalid rule has been approved automatically'), + invalidRulesPlural: s__('mrWidget|%{rules} invalid rules have been approved automatically'), + invalidFailedRuleSingular: s__( + "mrWidget|%{dangerStart}%{rules} rule can't be approved%{dangerEnd}", ), - invalidRulesPlural: s__( - 'mrWidget|%{rules} invalid rules have been approved automatically, as no one can approve them.', + invalidFailedRulesPlural: s__( + "mrWidget|%{dangerStart}%{rules} rules can't be approved%{dangerEnd}", ), learnMore: __('Learn more.'), }, }; </script> <template> - <mr-widget-container> - <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> - <mr-widget-icon name="approval" /> - <div v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</div> + <div class="js-mr-approvals mr-section-container mr-widget-workflow"> + <state-container + :is-loading="$apollo.queries.approvals.loading" + :mr="mr" + status="approval" + is-collapsible + collapse-on-desktop + :collapsed="collapsed" + @toggle="() => $emit('toggle')" + > + <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template> <template v-else> <div class="gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> @@ -220,7 +264,7 @@ export default { :variant="action.variant" :category="action.category" :loading="isApproving" - class="gl-mr-5" + class="gl-mr-3" data-qa-selector="approve_button" @click="action.action" > @@ -234,12 +278,15 @@ export default { <approvals-summary v-else :approval-state="approvals" + :disable-committers-approval="disableCommittersApproval" :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable" /> </div> <div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules"> <gl-sprintf :message="pluralizedRuleText"> - <template #rules>{{ invalidRulesText }}</template> + <template #danger="{ content }"> + <span class="gl-font-weight-bold text-danger">{{ content }}</span> + </template> </gl-sprintf> </div> </div> @@ -249,9 +296,7 @@ export default { :has-approval-auth-error="hasApprovalAuthError" ></slot> </template> - </div> - <template #footer> - <slot name="footer"></slot> - </template> - </mr-widget-container> + </state-container> + <slot name="footer"></slot> + </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 2af033bb80f..650fa798db6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -1,4 +1,5 @@ <script> +import { GlLink, GlPopover } from '@gitlab/ui'; import { toNounSeriesText } from '~/lib/utils/grammar'; import { n__, sprintf } from '~/locale'; import { @@ -12,6 +13,8 @@ import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/ma export default { components: { + GlLink, + GlPopover, UserAvatarList, }, props: { @@ -24,6 +27,11 @@ export default { type: Object, required: true, }, + disableCommittersApproval: { + type: Boolean, + required: false, + default: false, + }, }, computed: { approvers() { @@ -101,6 +109,13 @@ export default { (approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId, ); }, + currentUserHasCommitted() { + if (!this.currentUserId) return false; + + return this.approvalState.committers?.nodes?.some( + (user) => getIdFromGraphQLId(user.id) === this.currentUserId, + ); + }, currentUserId() { return gon.current_user_id; }, @@ -115,10 +130,18 @@ export default { <span v-if="approvalLeftMessage">{{ message }}</span> <span v-else class="gl-font-weight-bold">{{ message }}</span> <user-avatar-list - class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" + class="gl-display-inline-flex gl-vertical-align-middle" :img-size="24" :items="approvers" /> </template> + <template v-if="disableCommittersApproval && currentUserHasCommitted"> + <gl-link id="cant-approve-popover" data-testid="commit-cant-approve" class="gl-cursor-help">{{ + __("Why can't I approve?") + }}</gl-link> + <gl-popover target="cant-approve-popover"> + {{ __("You can't approve because you added one or more commits to this merge request.") }} + </gl-popover> + </template> </div> </template> 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 f9d0986d60d..1e5f91e12cf 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 @@ -60,7 +60,7 @@ export default { > <div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto"> <div class="gl-display-flex gl-m-auto gl-translate-y-n50"> - <gl-loading-icon v-if="isLoading" size="md" inline /> + <gl-loading-icon v-if="isLoading" size="sm" inline /> <gl-icon v-else :name="$options.EXTENSION_ICON_NAMES[iconName]" 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 e3f87c08ad4..4f8f8d6cb58 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 @@ -6,86 +6,6 @@ import { TELEMETRY_WIDGET_FULL_REPORT_CLICKED, } from '../../constants'; -/* - * Additional events to send beyond the defaults for certain widget extensions - */ -const nonStandardEvents = { - codeQuality: { - uniqueUser: { - expand: ['i_testing_code_quality_widget_total'], - }, - counter: {}, - }, - terraform: { - uniqueUser: { - expand: ['i_testing_terraform_widget_total'], - }, - counter: {}, - }, - issues: { - uniqueUser: { - expand: ['i_testing_issues_widget_total'], - }, - counter: {}, - }, - testSummary: { - uniqueUser: { - expand: ['i_testing_summary_widget_total'], - }, - counter: {}, - }, - metrics: { - uniqueUser: { - expand: ['i_testing_metrics_report_widget_total'], - }, - counter: {}, - }, - browserPerformance: { - uniqueUser: { - expand: ['i_testing_web_performance_widget_total'], - }, - counter: {}, - }, - licenseCompliance: { - uniqueUser: { - expand: ['i_testing_license_compliance_widget_total'], - }, - counter: {}, - }, - loadPerformance: { - uniqueUser: { - expand: ['i_testing_load_performance_widget_total'], - }, - counter: {}, - }, - statusChecks: { - uniqueUser: { - expand: ['i_testing_status_checks_widget'], - }, - counter: {}, - }, -}; - -function combineDeepArray(path, ...objects) { - const parts = path.split('.'); - const allEntries = objects.reduce((entries, currentObject) => { - let expandedEntries = entries; - let traversed = currentObject; - - parts.forEach((part) => { - traversed = traversed?.[part]; - }); - - if (traversed) { - expandedEntries = [...entries, ...traversed]; - } - - return expandedEntries; - }, []); - - return Array.from(new Set(allEntries)); -} - function simplifyWidgetName(componentName) { const noWidget = componentName.replace(/^Widget/, ''); @@ -166,7 +86,6 @@ function defaultBehaviorEvents({ bus, config }) { function baseTelemetry(componentName) { const simpleExtensionName = simplifyWidgetName(componentName); - const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {}; /* * Telemetry config format is: * { @@ -179,7 +98,7 @@ function baseTelemetry(componentName) { * - uniqueUser is sent to RedisHLL * - counter is sent to a regular Redis counter */ - const defaultTelemetry = { + return { uniqueUser: { view: [`${baseRedisEventName(simpleExtensionName)}_view`], expand: [`${baseRedisEventName(simpleExtensionName)}_expand`], @@ -191,27 +110,6 @@ function baseTelemetry(componentName) { clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`], }, }; - - return { - uniqueUser: { - view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard), - expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard), - clickFullReport: combineDeepArray( - 'uniqueUser.clickFullReport', - defaultTelemetry, - additionalNonStandard, - ), - }, - counter: { - view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard), - expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard), - clickFullReport: combineDeepArray( - 'counter.clickFullReport', - defaultTelemetry, - additionalNonStandard, - ), - }, - }; } export function createTelemetryHub(componentName) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index 20284c4a3d8..26527361b2e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/require-i18n-strings */ import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { escapeShellString } from '~/lib/utils/text_utility'; @@ -87,11 +86,11 @@ export default { const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`); return this.isFork - ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` - : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; + ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` // eslint-disable-line @gitlab/require-i18n-strings + : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings }, mergeInfo2() { - return `git push origin ${this.escapedSourceBranch}`; + return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings }, escapedForkBranch() { return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 2dec95c3fda..4e16b92fc05 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -173,7 +173,7 @@ export default { </p> </template> <template v-else-if="!hasPipeline"> - <gl-loading-icon size="md" /> + <gl-loading-icon size="sm" /> <p class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0" data-testid="monitoring-pipeline-message" @@ -187,7 +187,7 @@ export default { class="gl-display-flex gl-align-items-center gl-ml-2" > <gl-icon - name="question" + name="question-o" :aria-label="__('Link to go to GitLab pipeline documentation')" /> </gl-link> @@ -251,7 +251,7 @@ export default { </span> {{ pipelineCoverageJobNumberText }} <span ref="pipelineCoverageQuestion"> - <gl-icon name="question" :size="12" /> + <gl-icon name="question-o" :size="12" /> </span> <gl-tooltip :target="() => $refs.pipelineCoverageQuestion" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 1fd1e264c25..5d75f1d27b1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,5 +1,6 @@ <script> import { GlLink } from '@gitlab/ui'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; @@ -30,10 +31,10 @@ export default { }, computed: { closesText() { - if (this.state === 'merged') { + if (this.state === STATUS_MERGED) { return s__('mrWidget|Closed'); } - if (this.state === 'closed') { + if (this.state === STATUS_CLOSED) { return s__('mrWidget|Did not close'); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 3239285e53e..ea3f324b8f2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import StatusIcon from './extensions/status_icon.vue'; export default { @@ -14,22 +15,24 @@ export default { }, }, computed: { + isClosed() { + return this.status === STATUS_CLOSED; + }, isLoading() { return this.status === 'loading'; }, + isMerged() { + return this.status === STATUS_MERGED; + }, }, }; </script> <template> - <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3"> + <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-center gl-mr-3"> <div class="gl-display-flex gl-m-auto"> - <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" /> - <gl-icon - v-else-if="status === 'closed'" - name="merge-request-close" - :size="16" - class="gl-text-red-500" - /> + <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" /> + <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" /> + <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" /> <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 3e79c49994f..dd899701de0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import StatusIcon from './mr_widget_status_icon.vue'; import Actions from './action_buttons.vue'; @@ -13,7 +13,30 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + expandDetailsTooltip: { + default: '', + }, + collapseDetailsTooltip: { + default: '', + }, + }, props: { + isCollapsible: { + type: Boolean, + required: false, + default: false, + }, + collapseOnDesktop: { + type: Boolean, + required: false, + default: false, + }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, mr: { type: Object, required: false, @@ -35,14 +58,10 @@ export default { default: () => [], }, }, - i18n: { - expandDetailsTooltip: __('Expand merge details'), - collapseDetailsTooltip: __('Collapse merge details'), - }, computed: { wrapperClasses() { - if (this.status === 'merged') return 'gl-bg-blue-50'; - if (this.status === 'closed') return 'gl-bg-red-50'; + if (this.status === STATUS_MERGED) return 'gl-bg-blue-50'; + if (this.status === STATUS_CLOSED) return 'gl-bg-red-50'; return null; }, hasActionsSlot() { @@ -54,11 +73,11 @@ export default { <template> <div - class="mr-widget-body media gl-display-flex gl-align-items-center" + class="mr-widget-body media gl-display-flex gl-align-items-center gl-pl-5 gl-pr-4 gl-py-4" :class="wrapperClasses" v-on="$listeners" > - <div v-if="isLoading" class="gl-w-full mr-conflict-loader"> + <div v-if="isLoading" class="gl-w-full mr-state-loader"> <slot name="loading"> <div class="gl-display-flex"> <status-icon status="loading" /> @@ -94,21 +113,19 @@ export default { </div> </div> <div - v-if="mr" - class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1" + v-if="isCollapsible" + :class="{ 'gl-md-display-none': !collapseOnDesktop }" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" > <gl-button v-gl-tooltip - :title=" - mr.mergeDetailsCollapsed - ? $options.i18n.expandDetailsTooltip - : $options.i18n.collapseDetailsTooltip - " - :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" + :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'" category="tertiary" size="small" class="gl-vertical-align-top" - @click="() => mr.toggleMergeDetails()" + data-testid="widget-toggle" + @click="() => $emit('toggle')" /> </div> </div> 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 6d7ec607557..61eec503951 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 @@ -43,7 +43,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> <bold-text :message="failedText" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 837f8b32637..722efe2e6d2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <bold-text :message="$options.message" /> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index bcae1a12344..6299f0fcbb8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -131,16 +131,23 @@ export default { }; </script> <template> - <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions"> + <state-container + status="scheduled" + :is-loading="loading" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> - <gl-skeleton-loader :width="334" :height="30"> - <rect x="0" y="3" width="24" height="24" rx="4" /> - <rect x="32" y="7" width="150" height="16" rx="4" /> - <rect x="190" y="7" width="144" height="16" rx="4" /> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="150" height="20" rx="4" /> + <rect x="190" y="2" width="144" height="20" rx="4" /> </gl-skeleton-loader> </template> <template v-if="!loading"> - <h4 class="gl-mr-3" data-testid="statusText"> + <h4 class="gl-mr-3 gl-flex-grow-1" data-testid="statusText"> <gl-sprintf :message="statusText" data-testid="statusText"> <template #merge_author> <mr-widget-author v-if="state.mergeUser" :author="state.mergeUser" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 448805cf8b9..db5ef6c1a0e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -54,7 +54,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="failed" :actions="actions"> + <state-container + status="failed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-font-weight-bold"> <template v-if="mergeError">{{ mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index 670bd36d61e..d4b7d60568b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -15,7 +15,12 @@ export default { }; </script> <template> - <state-container :mr="mr" status="loading"> + <state-container + status="loading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > {{ s__('mrWidget|Checking if merge request can be merged…') }} </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 6bcf88713a5..aebba67b39a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -79,7 +79,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="closed" :actions="actions"> + <state-container + status="closed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <mr-widget-author-time :action-text="s__('mrWidget|Closed by')" :author="mr.metrics.closedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 83d718f5a54..55ae390216d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -72,12 +72,18 @@ export default { }; </script> <template> - <state-container :mr="mr" status="failed" :is-loading="isLoading"> + <state-container + status="failed" + :is-loading="isLoading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> - <gl-skeleton-loader :width="334" :height="30"> - <rect x="0" y="7" width="150" height="16" rx="4" /> - <rect x="158" y="7" width="84" height="16" rx="4" /> - <rect x="250" y="7" width="84" height="16" rx="4" /> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="150" height="20" rx="4" /> + <rect x="190" y="2" width="144" height="20" rx="4" /> </gl-skeleton-loader> </template> <template v-if="!isLoading"> @@ -106,7 +112,7 @@ export default { v-if="userPermissions.canMerge" size="small" variant="confirm" - category="secondary" + category="tertiary" data-testid="merge-locally-button" class="js-check-out-modal-trigger gl-align-self-start" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index bfc2c282f4c..742f5d4de14 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -95,12 +95,25 @@ export default { }; </script> <template> - <state-container v-if="isRefreshing" :mr="mr" status="loading"> + <state-container + v-if="isRefreshing" + status="loading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-font-weight-bold"> {{ s__('mrWidget|Refreshing now') }} </span> </state-container> - <state-container v-else :mr="mr" status="failed" :actions="actions"> + <state-container + v-else + status="failed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span v-if="mr.mergeError" class="has-error-message gl-font-weight-bold" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 4e2b12799d0..4d906f29cb0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -150,7 +150,13 @@ export default { }; </script> <template> - <state-container :mr="mr" :actions="actions" status="merged"> + <state-container + :actions="actions" + status="merged" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <mr-widget-author-time :action-text="s__('mrWidget|Merged by')" :author="mr.metrics.mergedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index c94718ca756..17c51bc4e6e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,5 +1,6 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import { STATUS_MERGED } from '~/issues/constants'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; @@ -50,7 +51,7 @@ export default { .poll() .then((res) => res.data) .then((data) => { - if (data.state === 'merged') { + if (data.state === STATUS_MERGED) { // If state is merged we should update the widget and stop the polling eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('FetchActionsContent'); 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 fac8d37712a..415f58ea8e6 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 @@ -195,11 +195,17 @@ export default { </script> <template> <div> - <state-container :mr="mr" :status="status" :is-loading="isLoading"> + <state-container + :status="status" + :is-loading="isLoading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> - <gl-skeleton-loader :width="334" :height="30"> - <rect x="0" y="3" width="24" height="24" rx="4" /> - <rect x="32" y="5" width="302" height="20" rx="4" /> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="302" height="20" rx="4" /> </gl-skeleton-loader> </template> <template v-if="!isLoading"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 9e67791afc0..a3c529de27c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -16,6 +16,7 @@ import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_ import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import { createAlert } from '~/alert'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; import { __, s__, n__ } from '~/locale'; @@ -23,6 +24,7 @@ import SmartInterval from '~/smart_interval'; import { helpPagePath } from '~/helpers/help_page_helper'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { AUTO_MERGE_STRATEGIES, WARNING, @@ -143,6 +145,7 @@ export default { ), AddedCommitMessage, RelatedLinks, + HelpPopover, }, directives: { GlTooltip: GlTooltipDirective, @@ -261,7 +264,10 @@ export default { if (this.isMergingImmediately) { return __('Merge in progress'); } - if (this.isAutoMergeAvailable) { + if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) { + return this.autoMergeTextLegacy; + } + if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) { return this.autoMergeText; } @@ -271,9 +277,24 @@ export default { return __('Merge'); }, + autoMergeLabelsEnabled() { + return window.gon?.features?.autoMergeLabelsMrWidget; + }, + showAutoMergeHelperText() { + return ( + !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) && + this.isAutoMergeAvailable + ); + }, hasPipelineMustSucceedConflict() { return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds; }, + isNotClosed() { + return this.mr.state !== STATUS_CLOSED; + }, + isNeitherClosedNorMerged() { + return this.mr.state !== STATUS_CLOSED && this.mr.state !== STATUS_MERGED; + }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -307,7 +328,7 @@ export default { ); }, sourceBranchDeletedText() { - const isPreMerge = this.mr.state !== 'merged'; + const isPreMerge = this.mr.state !== STATUS_MERGED; if (isPreMerge) { return this.mr.shouldRemoveSourceBranch @@ -325,6 +346,11 @@ export default { showMergeDetailsHeader() { return !['readyToMerge'].includes(this.mr.state); }, + autoMergeHelpPopoverOptions() { + return { + title: this.autoMergePopoverSettings.title, + }; + }, }, mounted() { eventHub.$on('ApprovalUpdated', this.updateGraphqlState); @@ -495,17 +521,19 @@ export default { <template> <div - :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }" + :class="{ 'gl-bg-gray-10': isNeitherClosedNorMerged }" data-testid="ready_to_merge_state" class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7" > <div v-if="loading" class="mr-widget-body"> <div class="gl-w-full mr-ready-to-merge-loader"> - <gl-skeleton-loader :width="418" :height="30"> - <rect x="0" y="3" width="24" height="24" rx="4" /> - <rect x="32" y="0" width="70" height="30" rx="4" /> - <rect x="110" y="7" width="150" height="16" rx="4" /> - <rect x="268" y="7" width="150" height="16" rx="4" /> + <gl-skeleton-loader :width="418" :height="86"> + <rect x="0" y="0" width="144" height="20" rx="4" /> + <rect x="0" y="26" width="100" height="16" rx="4" /> + <rect x="108" y="26" width="100" height="16" rx="4" /> + <rect x="0" y="48" width="130" height="16" rx="4" /> + <rect x="0" y="70" width="80" height="16" rx="4" /> + <rect x="88" y="70" width="90" height="16" rx="4" /> </gl-skeleton-loader> </div> </div> @@ -517,7 +545,7 @@ export default { <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> <template v-if="shouldShowMergeControls"> <div - class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-5" + class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-2" > <gl-form-checkbox v-if="canRemoveSourceBranch" @@ -587,9 +615,7 @@ export default { </li> </ul> </div> - <div - class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details" - > + <div class="gl-w-full gl-text-gray-500 gl-mb-3 mr-widget-merge-details"> <template v-if="sourceHasDivergedFromTarget"> <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> <template #link> @@ -670,7 +696,31 @@ export default { @cancel="isPipelineFailedModalVisibleNormalMerge = false" /> </gl-button-group> - <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> + <merge-train-helper-icon + v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled" + class="gl-mx-3" + /> + <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled"> + <div + class="gl-ml-4 gl-text-gray-500 gl-font-sm" + data-qa-selector="auto_merge_helper_text" + > + {{ autoMergeHelperText }} + </div> + <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions"> + <gl-sprintf :message="autoMergePopoverSettings.bodyText"> + <template #link="{ content }"> + <gl-link + :href="autoMergePopoverSettings.helpLink" + target="_blank" + class="gl-font-sm" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </help-popover> + </template> </template> <div v-else @@ -702,7 +752,7 @@ export default { /> </li> <li - v-if="mr.state !== 'closed'" + v-if="isNotClosed" class="gl-line-height-normal" data-testid="source-branch-deleted-text" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 2aa345b420e..9da754d01fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" data-qa-selector="head_mismatch_content" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 0fd5551979d..af036c01032 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import StateContainer from '../state_container.vue'; const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.'); @@ -15,6 +16,7 @@ export default { GlButton, StateContainer, }, + mixins: [glFeatureFlagsMixin()], props: { mr: { type: Object, @@ -30,7 +32,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> <bold-text :message="$options.message" /> </span> @@ -43,17 +50,17 @@ export default { category="primary" @click="jumpToFirstUnresolvedDiscussion" > - {{ s__('mrWidget|Jump to first unresolved thread') }} + {{ s__('mrWidget|Go to first unresolved thread') }} </gl-button> <gl-button - v-if="mr.createIssueToResolveDiscussionsPath" + v-if="mr.createIssueToResolveDiscussionsPath && !glFeatures.hideCreateIssueResolveAll" :href="mr.createIssueToResolveDiscussionsPath" class="js-create-issue gl-align-self-start gl-vertical-align-top" size="small" variant="confirm" category="secondary" > - {{ s__('mrWidget|Create issue to resolve all threads') }} + {{ s__('mrWidget|Resolve all with new issue') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 7163e54985e..7fc4a06cbae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -137,7 +137,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-0! gl-text-body! gl-flex-grow-1"> <bold-text :message="$options.i18n.removeDraftStatus" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue index 6d17ac98d7f..4e8098677cc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue @@ -52,7 +52,7 @@ export default { }" class="gl-relative gl-rounded-full gl-mr-3" > - <gl-loading-icon v-if="isLoading" size="md" inline /> + <gl-loading-icon v-if="isLoading" size="sm" inline /> <gl-icon v-else :name="$options.EXTENSION_ICON_NAMES[iconName]" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index a754d4e80ea..54eb15c8ac8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -219,6 +219,8 @@ export default { this.fetchExpandedContent(); } } + + this.$emit('toggle', { expanded: !this.isCollapsed }); }, async fetchExpandedContent() { this.isLoadingExpandedContent = true; @@ -287,7 +289,7 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="gl-px-5 gl-py-4 gl-align-items-center gl-display-flex"> + <div class="gl-px-5 gl-pr-4 gl-py-4 gl-align-items-center gl-display-flex"> <status-icon :level="1" :name="widgetName" @@ -346,6 +348,7 @@ export default { category="tertiary" data-testid="toggle-button" size="small" + data-qa-selector="expand_report_button" @click="toggleCollapsed" /> </div> 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 ff225afbc7b..d2f2d394a1f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import { STATUS_CLOSED } from '~/issues/constants'; import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; @@ -82,7 +83,7 @@ export default { // Icon to get rendered on the side of each row icon: { // Required: Name maps to an icon in GitLabs SVG - name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success, + name: issue.state === STATUS_CLOSED ? EXTENSION_ICONS.error : EXTENSION_ICONS.success, }, // Badges get rendered next to the text on each row // badge: issue.state === 'closed' && { diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 183f450854a..a2f088a7a58 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,7 +1,3 @@ -// This is a false violation of @gitlab/no-runtime-template-compiler, since it -// creates a new Vue instance by spreading a _valid_ Vue component definition -// into the Vue constructor. -/* eslint-disable @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; @@ -33,6 +29,10 @@ export default () => { gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; + // This is a false violation of @gitlab/no-runtime-template-compiler, since it + // creates a new Vue instance by spreading a _valid_ Vue component definition + // into the Vue constructor. + // eslint-disable-next-line @gitlab/no-runtime-template-compiler const vm = new Vue({ el: '#js-vue-mr-widget', provide: { diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js index ae9111b9504..7e658e77d37 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -16,6 +16,8 @@ export default { result({ data }) { const { mergeRequest } = data.project; + this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval; + this.mr.setApprovals(mergeRequest); }, error() { @@ -29,6 +31,7 @@ export default { return { alerts: [], approvals: {}, + disableCommittersApproval: false, }; }, methods: { diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index d964b4bacac..10a54c73273 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,3 +1,4 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); @@ -30,10 +31,25 @@ export default { pipelineMustSucceedConflictText() { return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT; }, - autoMergeText() { + autoMergeTextLegacy() { // MWPS is currently the only auto merge strategy available in CE return __('Merge when pipeline succeeds'); }, + autoMergeText() { + return __('Set to auto-merge'); + }, + autoMergeHelperText() { + return __('Merge when pipeline succeeds'); + }, + autoMergePopoverSettings() { + return { + helpLink: helpPagePath('/user/project/merge_requests/merge_when_pipeline_succeeds.html'), + bodyText: __( + 'When the pipeline for this merge request succeeds, it will %{linkStart}automatically merge%{linkEnd}.', + ), + title: __('Merge when pipeline succeeds'), + }; + }, shouldShowMergeImmediatelyDropdown() { return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds; }, 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 bbad2c13220..6e0ee1cb912 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 @@ -10,9 +10,9 @@ import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_wid import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import { createAlert } from '~/alert'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; -import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -155,6 +155,10 @@ export default { }, }, mixins: [mergeRequestQueryVariablesMixin], + provide: { + expandDetailsTooltip: __('Expand merge details'), + collapseDetailsTooltip: __('Collapse merge details'), + }, props: { mrData: { type: Object, @@ -226,7 +230,7 @@ export default { return this.mr.allowCollaboration && this.mr.isOpen; }, shouldRenderMergedPipeline() { - return this.mr.state === 'merged' && !isEmpty(this.mr.mergePipeline); + return this.mr.state === STATUS_MERGED && !isEmpty(this.mr.mergePipeline); }, showMergePipelineForkWarning() { return Boolean( @@ -264,7 +268,7 @@ export default { return (this.mr.humanAccess || '').toLowerCase(); }, hasMergeError() { - return this.mr.mergeError && this.state !== 'closed'; + return this.mr.mergeError && this.state !== STATUS_CLOSED; }, hasAlerts() { return this.hasMergeError || this.showMergePipelineForkWarning; @@ -416,8 +420,8 @@ export default { ); }, setFaviconHelper() { - if (this.mr.ciStatusFaviconPath) { - return setFaviconOverlay(this.mr.ciStatusFaviconPath); + if (this.mr.faviconOverlayPath) { + return setFaviconOverlay(this.mr.faviconOverlayPath); } return Promise.resolve(); }, @@ -474,7 +478,6 @@ export default { el.innerHTML = res.data; document.body.appendChild(el); document.dispatchEvent(new CustomEvent('merged:UpdateActions')); - Project.initRefSwitcher(); } }) .catch(() => diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 13009651550..a7758191315 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,6 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { badgeState } from '~/issuable/components/status_box.vue'; +import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { machine } from '~/lib/utils/finite_state_machine'; import { @@ -121,7 +122,7 @@ export default class MergeRequestStore { this.ffOnlyEnabled = data.ff_only_enabled; this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; this.mergeRequestState = data.state; - this.isOpen = this.mergeRequestState === 'opened'; + this.isOpen = this.mergeRequestState === STATUS_OPEN; this.latestSHA = data.diff_head_sha; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; @@ -139,7 +140,7 @@ export default class MergeRequestStore { this.isPipelineActive = data.pipeline ? data.pipeline.active : false; this.isPipelineBlocked = data.only_allow_merge_if_pipeline_succeeds && pipelineStatus?.group === 'manual'; - this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + this.faviconOverlayPath = data.favicon_overlay_path; this.terraformReportsPath = data.terraform_reports_path; this.testResultsPath = data.test_reports_path; this.accessibilityReportPath = data.accessibility_report_path; @@ -236,11 +237,11 @@ export default class MergeRequestStore { this.state = getStateKey.call(this); } else { switch (this.mergeRequestState) { - case 'merged': - this.state = 'merged'; + case STATUS_MERGED: + this.state = STATUS_MERGED; break; - case 'closed': - this.state = 'closed'; + case STATUS_CLOSED: + this.state = STATUS_CLOSED; break; default: this.state = null; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue deleted file mode 100644 index 9d5006564ef..00000000000 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import * as Sentry from '@sentry/browser'; -import Vue from 'vue'; -import Vuex from 'vuex'; - -Vue.use(Vuex); - -export default { - props: { - dashboardUrl: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - metricEmbedComponent: null, - namespace: 'alertMetrics', - }; - }, - mounted() { - if (this.dashboardUrl) { - Promise.all([ - import('~/monitoring/components/embeds/metric_embed.vue'), - import('~/monitoring/stores'), - ]) - .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => { - this.$store = new Vuex.Store({ - modules: { - [this.namespace]: monitoringDashboard, - }, - }); - this.metricEmbedComponent = MetricEmbed; - }) - .catch((e) => Sentry.captureException(e)); - } - }, -}; -</script> - -<template> - <div class="gl-py-3"> - <div v-if="dashboardUrl" ref="metricsChart"> - <component - :is="metricEmbedComponent" - v-if="metricEmbedComponent" - :dashboard-url="dashboardUrl" - :namespace="namespace" - /> - </div> - <div v-else ref="emptyState"> - {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }} - </div> - </div> -</template> 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 f2c27cf611e..0577279cdd0 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 @@ -53,11 +53,11 @@ export default { }, methods: { updateToDoCount(add) { - const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10); + const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10) || 0; const count = add ? oldCount + 1 : oldCount - 1; const headerTodoEvent = new CustomEvent('todo:toggle', { detail: { - count, + count: Math.max(count, 0), }, }); diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js index d106f545c61..4ee8d19770d 100644 --- a/app/assets/javascripts/vue_shared/alert_details/constants.js +++ b/app/assets/javascripts/vue_shared/alert_details/constants.js @@ -9,7 +9,8 @@ export const SEVERITY_LEVELS = { UNKNOWN: s__('severity|Unknown'), }; -/* eslint-disable @gitlab/require-i18n-strings */ +const category = 'Alert Management'; // eslint-disable-line @gitlab/require-i18n-strings + export const PAGE_CONFIG = { OPERATIONS: { TITLE: 'OPERATIONS', @@ -20,14 +21,14 @@ export const PAGE_CONFIG = { }, // Tracks snowplow event when user views alert details TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: { - category: 'Alert Management', + category, action: 'view_alert_details', }, // Tracks snowplow event when alert status is updated TRACK_ALERT_STATUS_UPDATE_OPTIONS: { - category: 'Alert Management', + category, action: 'update_alert_status', - label: 'Status', + label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index cb38b3e13bb..8f1f7ba0ad8 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -35,11 +35,6 @@ export default { required: false, default: NO_USER_ID, }, - addButtonClass: { - type: String, - required: false, - default: '', - }, defaultAwards: { type: Array, required: false, @@ -50,6 +45,11 @@ export default { required: false, default: 'selected', }, + boundary: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -201,6 +201,8 @@ export default { v-gl-tooltip.viewport :title="__('Add reaction')" :toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]" + :right="false" + :boundary="boundary" data-testid="emoji-picker" @click="handleAward" @shown="setIsMenuOpen(true)" diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue index c89e843b660..b4751d51fcb 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue @@ -1,4 +1,5 @@ <script> +import { v4 as uuidv4 } from 'uuid'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { CHART_CONTAINER_HEIGHT } from './constants'; @@ -17,6 +18,15 @@ export default { required: true, }, }, + data: () => ({ + chartKey: uuidv4(), + }), + watch: { + chartData() { + // Re-render area chart when the data changes + this.chartKey = uuidv4(); + }, + }, chartContainerHeight: CHART_CONTAINER_HEIGHT, }; </script> @@ -27,6 +37,7 @@ export default { </p> <gl-area-chart v-bind="$attrs" + :key="chartKey" responsive width="auto" :height="$options.chartContainerHeight" diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index 47b96934420..a30b18348ec 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -39,11 +39,10 @@ export default { </script> <template> <div> - <segmented-control-button-group - v-model="selectedChart" - :options="chartRanges" - class="gl-mb-4" - /> + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <segmented-control-button-group v-model="selectedChart" :options="chartRanges" /> + <slot name="extend-button-group"></slot> + </div> <ci-cd-analytics-area-chart v-if="chart" v-bind="$attrs" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js new file mode 100644 index 00000000000..97143d90c3f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js @@ -0,0 +1,11 @@ +import { RENAMED_DIFF_TRANSITIONS } from '~/diffs/constants'; + +export const transition = (currentState, transitionEvent) => { + const key = `${currentState}:${transitionEvent}`; + + if (RENAMED_DIFF_TRANSITIONS[key]) { + return RENAMED_DIFF_TRANSITIONS[key]; + } + + return currentState; +}; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue index b786f7752df..f7b817423de 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -10,15 +10,14 @@ import { STATE_IDLING, STATE_LOADING, STATE_ERRORED, - RENAMED_DIFF_TRANSITIONS, } from '~/diffs/constants'; import { truncateSha } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; +import { transition } from '../utils'; export default { STATE_LOADING, STATE_ERRORED, - TRANSITIONS: RENAMED_DIFF_TRANSITIONS, uiText: { showLink: __('Show file contents'), commitLink: __('View file @ %{commitSha}'), @@ -52,27 +51,23 @@ export default { }, methods: { ...mapActions('diffs', ['switchToFullDiffFromRenamedFile']), - transition(transitionEvent) { - const key = `${this.state}:${transitionEvent}`; - - if (this.$options.TRANSITIONS[key]) { - this.state = this.$options.TRANSITIONS[key]; - } - }, is(state) { return this.state === state; }, switchToFull() { - this.transition(TRANSITION_LOAD_START); + this.transitionState(TRANSITION_LOAD_START); this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile }) .then(() => { - this.transition(TRANSITION_LOAD_SUCCEED); + this.transitionState(TRANSITION_LOAD_SUCCEED); }) .catch(() => { - this.transition(TRANSITION_LOAD_ERROR); + this.transitionState(TRANSITION_LOAD_ERROR); }); }, + transitionState(transitionEvent) { + this.state = transition(this.state, transitionEvent); + }, clickLink(event) { if (this.canLoadFullDiff) { event.preventDefault(); @@ -81,7 +76,7 @@ export default { } }, dismissError() { - this.transition(TRANSITION_ACKNOWLEDGE_ERROR); + this.transitionState(TRANSITION_ACKNOWLEDGE_ERROR); }, }, }; 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 1da84df022f..b920af593df 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue @@ -27,6 +27,12 @@ export default { type: Number, required: true, }, + /* enable possibility to cycle around */ + enableCycle: { + type: Boolean, + required: false, + default: false, + }, }, watch: { max() { @@ -64,15 +70,34 @@ export default { return; } - const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max)); + let nextIndex = Math.max(this.min, Math.min(this.index + val, this.max)); - // Return if the index didn't change if (nextIndex === this.index) { - return; + // Return if the index didn't change and cycle is not enabled + if (!this.enableCycle) { + return; + } + // Update nextIndex if the cycle is enabled + nextIndex = this.cycle(nextIndex, val); } this.$emit('change', nextIndex); }, + cycle(nextIndex, val) { + if (val === 1 && nextIndex === this.max) { + // if we are moving down +1 and we reached bottom (max) + // return top most index (min) + return this.min; + } + + if (val === -1 && nextIndex === this.min) { + // if we are moving up -1 and we reached top (min) + // return bottom most index (max) + return this.max; + } + + return nextIndex; + }, }, render() { return this.$scopedSlots.default?.(); diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index 45c50dce8ce..9b45e969c90 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -13,6 +13,11 @@ export default { GlCollapsibleListbox, }, props: { + block: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: true, @@ -176,6 +181,7 @@ export default { <gl-collapsible-listbox ref="listbox" v-model="selected" + :block="block" :header-text="headerText" :reset-button-label="resetButtonLabel" :toggle-text="toggleText" diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js index 1afbeda74c4..12db70d8e9c 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js @@ -20,6 +20,8 @@ export const initProjectSelects = () => { orderBy, selected: initialSelection, } = el.dataset; + const block = parseBoolean(el.dataset.block); + const withShared = parseBoolean(el.dataset.withShared); const includeSubgroups = parseBoolean(el.dataset.includeSubgroups); const membership = parseBoolean(el.dataset.membership); const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel); @@ -37,6 +39,8 @@ export const initProjectSelects = () => { groupId, userId, orderBy, + block, + withShared, includeSubgroups, membership, initialSelection, diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue index 393991d746e..7af3819f2a5 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -20,6 +20,11 @@ export default { SafeHtml, }, props: { + block: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: true, @@ -47,6 +52,11 @@ export default { required: false, default: null, }, + withShared: { + type: Boolean, + required: false, + default: true, + }, includeSubgroups: { type: Boolean, required: false, @@ -86,7 +96,7 @@ export default { if (this.groupId) { return Api.groupProjects(this.groupId, searchString, { ...commonParams, - with_shared: true, + with_shared: this.withShared, include_subgroups: this.includeSubgroups, simple: true, }); @@ -99,7 +109,7 @@ export default { this.userId, searchString, { - with_shared: true, + with_shared: this.withShared, include_subgroups: this.includeSubgroups, }, (res) => ({ data: res }), @@ -154,6 +164,7 @@ export default { :default-toggle-text="$options.i18n.searchForProject" :fetch-items="fetchProjects" :fetch-initial-selection-text="fetchProjectName" + :block="block" clearable > <template v-if="hasHtmlLabel" #label> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index b0fa3e4c27e..b5783265ffa 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -9,7 +9,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants'; +import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, @@ -100,7 +100,7 @@ export default { return this.getActiveTokenValue(this.suggestions, this.value.data); }, availableDefaultSuggestions() { - if (this.value.operator === OPERATOR_NOT) { + if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) { return this.defaultSuggestions.filter( (suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value), ); 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 2f10e068542..dea279890b1 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 @@ -137,6 +137,7 @@ export default { v-if="showCopyButton" :text="value" :title="copyButtonTitle" + data-qa-selector="clipboard_button" @click="handleCopyButtonClick" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index 989b14f8711..8b3a54a536e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -1,6 +1,7 @@ <script> import { GlCollapsibleListbox, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { updateText } from '~/lib/utils/text_markdown'; import savedRepliesQuery from './saved_replies.query.graphql'; export default { @@ -9,7 +10,7 @@ export default { query: savedRepliesQuery, update: (r) => r.currentUser?.savedReplies?.nodes, skip() { - return !this.shouldFetchSavedReplies; + return !this.shouldFetchCommentTemplates; }, }, }, @@ -22,34 +23,52 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - newSavedRepliesPath: { + newCommentTemplatePath: { type: String, required: true, }, }, data() { return { - shouldFetchSavedReplies: false, + shouldFetchCommentTemplates: false, savedReplies: [], - savedRepliesSearch: '', + commentTemplateSearch: '', loadingSavedReplies: false, }; }, computed: { filteredSavedReplies() { - const savedReplies = this.savedRepliesSearch - ? fuzzaldrinPlus.filter(this.savedReplies, this.savedRepliesSearch, { key: ['name'] }) + const savedReplies = this.commentTemplateSearch + ? fuzzaldrinPlus.filter(this.savedReplies, this.commentTemplateSearch, { key: ['name'] }) : this.savedReplies; return savedReplies.map((r) => ({ value: r.id, text: r.name, content: r.content })); }, }, methods: { - fetchSavedReplies() { - this.shouldFetchSavedReplies = true; + fetchCommentTemplates() { + this.shouldFetchCommentTemplates = true; }, - setSavedRepliesSearch(search) { - this.savedRepliesSearch = search; + setCommentTemplateSearch(search) { + this.commentTemplateSearch = search; + }, + onSelect(id) { + const savedReply = this.savedReplies.find((r) => r.id === id); + const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); + + if (savedReply && textArea) { + updateText({ + textArea, + tag: savedReply.content, + cursorOffset: 0, + wrap: false, + }); + + // Wait for text to be added into textarea + requestAnimationFrame(() => { + textArea.focus(); + }); + } }, }, }; @@ -57,36 +76,32 @@ export default { <template> <gl-collapsible-listbox - :header-text="__('Insert saved reply')" + :header-text="__('Insert comment template')" :items="filteredSavedReplies" placement="right" searchable - class="saved-replies-dropdown" + class="comment-template-dropdown" :searching="$apollo.queries.savedReplies.loading" - @shown="fetchSavedReplies" - @search="setSavedRepliesSearch" + @shown="fetchCommentTemplates" + @search="setCommentTemplateSearch" + @select="onSelect" > <template #toggle> <gl-button v-gl-tooltip - :title="__('Insert saved reply')" - :aria-label="__('Insert saved reply')" + :title="__('Insert comment template')" + :aria-label="__('Insert comment template')" category="tertiary" class="gl-px-3!" - data-testid="saved-replies-dropdown-toggle" + data-testid="comment-template-dropdown-toggle" + @keydown.prevent > <gl-icon name="symlink" class="gl-mr-0!" /> <gl-icon name="chevron-down" /> </gl-button> </template> <template #list-item="{ item }"> - <div - class="gl-display-flex js-saved-reply-content" - :data-md-tag="item.content" - data-md-cursor-offset="0" - data-md-prepend="true" - data-testid="saved-reply-dropdown-item" - > + <div class="gl-display-flex js-comment-template-content"> <div class="gl-text-truncate"> <strong>{{ item.text }}</strong ><span class="gl-ml-2">{{ item.content }}</span> @@ -98,11 +113,11 @@ export default { class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3" > <gl-button - :href="newSavedRepliesPath" + :href="newCommentTemplatePath" category="tertiary" block class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!" - >{{ __('Add a new saved reply') }}</gl-button + >{{ __('Add a new comment template') }}</gl-button > </div> </template> @@ -110,11 +125,11 @@ export default { </template> <style> -.saved-replies-dropdown .gl-new-dropdown-panel { +.comment-template-dropdown .gl-new-dropdown-panel { width: 350px; } -.saved-replies-dropdown .gl-new-dropdown-item-check-icon { +.comment-template-dropdown .gl-new-dropdown-item-check-icon { display: none; } </style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue index 9ebf782a1d9..7803d6f53e0 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue @@ -23,7 +23,7 @@ export default { return this.value === 'markdown'; }, text() { - return this.markdownEditorSelected ? __('Viewing markdown') : __('Viewing rich text'); + return this.markdownEditorSelected ? __('Editing markdown') : __('Editing rich text'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9623c51d51c..cc153747765 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -416,7 +416,7 @@ export default { <div v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading" v-safe-html:[$options.safeHtmlConfig]="referencedCommands" - class="referenced-commands" + class="referenced-commands gl-mx-n5" data-testid="referenced-commands" ></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index eeeb0fce55d..3486f231b39 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -17,7 +17,7 @@ import { s__, __ } from '~/locale'; import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ToolbarButton from './toolbar_button.vue'; import DrawioToolbarButton from './drawio_toolbar_button.vue'; -import SavedRepliesDropdown from './saved_replies_dropdown.vue'; +import CommentTemplatesDropdown from './comment_templates_dropdown.vue'; export default { components: { @@ -27,16 +27,19 @@ export default { GlTabs, GlTab, DrawioToolbarButton, - SavedRepliesDropdown, + CommentTemplatesDropdown, + AiActionsDropdown: () => + import('ee_component/vue_shared/components/markdown/ai_actions_dropdown.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], inject: { - newSavedRepliesPath: { + newCommentTemplatePath: { default: null, }, + resourceGlobalId: { default: null }, }, props: { previewMarkdown: { @@ -118,6 +121,9 @@ export default { const expandText = s__('MarkdownEditor|Click to expand'); return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n'); }, + showAiActions() { + return this.resourceGlobalId && this.glFeatures.summarizeComments; + }, }, watch: { showSuggestPopover() { @@ -269,6 +275,7 @@ export default { </gl-button> </gl-popover> </template> + <ai-actions-dropdown v-if="showAiActions" :resource-global-id="resourceGlobalId" /> <toolbar-button tag="**" :button-title=" @@ -400,9 +407,9 @@ export default { :uploads-path="uploadsPath" :markdown-preview-path="markdownPreviewPath" /> - <saved-replies-dropdown - v-if="newSavedRepliesPath && glFeatures.savedReplies" - :new-saved-replies-path="newSavedRepliesPath" + <comment-templates-dropdown + v-if="newCommentTemplatePath && glFeatures.savedReplies" + :new-comment-template-path="newCommentTemplatePath" /> <toolbar-button v-if="!restrictedToolBarItems.includes('full-screen')" diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 93583907a11..52d8aab30d5 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -20,6 +20,11 @@ export default { type: String, required: true, }, + setFacade: { + type: Function, + required: false, + default: null, + }, renderMarkdownPath: { type: String, required: true, @@ -44,6 +49,16 @@ export default { required: false, default: false, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, supportsQuickActions: { type: Boolean, required: false, @@ -54,6 +69,11 @@ export default { required: false, default: null, }, + markdownDocsPath: { + type: String, + required: false, + default: '', + }, quickActionsDocsPath: { type: String, required: false, @@ -97,9 +117,25 @@ export default { mounted() { this.autofocusTextarea(); + this.$emit('input', this.markdown); this.saveDraft(); + + this.setFacade?.({ + getValue: () => this.getValue(), + setValue: (val) => this.setValue(val), + }); }, methods: { + getValue() { + return this.markdown; + }, + setValue(value) { + this.markdown = value; + this.$emit('input', value); + + this.saveDraft(); + this.autosizeTextarea(); + }, updateMarkdownFromContentEditor({ markdown }) { this.markdown = markdown; this.$emit('input', markdown); @@ -121,6 +157,11 @@ export default { this.notifyEditingModeChange(editingMode); }, onEditingModeRestored(editingMode) { + if (editingMode === EDITING_MODE_CONTENT_EDITOR && !this.enableContentEditor) { + this.editingMode = EDITING_MODE_MARKDOWN_FIELD; + return; + } + this.editingMode = editingMode; this.$emit(editingMode); this.notifyEditingModeChange(editingMode); @@ -161,7 +202,8 @@ export default { <div> <local-storage-sync v-model="editingMode" - storage-key="gl-wiki-content-editor-enabled" + as-string + storage-key="gl-markdown-editor-mode" @input="onEditingModeRestored" /> <markdown-field @@ -173,11 +215,14 @@ export default { can-attach-file :textarea-value="markdown" :uploads-path="uploadsPath" + :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :show-content-editor-switcher="enableContentEditor" :drawio-enabled="drawioEnabled" - class="bordered-box" @enableContentEditor="onEditingModeChange('contentEditor')" + @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <template #textarea> <textarea @@ -205,6 +250,8 @@ export default { :autofocus="contentEditorAutofocused" :placeholder="formFieldProps.placeholder" :drawio-enabled="drawioEnabled" + :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" :editable="!disabled" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js new file mode 100644 index 00000000000..0f2a46f78f7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; +import MarkdownEditor from './markdown_editor.vue'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +function organizeQuery(obj, isFallbackKey = false) { + if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { + return obj; + } + + if (isFallbackKey) { + return { + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + }; + } + + return { + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH], + }; +} + +function format(searchTerm, isFallbackKey = false) { + const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true }); + const organizeQueryObject = organizeQuery(queryObject, isFallbackKey); + const formattedQuery = objectToQuery(organizeQueryObject); + + return formattedQuery; +} + +function getSearchTerm(newIssuePath) { + const { search, pathname } = document.location; + return newIssuePath === pathname ? '' : format(search); +} + +export function mountMarkdownEditor() { + const el = document.querySelector('.js-markdown-editor'); + + if (!el) { + return null; + } + + const { + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + formFieldPlaceholder, + formFieldClasses, + qaSelector, + newIssuePath, + } = el.dataset; + + const hiddenInput = el.querySelector('input[type="hidden"]'); + const formFieldName = hiddenInput.getAttribute('name'); + const formFieldId = hiddenInput.getAttribute('id'); + const formFieldValue = hiddenInput.value; + + const searchTerm = getSearchTerm(newIssuePath); + const facade = { + setValue() {}, + getValue() {}, + focus() {}, + }; + + const setFacade = (props) => Object.assign(facade, props); + + // eslint-disable-next-line no-new + new Vue({ + el, + render(h) { + return h(MarkdownEditor, { + props: { + setFacade, + enableContentEditor: Boolean(gon.features?.contentEditorOnIssues), + value: formFieldValue, + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + formFieldProps: { + placeholder: formFieldPlaceholder, + id: formFieldId, + name: formFieldName, + class: formFieldClasses, + 'data-qa-selector': qaSelector, + }, + autosaveKey: `autosave/${document.location.pathname}/${searchTerm}/description`, + enableAutocomplete: true, + autocompleteDataSources: gl.GfmAutoComplete?.dataSources, + supportsQuickActions: true, + autofocus: true, + }, + }); + }, + }); + + return facade; +} diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 49eb11f8081..6d1cadf15be 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -176,6 +176,12 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div> + <div + v-show="isRendered" + ref="container" + v-safe-html="noteHtml" + data-testid="suggestions-container" + class="md suggestions" + ></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index e091fe74717..fac32bfdb24 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -13,7 +13,9 @@ export default { <template> <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder"> - <div class="timeline-icon"></div> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + ></div> <div class="timeline-content"> <div class="note-header"></div> <div class="note-body gl-mt-4"><gl-skeleton-loader /></div> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 1cbbdf0deb0..06ca90fa8c6 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -25,11 +25,18 @@ import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { spriteIcon } from '~/lib/utils/common_utils'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; +const MR_ICON_COLORS = { + check: 'gl-bg-green-100 gl-text-green-700', + 'merge-request-close': 'gl-bg-red-100 gl-text-red-700', + merge: 'gl-bg-blue-100 gl-text-blue-700', +}; +const ICON_COLORS = { + 'issue-close': 'gl-bg-blue-100 gl-text-blue-700', +}; export default { i18n: { @@ -63,7 +70,7 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'descriptionVersions']), + ...mapGetters(['targetNoteHash', 'descriptionVersions', 'getNoteableData']), ...mapState(['isLoadingDescriptionVersion']), noteAnchorId() { return `note_${this.note.id}`; @@ -71,9 +78,6 @@ export default { isTargetNote() { return this.targetNoteHash === this.noteAnchorId; }, - iconHtml() { - return spriteIcon(this.note.system_note_icon_name); - }, toggleIcon() { return this.expanded ? 'chevron-up' : 'chevron-down'; }, @@ -87,6 +91,19 @@ export default { descriptionVersion() { return this.descriptionVersions[this.note.description_version_id]; }, + isMergeRequest() { + return this.getNoteableData.noteableType === 'MergeRequest'; + }, + hasIconColors() { + if (!this.isMergeRequest) return true; + + return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name]; + }, + iconBgClass() { + const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS; + + return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600'; + }, }, mounted() { renderGFM(this.$refs['gfm-content']); @@ -108,9 +125,6 @@ export default { } }, }, - safeHtmlConfig: { - ADD_TAGS: ['use'], // to support icon SVGs - }, userColorSchemeClass: window.gon.user_color_scheme, }; </script> @@ -121,7 +135,24 @@ export default { :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > - <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div> + <div + :class="[ + iconBgClass, + { + 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors, + 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest, + 'mr-system-note-icon': isMergeRequest, + }, + ]" + class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon" + > + <gl-icon + v-if="note.system_note_icon_name && hasIconColors" + :name="note.system_note_icon_name" + :size="isMergeRequest ? 12 : 16" + data-testid="timeline-icon" + /> + </div> <div class="timeline-content"> <div class="note-header"> <note-header diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue index 35f9ac14681..9d5f494579b 100644 --- a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue @@ -1,3 +1,7 @@ <template> - <div class="timeline-icon"><slot></slot></div> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > + <slot></slot> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue new file mode 100644 index 00000000000..1ace1c52a68 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue @@ -0,0 +1,37 @@ +<script> +import ProjectsListItem from './projects_list_item.vue'; + +export default { + components: { ProjectsListItem }, + props: { + /** + * Expected format: + * + * { + * id: number | string; + * name: string; + * webUrl: string; + * forksCount?: number; + * avatarUrl: string | null; + * starCount: number; + * visibility: string; + * issuesAccessLevel: string; + * forkingAccessLevel: string; + * openIssuesCount: number; + * permissions: { + * projectAccess: { accessLevel: 50 }; + * }[]; + */ + projects: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <projects-list-item v-for="project in projects" :key="project.id" :project="project" /> + </ul> +</template> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue new file mode 100644 index 00000000000..f77fd029e93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -0,0 +1,152 @@ +<script> +import { GlAvatarLabeled, GlIcon, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui'; + +import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import { FEATURABLE_ENABLED } from '~/featurable/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { __ } from '~/locale'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; + +export default { + i18n: { + stars: __('Stars'), + forks: __('Forks'), + issues: __('Issues'), + archived: __('Archived'), + }, + components: { + GlAvatarLabeled, + GlIcon, + UserAccessRoleBadge, + GlLink, + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + /** + * Expected format: + * + * { + * id: number | string; + * name: string; + * webUrl: string; + * forksCount?: number; + * avatarUrl: string | null; + * starCount: number; + * visibility: string; + * issuesAccessLevel: string; + * forkingAccessLevel: string; + * openIssuesCount: number; + * permissions: { + * projectAccess: { accessLevel: 50 }; + * }; + */ + project: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.project.visibility]; + }, + visibilityTooltip() { + return PROJECT_VISIBILITY_TYPE[this.project.visibility]; + }, + accessLevel() { + return this.project.permissions?.projectAccess?.accessLevel; + }, + accessLevelLabel() { + return ACCESS_LEVEL_LABELS[this.accessLevel]; + }, + shouldShowAccessLevel() { + return this.accessLevel !== undefined; + }, + starsHref() { + return `${this.project.webUrl}/-/starrers`; + }, + forksHref() { + return `${this.project.webUrl}/-/forks`; + }, + issuesHref() { + return `${this.project.webUrl}/-/issues`; + }, + isForkingEnabled() { + return ( + this.project.forkingAccessLevel === FEATURABLE_ENABLED && + this.project.forksCount !== undefined + ); + }, + isIssuesEnabled() { + return this.project.issuesAccessLevel === FEATURABLE_ENABLED; + }, + }, + methods: { + numberToMetricPrefix, + }, +}; +</script> + +<template> + <li class="gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b"> + <gl-avatar-labeled + class="gl-flex-grow-1" + :entity-id="project.id" + :entity-name="project.name" + :label="project.name" + :label-link="project.webUrl" + shape="rect" + :size="48" + > + <template #meta> + <gl-icon + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary gl-ml-3" + /> + <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{ + accessLevelLabel + }}</user-access-role-badge> + </template> + </gl-avatar-labeled> + <div + class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0" + > + <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> + <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge> + <gl-link + v-gl-tooltip="$options.i18n.stars" + :href="starsHref" + :aria-label="$options.i18n.stars" + class="gl-text-secondary" + > + <gl-icon name="star-o" /> + <span>{{ numberToMetricPrefix(project.starCount) }}</span> + </gl-link> + <gl-link + v-if="isForkingEnabled" + v-gl-tooltip="$options.i18n.forks" + :href="forksHref" + :aria-label="$options.i18n.forks" + class="gl-text-secondary" + > + <gl-icon name="fork" /> + <span>{{ numberToMetricPrefix(project.forksCount) }}</span> + </gl-link> + <gl-link + v-if="isIssuesEnabled" + v-gl-tooltip="$options.i18n.issues" + :href="issuesHref" + :aria-label="$options.i18n.issues" + class="gl-text-secondary" + > + <gl-icon name="issues" /> + <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span> + </gl-link> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_shared/components/registry/history_item.vue b/app/assets/javascripts/vue_shared/components/registry/history_item.vue index 384b084ce09..d7d62df78f5 100644 --- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue @@ -19,7 +19,9 @@ export default { <template> <timeline-entry-item class="system-note note-wrapper"> - <div class="timeline-icon"> + <div + class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left" + > <gl-icon :name="icon" /> </div> <div class="timeline-content"> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 092e8ba6c15..d77061d4b31 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,6 +1,5 @@ <script> import { GlIntersectionObserver } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { getPageParamValue, getPageSearchString } from '~/blob/utils'; @@ -21,7 +20,6 @@ export default { directives: { SafeHtml, }, - mixins: [glFeatureFlagMixin()], props: { isHighlighted: { type: Boolean, @@ -69,7 +67,6 @@ export default { return this.content.split('\n'); }, pageSearchString() { - if (!this.glFeatures.fileLineBlame) return ''; const page = getPageParamValue(this.number); return getPageSearchString(this.blamePath, page); }, @@ -106,7 +103,6 @@ export default { class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" > <a - v-if="glFeatures.fileLineBlame" class="gl-user-select-none gl-shadow-none! file-line-blame" :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" ></a> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index ce6741f33b1..f121e84e1de 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,13 +1,11 @@ <script> import SafeHtml from '~/vue_shared/directives/safe_html'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getPageParamValue, getPageSearchString } from '~/blob/utils'; export default { directives: { SafeHtml, }, - mixins: [glFeatureFlagMixin()], props: { number: { type: Number, @@ -28,7 +26,6 @@ export default { }, computed: { pageSearchString() { - if (!this.glFeatures.fileLineBlame) return ''; const page = getPageParamValue(this.number); return getPageSearchString(this.blamePath, page); }, @@ -41,8 +38,7 @@ export default { class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" > <a - v-if="glFeatures.fileLineBlame" - class="gl-user-select-none gl-shadow-none! file-line-blame" + class="gl-user-select-none gl-shadow-none! file-line-blame gl-mx-n2 gl-flex-grow-1" :href="`${blamePath}${pageSearchString}#L${number}`" ></a> <a 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 dd9d2ce66cd..e09f193310b 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 @@ -1,5 +1,6 @@ <script> import { + GlBadge, GlPopover, GlLink, GlSkeletonLoader, @@ -35,6 +36,7 @@ export default { I18N_USER_LEARN, USER_POPOVER_DELAY, components: { + GlBadge, GlIcon, GlLink, GlPopover, @@ -226,9 +228,9 @@ export default { 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_USER_BUSY }})</span - > + <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-1"> + {{ $options.I18N_USER_BUSY }} + </gl-badge> </template> </gl-avatar-labeled> </div> @@ -269,7 +271,7 @@ export default { <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> <gl-sprintf :message="$options.I18N_USER_LEARN"> <template #name>{{ user.name }}</template> 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 4ef9bc07b1c..9665e188469 100644 --- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue +++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue @@ -5,7 +5,9 @@ export default { // We can't use this.vuexModule due to bug in vue-apollo when // provide is called in beforeCreate // See https://github.com/vuejs/vue-apollo/pull/1153 for details - vuexModule: this.$options.propsData.vuexModule, + + // @vue-compat does not care to normalize propsData fields + vuexModule: this.$options.propsData.vuexModule ?? this.$options.propsData['vuex-module'], }; }, props: { diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 388e7c92f03..4211b9578a2 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -27,6 +27,10 @@ export const KBD_HELP = sprintf( { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, false, ); +export const MIN_SEARCH_TERM = s__( + 'GlobalSearch|The search term must be at least 3 characters long.', +); + export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}'); export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); 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 5b303b9a314..a68c577bff6 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 @@ -2,6 +2,7 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { STATUS_CLOSED } from '~/issues/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; @@ -93,13 +94,13 @@ export default { return getTimeago().format(this.issuable.createdAt); }, timestamp() { - if (this.issuable.state === 'closed' && this.issuable.closedAt) { + if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { return this.issuable.closedAt; } return this.issuable.updatedAt; }, formattedTimestamp() { - if (this.issuable.state === 'closed' && this.issuable.closedAt) { + if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { return sprintf(__('closed %{timeago}'), { timeago: getTimeago().format(this.issuable.closedAt), }); @@ -226,7 +227,7 @@ export default { </gl-link> <span v-if="taskStatus" - class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3" + class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm" data-testid="task-status" > {{ taskStatus }} @@ -265,7 +266,7 @@ export default { :data-avatar-url="author.avatarUrl" :href="author.webUrl" data-testid="issuable-author" - class="author-link js-user-link" + class="author-link js-user-link gl-font-sm" > <span class="author">{{ author.name }}</span> </gl-link> @@ -285,8 +286,7 @@ export default { </span> <slot name="timeframe"></slot> </span> - - <span v-if="labels.length" role="group" :aria-label="__('Labels')"> + <p v-if="labels.length" role="group" :aria-label="__('Labels')" class="gl-mt-1 gl-mb-0"> <gl-label v-for="(label, index) in labels" :key="index" @@ -295,10 +295,10 @@ export default { :description="label.description" :scoped="scopedLabel(label)" :target="labelTarget(label)" - :class="{ 'gl-ml-2': index }" + class="gl-mr-2" size="sm" /> - </span> + </p> </div> </div> <div class="issuable-meta"> @@ -312,7 +312,7 @@ export default { :icon-size="16" :max-visible="4" img-css-classes="gl-mr-2!" - class="gl-align-items-center gl-display-flex gl-ml-3" + class="gl-align-items-center gl-display-flex" /> </li> <slot name="statistics"></slot> 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 5b6c5bf6e03..3ac6aaf8b86 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,5 +1,5 @@ <script> -import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui'; +import { GlAlert, GlBadge, 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'; @@ -24,6 +24,7 @@ export default { }, components: { GlAlert, + GlBadge, GlKeysetPagination, GlSkeletonLoader, IssuableTabs, @@ -371,7 +372,9 @@ export default { <slot name="timeframe" :issuable="issuable"></slot> </template> <template #status> - <slot name="status" :issuable="issuable"></slot> + <gl-badge size="sm" variant="info"> + <slot name="status" :issuable="issuable"></slot> + </gl-badge> </template> <template #statistics> <slot name="statistics" :issuable="issuable"></slot> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index a8d5f72373c..45fde45f516 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -139,7 +139,7 @@ export default { <template> <div class="issue-details issuable-details"> - <div class="detail-page-description js-detail-page-description content-block gl-pt-2"> + <div class="detail-page-description js-detail-page-description content-block gl-pt-4"> <issuable-edit-form v-if="editFormVisible" :issuable="issuable" diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index 26309a25f07..08e52442311 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -20,7 +20,9 @@ export default { }; </script> <template> - <div class="container gl-display-flex gl-flex-direction-column"> + <div + class="gl-display-flex gl-flex-direction-column gl-mt-4 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid" + > <h2 class="gl-my-7 gl-font-size-h1 gl-text-center"> {{ title }} </h2> diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 2533b3b5489..31fd9e0a0ec 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -130,17 +130,19 @@ export default { <template> <credit-card-verification v-if="shouldVerify" @verified="onVerified" /> - <div v-else-if="!activePanelName"> - <gl-breadcrumb :items="breadcrumbs" /> + <div v-else-if="!activePanelName" class="gl-mt-4"> + <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> <welcome-page :panels="panels" :title="title"> <template #footer> <slot name="welcome-footer"> </slot> </template> </welcome-page> </div> - <div v-else> - <gl-breadcrumb :items="breadcrumbs" /> - <div class="gl-display-flex gl-py-5 gl-align-items-center"> + <div v-else class="gl-pt-4"> + <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> + <div + class="gl-display-flex gl-align-items-center gl-mt-4 gl-py-5 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid" + > <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div> <div class="col"> <h4>{{ activePanel.title }}</h4> diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index 3afd1f9410b..c2ff2eec9fa 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -1,6 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { parseErrorMessage } from '~/lib/utils/error_message'; import { redirectTo } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '~/locale'; import apolloProvider from '../provider'; @@ -9,6 +10,16 @@ function mutationSettingsForFeatureType(type) { return featureToMutationMap[type]; } +export const i18n = { + buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), + noSuccessPathError: s__( + 'SecurityConfiguration|%{featureName} merge request creation mutation failed', + ), + genericErrorText: s__( + `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`, + ), +}; + export default { apolloProvider, components: { @@ -55,15 +66,20 @@ export default { throw new Error(errors[0]); } + // Sending window.gon.uf_error_prefix prefixed messages should happen only in + // the backend. Hence the code below is an anti-pattern. + // The issue to refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/397714 if (!successPath) { throw new Error( - sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }), + `${window.gon.uf_error_prefix} ${sprintf(this.$options.i18n.noSuccessPathError, { + featureName: this.feature.name, + })}`, ); } redirectTo(successPath); } catch (e) { - this.$emit('error', e.message); + this.$emit('error', parseErrorMessage(e, this.$options.i18n.genericErrorText)); this.isLoading = false; } }, @@ -84,12 +100,7 @@ export default { Boolean(mutationSettingsForFeatureType(type)) ); }, - i18n: { - buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), - noSuccessPathError: s__( - 'SecurityConfiguration|%{featureName} merge request creation mutation failed', - ), - }, + i18n, }; </script> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index fafbd02634f..8b523645973 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -20,6 +20,7 @@ export const REPORT_TYPE_SAST = 'sast'; export const REPORT_TYPE_SAST_IAC = 'sast_iac'; export const REPORT_TYPE_DAST = 'dast'; export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles'; +export const REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION = 'breach_and_attack_simulation'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning'; export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning'; @@ -28,6 +29,7 @@ export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing'; export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management'; export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning'; export const REPORT_TYPE_API_FUZZING = 'api_fuzzing'; +export const REPORT_TYPE_MANUALLY_ADDED = 'generic'; /** * SecurityReportTypeEnum values for use with GraphQL. diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue index 78e5dff6f59..90a8a7aa3e6 100644 --- a/app/assets/javascripts/webhooks/components/test_dropdown.vue +++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue @@ -19,45 +19,16 @@ export default { }, }, computed: { - itemsWithAction() { - return this.items.map((item) => ({ - text: item.text, - action: () => this.testHook(item.href), + webhookTriggers() { + return this.items.map(({ text, href }) => ({ + text, + href, + extraAttrs: { + 'data-method': 'post', + }, })); }, }, - methods: { - testHook(href) { - // HACK: Trigger @rails/ujs's data-method handling. - // - // The more obvious approaches of (1) declaratively rendering the - // links using GlDisclosureDropdown's list-item slot and (2) using - // item.extraAttrs to set the data-method attributes on the links - // do not work for reasons laid out in - // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134. - // - // Sending the POST with axios also doesn't work, since the - // endpoints return 302 redirects. Since axios uses XMLHTTPRequest, - // it transparently follows redirects, meaning the Location header - // of the first response cannot be inspected/acted upon by JS. We - // could manually trigger a reload afterwards, but that would mean - // a duplicate fetch of the current page: one by the XHR, and one - // by the explicit reload. It would also mean losing the flash - // alert set by the backend, making the feature useless for the - // user. - // - // The ideal fix here would be to refactor the test endpoint to - // return a JSON response, removing the need for a redirect/page - // reload to show the result. - const a = document.createElement('a'); - a.setAttribute('hidden', ''); - a.href = href; - a.dataset.method = 'post'; - document.body.appendChild(a); - a.click(); - a.remove(); - }, - }, i18n: { test: __('Test'), }, @@ -65,5 +36,5 @@ export default { </script> <template> - <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" /> + <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="webhookTriggers" :size="size" /> </template> diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue index ab4691a4a4e..f8dfa1c7f01 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -127,7 +127,11 @@ export default { :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > - <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > + <gl-icon :name="note.systemNoteIconName" /> + </div> <div class="timeline-content"> <div class="note-header"> <note-header diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index 1762344ea9e..6c27d5a87f0 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -1,9 +1,9 @@ <script> -import { GlAvatar, GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; -import { clearDraft } from '~/lib/utils/autosave'; import Tracking from '~/tracking'; import { ASC } from '~/notes/constants'; +import { __ } from '~/locale'; +import { clearDraft } from '~/lib/utils/autosave'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getWorkItemQuery } from '../../utils'; import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql'; @@ -17,8 +17,6 @@ export default { avatarUrl: window.gon.current_user_avatar_url, }, components: { - GlAvatar, - GlButton, WorkItemNoteSignedOut, WorkItemCommentLocked, WorkItemCommentForm, @@ -66,11 +64,25 @@ export default { required: false, default: ASC, }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { workItem: {}, - isEditing: false, + isEditing: this.isNewDiscussion, isSubmitting: false, isSubmittingWithKeydown: false, }; @@ -109,28 +121,9 @@ export default { property: `type_${this.workItemType}`, }; }, - markdownPreviewPath() { - return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ - this.workItemType - }`; - }, - isLockedOutOrSignedOut() { - return !this.signedIn || !this.canUpdate; - }, - lockedOutUserWarningInReplies() { - return this.addPadding && this.isLockedOutOrSignedOut; - }, - timelineEntryClass() { - return { - 'timeline-entry gl-mb-3 note note-wrapper note-comment': true, - 'gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-p-5! gl-mx-n3 gl-mb-n2!': this - .lockedOutUserWarningInReplies, - }; - }, timelineEntryInnerClass() { return { - 'timeline-entry-inner': true, - 'gl-pb-3': this.addPadding, + 'timeline-entry-inner': this.isNewDiscussion, }; }, timelineContentClass() { @@ -141,8 +134,7 @@ export default { }, parentClass() { return { - 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap': !this - .isEditing, + 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap': !this.isEditing, }; }, isProjectArchived() { @@ -151,6 +143,18 @@ export default { canUpdate() { return this.workItem?.userPermissions?.updateWorkItem; }, + workItemState() { + return this.workItem?.state; + }, + commentButtonText() { + return this.isNewDiscussion ? __('Comment') : __('Reply'); + }, + timelineEntryClass() { + return this.isNewDiscussion + ? 'timeline-entry note-form' + : // eslint-disable-next-line @gitlab/require-i18n-strings + 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix'; + }, }, watch: { autofocus: { @@ -166,7 +170,6 @@ export default { async updateWorkItem(commentText) { this.isSubmitting = true; this.$emit('replying', commentText); - try { this.track('add_work_item_comment'); @@ -180,25 +183,56 @@ export default { }, }, update(store, createNoteData) { - if (createNoteData.data?.createNote?.errors?.length) { + const numErrors = createNoteData.data?.createNote?.errors?.length; + + if (numErrors) { + const { errors } = createNoteData.data.createNote; + + // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557 + // When a note only contains quick actions, + // additional "helpful" messages are embedded in the errors field. + // For instance, a note solely composed of "/assign @foobar" would + // return a message "Commands only Assigned @root." as an error on creation + // even though the quick action successfully executed. + if ( + numErrors === 2 && + errors[0].includes('Commands only') && + errors[1].includes('Command names') + ) { + return; + } + throw new Error(createNoteData.data?.createNote?.errors[0]); } }, }); - clearDraft(this.autosaveKey); + /** + * https://gitlab.com/gitlab-org/gitlab/-/issues/388314 + * + * Once form is successfully submitted, emit replied event, + * mark isSubmitting to false and clear storage before hiding the form. + * This will restrict comment form to restore the value while textarea + * input triggered due to keyboard event meta+enter. + * + */ this.$emit('replied'); + clearDraft(this.autosaveKey); this.cancelEditing(); } catch (error) { this.$emit('error', error.message); Sentry.captureException(error); + } finally { + this.isSubmitting = false; } - - this.isSubmitting = false; }, cancelEditing() { - this.isEditing = false; + this.isEditing = this.isNewDiscussion; this.$emit('cancelEditing'); }, + showReplyForm() { + this.isEditing = true; + this.$emit('startReplying'); + }, }, }; </script> @@ -212,9 +246,6 @@ export default { :is-project-archived="isProjectArchived" /> <div v-else :class="timelineEntryInnerClass"> - <div class="timeline-avatar gl-float-left"> - <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> - </div> <div :class="timelineContentClass"> <div :class="parentClass"> <work-item-comment-form @@ -223,15 +254,27 @@ export default { :aria-label="__('Add a reply')" :is-submitting="isSubmitting" :autosave-key="autosaveKey" + :is-new-discussion="isNewDiscussion" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :work-item-state="workItemState" + :work-item-id="workItemId" + :autofocus="autofocus" + :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" /> - <gl-button + <textarea v-else - class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" - @click="isEditing = true" - >{{ __('Add a reply') }}</gl-button - > + ref="textarea" + rows="1" + class="reply-placeholder-text-field gl-font-regular!" + data-testid="note-reply-textarea" + :placeholder="__('Reply')" + :aria-label="__('Reply to comment')" + @focus="showReplyForm" + @click="showReplyForm" + ></textarea> </div> </div> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index a3ebd51f76d..f9f24366725 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -1,11 +1,22 @@ <script> import { GlButton } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __ } from '~/locale'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + STATE_OPEN, + STATE_EVENT_REOPEN, + STATE_EVENT_CLOSE, + TRACKING_CATEGORY_SHOW, + i18n, +} from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; export default { constantOptions: { @@ -15,8 +26,13 @@ export default { GlButton, MarkdownEditor, }, + mixins: [Tracking.mixin()], inject: ['fullPath'], props: { + workItemId: { + type: String, + required: true, + }, workItemType: { type: String, required: true, @@ -44,20 +60,44 @@ export default { required: false, default: __('Comment'), }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, + workItemState: { + type: String, + required: false, + default: STATE_OPEN, + }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { commentText: getDraft(this.autosaveKey) || this.initialValue || '', + updateInProgress: false, }; }, computed: { - markdownPreviewPath() { - return joinPaths( - '/', - gon.relative_url_root || '', - this.fullPath, - `/preview_markdown?target_type=${this.workItemType}`, - ); + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_task_status', + property: `type_${this.workItemType}`, + }; }, formFieldProps() { return { @@ -67,11 +107,30 @@ export default { name: 'work-item-add-or-edit-comment', }; }, + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + toggleWorkItemStateText() { + return this.isWorkItemOpen + ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }) + : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }); + }, + cancelButtonText() { + return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); + }, }, methods: { setCommentText(newText) { - this.commentText = newText; - updateDraft(this.autosaveKey, this.commentText); + /** + * https://gitlab.com/gitlab-org/gitlab/-/issues/388314 + * + * While the form is saving using meta+enter, + * avoid updating the data which is cleared after form submission. + */ + if (!this.isSubmitting) { + this.commentText = newText; + updateDraft(this.autosaveKey, this.commentText); + } }, async cancelEditing() { if (this.commentText && this.commentText !== this.initialValue) { @@ -91,23 +150,68 @@ export default { this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, + async toggleWorkItemState() { + const input = { + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, + }; + + this.updateInProgress = true; + + try { + this.track('updated_state'); + + const { mutation, variables } = getUpdateWorkItemMutation({ + workItemParentId: this.workItemParentId, + input, + }); + + const { data } = await this.$apollo.mutate({ + mutation, + variables, + }); + + const errors = data.workItemUpdate?.errors; + + if (errors?.length) { + this.$emit('error', i18n.updateError); + } + } catch (error) { + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + + this.$emit('error', msg); + Sentry.captureException(error); + } + + this.updateInProgress = false; + }, + cancelButtonAction() { + if (this.isNewDiscussion) { + this.toggleWorkItemState(); + } else { + this.cancelEditing(); + } + }, }, }; </script> <template> - <div class="timeline-discussion-body"> - <div class="note-body"> + <div class="timeline-discussion-body gl-overflow-visible!"> + <div class="note-body gl-p-0! gl-overflow-visible!"> <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> <markdown-editor :value="commentText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :autocomplete-data-sources="autocompleteDataSources" :form-field-props="formFieldProps" + :add-spacing-classes="false" data-testid="work-item-add-comment" class="gl-mb-3" - autofocus use-bottom-toolbar + supports-quick-actions + :autofocus="autofocus" @input="setCommentText" @keydown.meta.enter="$emit('submitForm', commentText)" @keydown.ctrl.enter="$emit('submitForm', commentText)" @@ -117,6 +221,7 @@ export default { category="primary" variant="confirm" data-testid="confirm-button" + :disabled="!commentText.length" :loading="isSubmitting" @click="$emit('submitForm', commentText)" >{{ commentButtonText }} @@ -125,8 +230,9 @@ export default { data-testid="cancel-button" category="primary" class="gl-ml-3" - @click="cancelEditing" - >{{ __('Cancel') }} + :loading="updateInProgress" + @click="cancelButtonAction" + >{{ cancelButtonText }} </gl-button> </form> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index 1e08fecaf3d..21fc8f99366 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -54,6 +54,25 @@ export default { required: false, default: false, }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + canSetWorkItemMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -117,8 +136,7 @@ export default { this.isExpanded = !this.isExpanded; }, threadKey(note) { - /* eslint-disable @gitlab/require-i18n-strings */ - return `${note.id}-thread`; + return `${note.id}-thread`; // eslint-disable-line @gitlab/require-i18n-strings }, onReplied() { this.isExpanded = true; @@ -142,7 +160,15 @@ export default { :has-replies="hasReplies" :work-item-type="workItemType" :is-modal="isModal" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" :class="{ 'gl-mb-4': hasReplies }" + :assignees="assignees" + :can-set-work-item-metadata="canSetWorkItemMetadata" + :work-item-id="workItemId" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', note)" @error="$emit('error', $event)" @@ -167,6 +193,14 @@ export default { :work-item-type="workItemType" :is-modal="isModal" :class="{ 'gl-mb-4': hasReplies }" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :assignees="assignees" + :work-item-id="workItemId" + :can-set-work-item-metadata="canSetWorkItemMetadata" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', note)" @error="$emit('error', $event)" @@ -186,6 +220,14 @@ export default { :note="reply" :work-item-type="workItemType" :is-modal="isModal" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :assignees="assignees" + :work-item-id="workItemId" + :can-set-work-item-metadata="canSetWorkItemMetadata" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', reply)" @error="$emit('error', $event)" @@ -204,6 +246,9 @@ export default { :work-item-type="workItemType" :sort-order="sortOrder" :add-padding="true" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + @startReplying="showReplyForm" @cancelEditing="hideReplyForm" @replied="onReplied" @replying="onReplying" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue index 07e25312f87..e7a80bf39fb 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue @@ -30,7 +30,9 @@ export default { <template> <li class="timeline-entry note note-wrapper discussion-filter-note"> - <div class="timeline-icon gl-display-none gl-lg-display-flex"> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > <gl-icon name="comment" /> </div> <div class="timeline-content gl-pl-8"> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index dcb6557600e..b8911592f5d 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -1,27 +1,26 @@ <script> -import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; +import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants'; +import Tracking from '~/tracking'; import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import { renderMarkdown } from '~/notes/utils'; import { getLocationHash } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getWorkItemQuery } from '~/work_items/utils'; import EditedAt from '~/issues/show/components/edited.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; import WorkItemCommentForm from './work_item_comment_form.vue'; export default { name: 'WorkItemNoteThread', - i18n: { - moreActionsText: __('More actions'), - deleteNoteText: __('Delete comment'), - copyLinkText: __('Copy link'), - }, components: { TimelineEntryItem, NoteBody, @@ -29,15 +28,28 @@ export default { NoteActions, GlAvatar, GlAvatarLink, - GlDropdown, - GlDropdownItem, WorkItemCommentForm, EditedAt, }, - directives: { - GlTooltip: GlTooltipDirective, - }, + mixins: [Tracking.mixin()], props: { + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, + workItemId: { + type: String, + required: true, + }, note: { type: Object, required: true, @@ -61,6 +73,25 @@ export default { required: false, default: false, }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + canSetWorkItemMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -68,6 +99,13 @@ export default { }; }, computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_note_actions', + property: `type_${this.workItemType}`, + }; + }, author() { return this.note.author; }, @@ -111,6 +149,28 @@ export default { hasAwardEmojiPermission() { return this.note.userPermissions.awardEmoji; }, + isAuthorAnAssignee() { + return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length); + }, + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, }, methods: { showReplyForm() { @@ -121,8 +181,8 @@ export default { updateDraft(this.autosaveKey, this.note.body); }, async updateNote(newText) { - this.isEditing = false; try { + this.isEditing = false; await this.$apollo.mutate({ mutation: updateWorkItemNoteMutation, variables: { @@ -149,12 +209,68 @@ export default { Sentry.captureException(error); } }, + getNewAssigneesAndWidget() { + let newAssignees = []; + if (this.isAuthorAnAssignee) { + newAssignees = this.assignees.filter(({ id }) => id !== this.author.id); + } else { + newAssignees = [...this.assignees, this.author]; + } + + const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES; + + const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget); + + const editedWorkItemWidgets = [...this.workItem.widgets]; + + editedWorkItemWidgets[assigneesWidgetIndex] = { + ...editedWorkItemWidgets[assigneesWidgetIndex], + assignees: { + nodes: newAssignees, + }, + }; + + return { + newAssignees, + editedWorkItemWidgets, + }; + }, notifyCopyDone() { if (this.isModal) { navigator.clipboard.writeText(this.noteUrl); } toast(__('Link copied to clipboard.')); }, + async assignUserAction() { + const { newAssignees, editedWorkItemWidgets } = this.getNewAssigneesAndWidget(); + + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + assigneesWidget: { + assigneeIds: newAssignees.map(({ id }) => id), + }, + }, + }, + optimisticResponse: { + workItemUpdate: { + errors: [], + workItem: { + ...this.workItem, + widgets: editedWorkItemWidgets, + }, + }, + }, + }); + this.track(`${this.isAuthorAnAssignee ? 'unassigned_user' : 'assigned_user'}`); + } catch (error) { + this.$emit('error', i18n.updateError); + Sentry.captureException(error); + } + }, }, }; </script> @@ -179,6 +295,11 @@ export default { :autosave-key="autosaveKey" :initial-value="note.body" :comment-button-text="__('Save comment')" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :work-item-id="workItemId" + :autofocus="isEditing" + class="gl-pl-3 gl-mt-3" @cancelEditing="isEditing = false" @submitForm="updateNote" /> @@ -199,32 +320,15 @@ export default { :show-reply="showReply" :show-edit="hasAdminPermission" :note-id="note.id" + :is-author-an-assignee="isAuthorAnAssignee" + :show-assign-unassign="canSetWorkItemMetadata" @startReplying="showReplyForm" @startEditing="startEditing" @error="($event) => $emit('error', $event)" + @notifyCopyDone="notifyCopyDone" + @deleteNote="$emit('deleteNote')" + @assignUser="assignUserAction" /> - <gl-dropdown - v-gl-tooltip - icon="ellipsis_v" - text-sr-only - right - :text="$options.i18n.moreActionsText" - :title="$options.i18n.moreActionsText" - category="tertiary" - no-caret - > - <gl-dropdown-item :data-clipboard-text="noteUrl" @click="notifyCopyDone"> - <span>{{ $options.i18n.copyLinkText }}</span> - </gl-dropdown-item> - <gl-dropdown-item - v-if="hasAdminPermission" - variant="danger" - data-testid="delete-note-action" - @click="$emit('deleteNote')" - > - {{ $options.i18n.deleteNoteText }} - </gl-dropdown-item> - </gl-dropdown> </div> </div> <div class="timeline-discussion-body"> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue index 6bea7953698..624a532c2aa 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { __, s__ } from '~/locale'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; @@ -10,11 +10,18 @@ export default { name: 'WorkItemNoteActions', i18n: { editButtonText: __('Edit comment'), + moreActionsText: __('More actions'), + deleteNoteText: __('Delete comment'), + copyLinkText: __('Copy link'), + assignUserText: __('Assign to commenting user'), + unassignUserText: __('Unassign from commenting user'), }, components: { GlButton, GlIcon, ReplyButton, + GlDropdown, + GlDropdownItem, EmojiPicker: () => import('~/emoji/components/picker.vue'), }, directives: { @@ -39,6 +46,28 @@ export default { required: false, default: false, }, + noteUrl: { + type: String, + required: false, + default: '', + }, + isAuthorAnAssignee: { + type: Boolean, + required: false, + default: false, + }, + showAssignUnassign: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + assignUserActionText() { + return this.isAuthorAnAssignee + ? this.$options.i18n.unassignUserText + : this.$options.i18n.assignUserText; + }, }, methods: { async setAwardEmoji(name) { @@ -100,5 +129,39 @@ export default { :aria-label="$options.i18n.editButtonText" @click="$emit('startEditing')" /> + <gl-dropdown + v-gl-tooltip + data-testid="work-item-note-actions" + icon="ellipsis_v" + text-sr-only + right + :text="$options.i18n.moreActionsText" + :title="$options.i18n.moreActionsText" + category="tertiary" + no-caret + > + <gl-dropdown-item + data-testid="copy-link-action" + :data-clipboard-text="noteUrl" + @click="$emit('notifyCopyDone')" + > + <span>{{ $options.i18n.copyLinkText }}</span> + </gl-dropdown-item> + <gl-dropdown-item + v-if="showAssignUnassign" + data-testid="assign-note-action" + @click="$emit('assignUser')" + > + {{ assignUserActionText }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="showEdit" + variant="danger" + data-testid="delete-note-action" + @click="$emit('deleteNote')" + > + {{ $options.i18n.deleteNoteText }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue index db36b4e1bbe..3f5ff526e91 100644 --- a/app/assets/javascripts/work_items/components/widget_wrapper.vue +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -1,11 +1,12 @@ <script> -import { GlAlert, GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { GlAlert, GlButton, + GlLink, }, props: { error: { @@ -42,7 +43,10 @@ export default { </script> <template> - <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"> + <div + id="tasks" + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + > <div class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base" :class="{ @@ -50,9 +54,15 @@ export default { }" > <div class="gl-display-flex gl-flex-grow-1"> - <h5 class="gl-m-0 gl-line-height-24"> + <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24"> + <gl-link + id="user-content-tasks-links" + class="anchor position-absolute gl-text-decoration-none" + href="#tasks" + aria-hidden="true" + /> <slot name="header"></slot> - </h5> + </h3> <slot name="header-suffix"></slot> </div> <slot name="header-right"></slot> 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 3c56b627673..0e0c6bca802 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -2,33 +2,53 @@ import { GlDropdown, GlDropdownItem, + GlDropdownForm, GlDropdownDivider, GlModal, GlModalDirective, + GlToggle, } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import toast from '~/vue_shared/plugins/global_toast'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { sprintfWorkItem, I18N_WORK_ITEM_DELETE, I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, + TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, + TEST_ID_NOTIFICATIONS_TOGGLE_FORM, + TEST_ID_DELETE_ACTION, + WIDGET_TYPE_NOTIFICATIONS, } from '../constants'; +import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql'; export default { i18n: { enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), + notifications: s__('WorkItem|Notifications'), + notificationOn: s__('WorkItem|Notifications turned on.'), + notificationOff: s__('WorkItem|Notifications turned off.'), }, components: { GlDropdown, GlDropdownItem, + GlDropdownForm, GlDropdownDivider, GlModal, + GlToggle, }, directives: { GlModal: GlModalDirective, }, mixins: [Tracking.mixin({ label: 'actions_menu' })], + isLoggedIn: isLoggedIn(), + notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, + notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM, + confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + deleteActionTestId: TEST_ID_DELETE_ACTION, props: { workItemId: { type: String, @@ -60,8 +80,17 @@ export default { required: false, default: false, }, + subscribedToNotifications: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + initialSubscribed: this.subscribedToNotifications, + }; }, - emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'], computed: { i18n() { return { @@ -70,6 +99,16 @@ export default { }; }, }, + watch: { + subscribedToNotifications() { + /** + * To toggle the value if mutation fails, assign the + * subscribedToNotifications boolean value directly + * to data prop. + */ + this.initialSubscribed = this.subscribedToNotifications; + }, + }, methods: { handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); @@ -84,6 +123,56 @@ export default { this.track('cancel_delete_work_item'); } }, + toggleNotifications(subscribed) { + const inputVariables = { + id: this.workItemId, + notificationsWidget: { + subscribed, + }, + }; + this.$apollo + .mutate({ + mutation: updateWorkItemNotificationsMutation, + variables: { + input: inputVariables, + }, + optimisticResponse: { + workItemUpdate: { + errors: [], + workItem: { + id: this.workItemId, + widgets: [ + { + type: WIDGET_TYPE_NOTIFICATIONS, + subscribed, + __typename: 'WorkItemWidgetNotifications', + }, + ], + __typename: 'WorkItem', + }, + __typename: 'WorkItemUpdatePayload', + }, + }, + }) + .then( + ({ + data: { + workItemUpdate: { errors }, + }, + }) => { + if (errors?.length) { + throw new Error(errors[0]); + } + toast( + subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff, + ); + }, + ) + .catch((error) => { + this.updateError = error.message; + this.$emit('error', error.message); + }); + }, }, }; </script> @@ -99,9 +188,27 @@ export default { no-caret right > + <template v-if="$options.isLoggedIn"> + <gl-dropdown-form + class="work-item-notifications-form" + :data-testid="$options.notificationsToggleFormTestId" + > + <div class="gl-px-5 gl-pb-2 gl-pt-1"> + <gl-toggle + v-model="initialSubscribed" + :label="$options.i18n.notifications" + :data-testid="$options.notificationsToggleTestId" + label-position="left" + label-id="notifications-toggle" + @change="toggleNotifications($event)" + /> + </div> + </gl-dropdown-form> + <gl-dropdown-divider /> + </template> <template v-if="canUpdate && !isParentConfidential"> <gl-dropdown-item - data-testid="confidentiality-toggle-action" + :data-testid="$options.confidentialityTestId" @click="handleToggleWorkItemConfidentiality" >{{ isConfidential @@ -114,7 +221,7 @@ export default { <gl-dropdown-item v-if="canDelete" v-gl-modal="'work-item-confirm-delete'" - data-testid="delete-action" + :data-testid="$options.deleteActionTestId" variant="danger" >{{ i18n.deleteWorkItem }}</gl-dropdown-item > 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 ddfaa376028..141dac9573c 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -55,7 +55,6 @@ export default { isSubmitting: false, isSubmittingWithKeydown: false, descriptionText: '', - descriptionHtml: '', conflictedDescription: '', formFieldProps: { 'aria-label': __('Description'), @@ -81,12 +80,7 @@ export default { }, result() { if (this.isEditing) { - if (this.descriptionText !== this.workItemDescription?.description) { - this.conflictedDescription = this.workItemDescription?.description; - } - } else { - this.descriptionText = this.workItemDescription?.description; - this.descriptionHtml = this.workItemDescription?.descriptionHtml; + this.checkForConflicts(); } }, error() { @@ -148,6 +142,11 @@ export default { }, }, methods: { + checkForConflicts() { + if (this.descriptionText !== this.workItemDescription?.description) { + this.conflictedDescription = this.workItemDescription?.description; + } + }, async startEditing() { this.isEditing = true; @@ -254,7 +253,7 @@ export default { :autocomplete-data-sources="autocompleteDataSources" enable-autocomplete supports-quick-actions - init-on-autofocus + autofocus @input="setDescriptionText" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" 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 ad7a54aaf16..06e8a65ecf7 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -26,6 +26,7 @@ import { i18n, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, + WIDGET_TYPE_NOTIFICATIONS, WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, @@ -39,7 +40,7 @@ import { WIDGET_TYPE_NOTES, } from '../constants'; -import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; +import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql'; import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql'; @@ -224,15 +225,18 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, + canSetWorkItemMetadata() { + return this.workItem?.userPermissions?.setWorkItemMetadata; + }, + canAssignUnassignUser() { + return this.workItemAssignees && this.canSetWorkItemMetadata; + }, confidentialTooltip() { return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); }, fullPath() { return this.workItem?.project.fullPath; }, - workItemsMvcEnabled() { - return this.glFeatures.workItemsMvc; - }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, @@ -268,6 +272,9 @@ export default { hasDescriptionWidget() { return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION); }, + workItemNotificationsSubscribed() { + return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed); + }, workItemAssignees() { return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, @@ -534,14 +541,13 @@ export default { {{ workItemBreadcrumbReference }} </li> </ul> - <work-item-type-icon - v-else-if="!error" - :work-item-icon-name="workItemIconName" - :work-item-type="workItemType && workItemType.toUpperCase()" - show-text - class="gl-font-weight-bold gl-text-secondary gl-mr-auto" - data-testid="work-item-type" - /> + <div v-else-if="!error" class="gl-mr-auto" data-testid="work-item-type"> + <work-item-type-icon + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType && workItemType.toUpperCase()" + /> + {{ workItemBreadcrumbReference }} + </div> <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> <gl-badge v-if="workItem.confidential" @@ -555,6 +561,7 @@ export default { <work-item-actions v-if="canUpdate || canDelete" :work-item-id="workItem.id" + :subscribed-to-notifications="workItemNotificationsSubscribed" :work-item-type="workItemType" :can-delete="canDelete" :can-update="canUpdate" @@ -705,12 +712,17 @@ export default { <work-item-notes v-if="workItemNotes" :work-item-id="workItem.id" + :work-item-iid="workItem.iid" :query-variables="queryVariables" :full-path="fullPath" :fetch-by-iid="fetchByIid" :work-item-type="workItemType" + :is-modal="isModal" + :assignees="workItemAssignees && workItemAssignees.assignees.nodes" + :can-set-work-item-metadata="canAssignUnassignUser" class="gl-pt-5" @error="updateError = $event" + @has-notes="updateHasNotes" /> <gl-empty-state v-if="error" 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 730bdb4e7c7..51b957bb852 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 @@ -188,7 +188,7 @@ export default { :work-item-parent-id="issueGid" :work-item-id="displayedWorkItemId" :work-item-iid="displayedWorkItemIid" - class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate" + class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate" @close="hide" @deleteWorkItem="deleteWorkItem" @update-modal="updateModal" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index d119cdc2785..d1866110fd4 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,11 +1,13 @@ <script> -import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; - +import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; +import * as Sentry from '@sentry/browser'; import { __, s__ } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; - +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import { STATE_OPEN, TASK_TYPE_NAME, @@ -24,6 +26,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLabel, GlLink, GlButton, GlIcon, @@ -68,9 +71,18 @@ export default { isExpanded: false, children: [], isLoadingChildren: false, + activeToast: null, + childrenBeforeRemoval: [], + hasChildren: false, }; }, computed: { + labels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; + }, + allowsScopedLabels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; + }, canHaveChildren() { return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; }, @@ -113,9 +125,6 @@ export default { this.childItem.iid }?iid_path=true`; }, - hasChildren() { - return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren; - }, chevronType() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, @@ -135,6 +144,17 @@ export default { return false; }, }, + watch: { + childItem: { + handler(val) { + this.hasChildren = this.getWidgetByType(val, WIDGET_TYPE_HIERARCHY)?.hasChildren; + }, + immediate: true, + }, + children(val) { + this.hasChildren = val?.length > 0; + }, + }, methods: { toggleItem() { this.isExpanded = !this.isExpanded; @@ -166,6 +186,72 @@ export default { this.isLoadingChildren = false; } }, + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + async removeChild(childId) { + this.cloneChildren(); + this.isLoadingChildren = true; + + try { + const { data } = await this.updateWorkItem(childId, null); + if (!data?.workItemUpdate?.errors?.length) { + this.filterRemovedChild(childId); + + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, childId), + }, + }); + } + } catch (error) { + this.showAlert(s__('WorkItem|Something went wrong while removing child.'), error); + Sentry.captureException(error); + this.restoreChildren(); + } finally { + this.isLoadingChildren = false; + } + }, + async undoChildRemoval(childId) { + this.isLoadingChildren = true; + try { + const { data } = await this.updateWorkItem(childId, this.childItem.id); + if (!data?.workItemUpdate?.errors?.length) { + this.activeToast?.hide(); + this.restoreChildren(); + } + } catch (error) { + this.showAlert(s__('WorkItem|Something went wrong while undoing child removal.'), error); + Sentry.captureException(error); + } finally { + this.activeToast?.hide(); + this.childrenBeforeRemoval = []; + this.isLoadingChildren = false; + } + }, + async updateWorkItem(childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + }); + }, + cloneChildren() { + this.childrenBeforeRemoval = cloneDeep(this.children); + }, + filterRemovedChild(childId) { + this.children = this.children.filter(({ id }) => id !== childId); + }, + restoreChildren() { + this.children = [...this.childrenBeforeRemoval]; + }, + showAlert(message, error) { + createAlert({ + message, + captureError: true, + error, + }); + }, }, }; </script> @@ -190,66 +276,72 @@ export default { @click="toggleItem" /> <div - class="work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-rounded-base" - :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']" + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base" data-testid="links-child" > - <span - :id="`stateIcon-${childItem.id}`" - class="gl-cursor-help gl-mr-3 gl-line-height-32" - :class="{ 'gl-display-flex': hasMetadata }" - data-testid="item-status-icon" - > - <gl-icon - class="gl-text-secondary" - :class="iconClass" - :name="iconName" - :aria-label="stateTimestampTypeText" - /> - </span> - <div - class="gl-display-flex gl-flex-grow-1" - :class="{ - 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, - 'gl-align-items-center': !hasMetadata, - }" - > - <div class="gl-display-flex"> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" + <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" + > + <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> + <span + :id="`stateIcon-${childItem.id}`" + class="gl-cursor-help" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <span v-if="childItem.confidential"> + <gl-icon + v-gl-tooltip.top + name="eye-slash" + class="gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + </span> + <gl-link + :href="childPath" + class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :metadata-widgets="metadataWidgets" + class="gl-ml-6 ml-xl-0" /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" + </div> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" /> - <gl-link - :href="childPath" - class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" - data-testid="item-title" - @click="$emit('click', $event)" - @mouseover="$emit('mouseover')" - @mouseout="$emit('mouseout')" - > - {{ childItem.title }} - </gl-link> </div> - <work-item-link-child-metadata - v-if="hasMetadata" - :metadata-widgets="metadataWidgets" - class="gl-mt-1" - /> </div> - <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" - > + <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> <work-item-links-menu :work-item-id="childItem.id" :parent-work-item-id="issuableGid" @@ -266,7 +358,7 @@ export default { :work-item-id="issuableGid" :work-item-type="workItemType" :children="children" - @removeChild="fetchChildren" + @removeChild="removeChild" @click="$emit('click', $event)" /> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue index 80802cb3858..ddeac2b92ae 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -1,16 +1,14 @@ <script> -import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import { isScopedLabel } from '~/lib/utils/common_utils'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; -import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants'; +import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants'; export default { components: { - GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, @@ -33,12 +31,6 @@ export default { assignees() { return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || []; }, - labels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; - }, - allowsScopedLabels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; - }, assigneesCollapsedTooltip() { if (this.assignees.length > 2) { return sprintf(s__('WorkItem|%{count} more assignees'), { @@ -56,21 +48,16 @@ export default { return ''; }, }, - methods: { - showScopedLabel(label) { - return isScopedLabel(label) && this.allowsScopedLabels; - }, - }, }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <div class="gl-display-flex gl-md-justify-content-end gl-gap-3"> <slot></slot> <item-milestone v-if="milestone" :milestone="milestone" - class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + class="gl-display-flex gl-align-items-center gl-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" /> <gl-avatars-inline v-if="assignees.length" @@ -81,7 +68,6 @@ export default { badge-tooltip-prop="name" :badge-sr-only-text="assigneesCollapsedTooltip" :class="assigneesContainerClass" - class="gl-mr-5" > <template #avatar="{ avatar }"> <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> @@ -89,18 +75,6 @@ export default { </gl-avatar-link> </template> </gl-avatars-inline> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap"> - <gl-label - v-for="label in labels" - :key="label.id" - :title="label.title" - :background-color="label.color" - :description="label.description" - :scoped="showScopedLabel(label)" - class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" - tooltip-placement="top" - /> - </div> </div> </template> 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 index fb3ed7af736..53e8eedf060 100644 --- 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 @@ -11,8 +11,13 @@ export default { </script> <template> - <span class="gl-ml-5"> - <gl-dropdown category="tertiary" toggle-class="btn-icon btn-sm" :right="true"> + <div class="gl-ml-5"> + <gl-dropdown + category="tertiary" + toggle-class="btn-icon btn-sm" + :right="true" + data-testid="work_items_links_menu" + > <template #button-content> <gl-icon name="ellipsis_v" :size="14" /> </template> @@ -20,5 +25,5 @@ export default { {{ s__('WorkItem|Remove') }} </gl-dropdown-item> </gl-dropdown> - </span> + </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 97eaf2c0422..b72de98199e 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -186,7 +186,7 @@ export default { </template> <template #body> <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> - <p class="gl-mb-3"> + <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue index e233a2219fa..ba5c0794395 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -1,9 +1,4 @@ <script> -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; - -import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; - export default { components: { WorkItemLinkChild: () => import('./work_item_link_child.vue'), @@ -32,28 +27,11 @@ export default { required: true, }, }, - methods: { - async updateWorkItem(childId) { - try { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, - }); - this.$emit('removeChild'); - } catch (error) { - createAlert({ - message: s__('Hierarchy|Something went wrong while removing a child item.'), - captureError: true, - error, - }); - } - }, - }, }; </script> <template> - <div class="gl-ml-6"> + <div class="gl-ml-6" data-testid="tree-children"> <work-item-link-child v-for="child in children" :key="child.id" @@ -62,7 +40,7 @@ export default { :issuable-gid="workItemId" :child-item="child" :work-item-type="workItemType" - @removeChild="updateWorkItem" + @removeChild="$emit('removeChild', child.id)" @click="$emit('click', Object.assign($event, { childItem: child }))" /> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 4ca8054fa5f..00cdc224320 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,6 +1,7 @@ <script> import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; @@ -14,7 +15,11 @@ import { WORK_ITEM_NOTES_FILTER_ONLY_HISTORY, } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; -import { getWorkItemNotesQuery } from '~/work_items/utils'; +import { + getWorkItemNotesQuery, + autocompleteDataSources, + markdownPreviewPath, +} from '~/work_items/utils'; import { updateCacheAfterCreatingNote, updateCacheAfterDeletingNote, @@ -48,6 +53,10 @@ export default { type: String, required: true, }, + workItemIid: { + type: String, + required: true, + }, queryVariables: { type: Object, required: true, @@ -70,6 +79,16 @@ export default { required: false, default: false, }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + canSetWorkItemMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -78,6 +97,7 @@ export default { sortOrder: ASC, noteToDelete: null, discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES, + addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`), }; }, computed: { @@ -102,6 +122,12 @@ export default { formAtTop() { return this.sortOrder === DESC; }, + markdownPreviewPath() { + return markdownPreviewPath(this.fullPath, this.workItemIid); + }, + autocompleteDataSources() { + return autocompleteDataSources(this.fullPath, this.workItemIid); + }, workItemCommentFormProps() { return { queryVariables: this.queryVariables, @@ -110,6 +136,9 @@ export default { fetchByIid: this.fetchByIid, workItemType: this.workItemType, sortOrder: this.sortOrder, + isNewDiscussion: true, + markdownPreviewPath: this.markdownPreviewPath, + autocompleteDataSources: this.autocompleteDataSources, }; }, notesArray() { @@ -252,6 +281,9 @@ export default { filterDiscussions(filterValue) { this.discussionFilter = filterValue; }, + updateKey() { + this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`); + }, async fetchMoreNotes() { this.isLoadingMore = true; // copied from discussions batch logic - every fetchMore call has a higher @@ -335,12 +367,17 @@ export default { </div> <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!"> <template v-if="!initialLoading"> - <ul class="notes main-notes-list timeline gl-clearfix!"> - <work-item-add-note - v-if="formAtTop && !commentsDisabled" - v-bind="workItemCommentFormProps" - @error="$emit('error', $event)" - /> + <div v-if="formAtTop && !commentsDisabled" class="js-comment-form"> + <ul class="notes notes-form timeline"> + <work-item-add-note + v-bind="workItemCommentFormProps" + :key="addNoteKey" + @cancelEditing="updateKey" + @error="$emit('error', $event)" + /> + </ul> + </div> + <ul class="notes main-notes-list timeline"> <template v-for="discussion in notesArray"> <system-note v-if="isSystemNote(discussion)" @@ -357,23 +394,31 @@ export default { :fetch-by-iid="fetchByIid" :work-item-type="workItemType" :is-modal="isModal" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :assignees="assignees" + :can-set-work-item-metadata="canSetWorkItemMetadata" @deleteNote="showDeleteNoteModal($event, discussion)" @error="$emit('error', $event)" /> </template> </template> - <work-item-add-note - v-if="!formAtTop && !commentsDisabled" - v-bind="workItemCommentFormProps" - @error="$emit('error', $event)" - /> - <work-item-history-only-filter-note v-if="commentsDisabled" @changeFilter="filterDiscussions" /> </ul> + <div v-if="!formAtTop && !commentsDisabled" class="js-comment-form"> + <ul class="notes notes-form timeline"> + <work-item-add-note + v-bind="workItemCommentFormProps" + :key="addNoteKey" + @cancelEditing="updateKey" + @error="$emit('error', $event)" + /> + </ul> + </div> </template> <template v-if="showLoadingMoreSkeleton"> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index bbcf78e23aa..6af4f0fe790 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -14,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS'; export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; @@ -205,3 +206,8 @@ export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [ { key: DESC, text: __('Newest first'), testid: 'newest-first' }, { key: ASC, text: __('Oldest first') }, ]; + +export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action'; +export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action'; +export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form'; +export const TEST_ID_DELETE_ACTION = 'delete-action'; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index fda71fabe22..40fb0fbc91d 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -15,6 +15,10 @@ extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } +extend type WorkItemPermissions { + setWorkItemMetadata: Boolean +} + input LocalUserInput { id: ID! name: String diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql new file mode 100644 index 00000000000..f8952b62f28 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql @@ -0,0 +1,13 @@ +mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) { + workItemUpdate(input: $input) { + workItem { + id + widgets { + ... on WorkItemWidgetNotifications { + type + subscribed + } + } + } + } +} 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 ada9f737e6e..86640a6d994 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -27,6 +27,7 @@ fragment WorkItem on WorkItem { userPermissions { deleteWorkItem updateWorkItem + setWorkItemMetadata @client } widgets { ...WorkItemWidgets diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index b5d27231bef..44fda3ee894 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { } } } + + ... on WorkItemWidgetNotifications { + type + subscribed + } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index bf8eafe3211..8039ef53f98 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetNotes { type } + ... on WorkItemWidgetNotifications { + type + subscribed + } } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 95709b36594..7c47e72e170 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -18,7 +18,7 @@ export const initWorkItemsRoot = () => { hasIterationsFeature, hasOkrsFeature, hasIssuableHealthStatusFeature, - savedRepliesNewPath, + newCommentTemplatePath, } = el.dataset; return new Vue({ @@ -36,7 +36,7 @@ export const initWorkItemsRoot = () => { signInPath, hasIterationsFeature: parseBoolean(hasIterationsFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), - newSavedRepliesPath: savedRepliesNewPath, + newCommentTemplatePath, }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 1aa3baca165..ccb9d05bc90 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - // Zen Mode (full screen) textarea // /*= provides zen_mode:enter */ @@ -55,6 +53,7 @@ export default class ZenMode { $(document).on('zen_mode:leave', () => { this.exit(); }); + // eslint-disable-next-line consistent-return $(document).on('keydown', (e) => { // Esc if (e.keyCode === 27) { diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 1a998f89c68..483c4dc226b 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,12 +1,10 @@ @import './pages/colors'; @import './pages/commits'; -@import './pages/detail_page'; @import './pages/events'; @import './pages/groups'; @import './pages/hierarchy'; @import './pages/issues'; @import './pages/labels'; -@import './pages/login'; @import './pages/merge_requests'; @import './pages/note_form'; @import './pages/notes'; @@ -15,4 +13,3 @@ @import './pages/projects'; @import './pages/registry'; @import './pages/settings'; -@import './pages/storage_quota'; diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss index 7736f1012a5..de8142924f9 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/components/detail_page.scss @@ -1,4 +1,5 @@ .detail-page-header { + padding-top: $gl-spacing-scale-4; color: $gl-text-color; line-height: 34px; display: flex; @@ -59,7 +60,7 @@ .detail-page-description { .title { - margin: 0 0 16px; + margin: 0 0 $gl-spacing-scale-4; color: $gl-text-color; padding: 0 0 0.3em; border-bottom: 1px solid $white-dark; diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 0b30b4c3ef0..04a7590d531 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -55,16 +55,16 @@ $item-remove-button-space: 42px; .item-weight .board-card-info-icon { min-width: $gl-padding; cursor: help; + + &:focus { + @include gl-focus; + } } .confidential-icon { color: $orange-500; } - .item-title-wrapper { - max-width: calc(100% - #{$item-remove-button-space}); - } - .item-title { flex-basis: 100%; font-size: $gl-font-size-small; diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index c1c68f64d86..35c619a2e2f 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -1,5 +1,5 @@ .whats-new-drawer { - margin-top: $header-height; + margin-top: calc(#{$header-height} + #{$calc-application-bars-height}); @include gl-shadow-none; overflow-y: hidden; width: 500px; @@ -35,18 +35,6 @@ } } -.with-performance-bar .whats-new-drawer { - margin-top: calc(#{$performance-bar-height} + #{$header-height}); -} - -.with-system-header .whats-new-drawer { - margin-top: calc(#{$system-header-height} + #{$header-height}); -} - -.with-performance-bar.with-system-header .whats-new-drawer { - margin-top: calc(#{$performance-bar-height} + #{$system-header-height} + #{$header-height}); -} - .whats-new-item-title-link { &:hover, &:focus, diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 799777977ed..cbdc55d66c1 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -29,10 +29,6 @@ border-bottom: 1px solid $white-dark; color: $gl-text-color; - &.oneline-block { - line-height: 42px; - } - &.white { background-color: $white; } @@ -89,10 +85,6 @@ padding: $gl-padding 0; border-bottom: 1px solid $white-dark; - &.oneline-block { - line-height: 36px; - } - > .controls { float: right; } diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 27e9a041145..65e378a79f3 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -20,56 +20,3 @@ } } } - -.pika-single.gitlab-theme { - .pika-label { - color: $gl-text-color-secondary; - font-size: 14px; - font-weight: $gl-font-weight-normal; - } - - th { - padding: 2px 0; - color: $note-disabled-comment-color; - font-weight: $gl-font-weight-normal; - text-transform: lowercase; - border-top: 1px solid $calendar-border-color; - } - - abbr { - cursor: default; - } - - td { - border: 1px solid $calendar-border-color; - - &:first-child { - border-left: 0; - } - - &:last-child { - border-right: 0; - } - } - - .pika-day { - border-radius: 0; - background-color: $white; - text-align: center; - } - - .is-today { - .pika-day { - color: inherit; - font-weight: $gl-font-weight-normal; - } - } - - .is-selected .pika-day, - .pika-day:hover, - .is-today .pika-day { - background: $gray-darker; - color: $gl-text-color; - box-shadow: none; - } -} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index cc7a45e1c82..d033a076832 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -1,3 +1,32 @@ +// stylelint-disable length-zero-no-unit +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} + +.with-performance-bar { + --performance-bar-height: #{$performance-bar-height}; +} + +.with-system-header { + --system-header-height: #{$system-header-height}; +} + +.with-top-bar { + --top-bar-height: #{$top-bar-height}; +} + +.with-system-footer { + --system-footer-height: #{$system-footer-height}; +} + +.review-bar-visible { + --mr-review-bar-height: #{$mr-review-bar-height}; +} + /** COLORS **/ .cgray { color: $gl-text-color; } .clgray { color: $gray-200; } @@ -253,12 +282,6 @@ li.note { } } -img.emoji { - height: 16px; - vertical-align: top; - width: 20px; -} - .chart { overflow: hidden; height: 220px; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 1e05441c731..fb9816d1402 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -229,13 +229,13 @@ // .nav-sidebar { - @include gl-fixed; - @include gl-bottom-0; - @include gl-left-0; + position: fixed; + bottom: $calc-application-footer-height; + left: 0; transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium; z-index: 600; width: $contextual-sidebar-width; - top: $header-height; + top: $calc-application-header-height; background-color: $contextual-sidebar-bg-color; border-right: 1px solid $contextual-sidebar-border-color; transform: translate3d(0, 0, 0); diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index cd0ea84cff4..ad09740583b 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -32,30 +32,12 @@ } @media (min-width: map-get($grid-breakpoints, md)) { - // The `+11` is to ensure the file header border shows when scrolled - - // the bottom of the compare-versions header and the top of the file header - --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); - --top: var(--initial-top); - - position: -webkit-sticky; position: sticky; - top: var(--top); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height}); z-index: 120; &.is-sidebar-moved { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px}); - } - - .with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height}); - } - - .with-system-header.with-performance-bar & { - --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); - } - - .with-performance-bar & { - top: calc(var(--initial-top) + #{$performance-bar-height}); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + 24px); } &::before { @@ -70,19 +52,11 @@ } &.is-commit { - top: calc(#{$header-height} + #{$commit-stat-summary-height}); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$commit-stat-summary-height} + #{$performance-bar-height}); - } + top: calc(#{$calc-application-header-height} + #{$commit-stat-summary-height}); } &.is-compare { - top: calc(#{$header-height} + #{$compare-branches-sticky-header-height}); - - .with-performance-bar & { - top: calc(#{$performance-bar-height} + #{$header-height} + #{$compare-branches-sticky-header-height}); - } + top: calc(#{$calc-application-header-height} + #{$compare-branches-sticky-header-height}); } } @@ -99,22 +73,7 @@ @media (min-width: map-get($grid-breakpoints, md)) { &.conflict .file-title, &.conflict .file-title-flex-parent { - top: $header-height; - } - - .with-performance-bar &.conflict .file-title, - .with-performance-bar &.conflict .file-title-flex-parent { - top: calc(#{$header-height} + #{$performance-bar-height}); - } - - .with-system-header &.conflict .file-title, - .with-system-header &.conflict .file-title-flex-parent { - top: calc(#{$header-height} + #{$system-header-height}); - } - - .with-system-header.with-performance-bar &.conflict .file-title, - .with-system-header.with-performance-bar &.conflict .file-title-flex-parent { - top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); + top: $calc-application-header-height; } } @@ -733,13 +692,9 @@ table.code { @include media-breakpoint-up(sm) { @include gl-sticky; - top: $header-height; + top: $calc-application-header-height; z-index: 200; - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}); - } - &.is-stuck { @include gl-py-0; border-top: 1px solid $white-dark; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index b292adf9eac..e4025eb8b8d 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -2,14 +2,6 @@ * File content holder * */ -.container-fluid.container-limited.limit-container-width { - .file-holder.readme-holder.limited-width-container .file-content { - max-width: $limited-layout-width; - margin-left: auto; - margin-right: auto; - } -} - .file-holder { border: 1px solid $border-color; border-top: 0; @@ -484,18 +476,24 @@ span.idiff { @include gl-display-none; } -.tree-list-scroll:not(.tree-list-blobs) { +.mr-tree-list:not(.tree-list-blobs) { .tree-list-parent::before { @include gl-content-empty; @include gl-absolute; @include gl-z-index-1; @include gl-pointer-events-none; - top: 28px; - left: calc(14px + (var(--level) * 16px)); - width: 1px; - height: calc(100% - 24px); - background-color: var(--gray-100, $gray-100); + top: -4px; + left: 0; + width: 100%; + bottom: -4px; + // The virtual scroller has a flat HTML structure so instead of the ::before + // element stretching over multiple rows we instead create a repeating background image + // for the line + background: repeating-linear-gradient(to right, var(--gray-100, $gray-100), var(--gray-100, $gray-100) 1px, transparent 1px, transparent 14px); + background-size: calc(var(--level) * 14px) 100%; + background-repeat: no-repeat; + background-position: 14px; } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 16c0a67f137..104cdf5544d 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -87,8 +87,8 @@ .filtered-search-term { display: flex; flex-shrink: 0; - margin-top: 4px; - margin-bottom: 4px; + margin-top: 2px; + margin-bottom: 2px; .selectable { display: flex; @@ -195,7 +195,7 @@ display: flex; width: 100%; min-width: 0; - border: 1px solid $border-color; + border: 1px solid $gray-400; background-color: $white; border-radius: $border-radius-default; @@ -206,8 +206,7 @@ &.focus, &.focus:hover { - border-color: $blue-300; - box-shadow: 0 0 4px $dropdown-input-focus-shadow; + @include gl-focus; } gl-emoji { @@ -227,7 +226,7 @@ min-width: 200px; padding-right: 25px; padding-left: 0; - height: $input-height; + height: #{$input-height - 2px}; line-height: inherit; &, @@ -261,7 +260,7 @@ flex: 1; position: relative; min-width: 0; - height: 2rem; + height: #{$input-height - 2px}; background-color: $input-bg; border-radius: $border-radius-default; } @@ -292,10 +291,11 @@ } .filtered-search-history-dropdown-toggle-button.gl-button { - border-radius: $border-radius-default 0 0 $border-radius-default; - border-right: 1px solid $border-color; - box-shadow: none; + $inner-border: #{$border-radius-default - 1px}; + border-radius: $inner-border 0 0 $inner-border; color: $gl-text-color-secondary; + margin: -1px 0 -1px -1px; + box-shadow: inset 0 0 0 1px $gray-400; flex: 1; transition: color 0.1s linear; width: auto; @@ -303,7 +303,6 @@ &:hover, &:focus { color: $gl-text-color; - border-color: $border-color; } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index b63365e8159..6b4f1478978 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -9,7 +9,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); &.sticky { position: sticky; - top: $flash-container-top; + top: $calc-application-header-height; z-index: 251; .flash-alert, @@ -114,17 +114,3 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); left: -50%; } } - -.with-system-header .flash-container.sticky { - top: $flash-container-top + $system-header-height; -} - -.with-performance-bar { - .flash-container.sticky { - top: $flash-container-top + $performance-bar-height; - } - - &.with-system-header .flash-container.sticky { - top: $flash-container-top + $performance-bar-height + $system-header-height; - } -} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index d1231da83d4..a5ff3c9c980 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -8,7 +8,7 @@ $search-input-field-x-min-width: 200px; min-height: $header-height; border: 0; position: fixed; - top: 0; + top: $calc-application-bars-height; left: 0; right: 0; border-radius: 0; @@ -312,21 +312,52 @@ $search-input-field-x-min-width: 200px; margin-top: $dropdown-vertical-offset; } -.breadcrumbs { - display: flex; - min-height: $breadcrumb-min-height; - color: $gl-text-color; +.top-bar-container { + min-height: $top-bar-height; } -.breadcrumbs-container { - display: flex; - width: 100%; - padding-top: $gl-padding / 2; - padding-bottom: $gl-padding / 2; - align-items: center; +.top-bar-fixed { + background-color: $body-bg; + left: 0; + position: fixed; + right: 0; + top: $calc-application-bars-height; + width: auto; + z-index: $top-bar-z-index; + @include gl-inset-border-b-1-gray-100; + + .breadcrumbs-list { + @include media-breakpoint-down(xs) { + flex-wrap: nowrap; + } + } + + @media (prefers-reduced-motion: no-preference) { + transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium; + } + + @include media-breakpoint-up(md) { + .right-sidebar-collapsed & { + right: $gutter-collapsed-width; + } + + .right-sidebar-expanded & { + right: $gutter-width; + } + } + + @include media-breakpoint-up(xl) { + .page-with-super-sidebar & { + left: $super-sidebar-width; + } + + .page-with-super-sidebar-collapsed & { + left: 0; + } + } } -.breadcrumbs-links { +.breadcrumbs { flex: 1; min-width: 0; align-self: center; @@ -348,16 +379,6 @@ $search-input-field-x-min-width: 200px; top: 1px; } } - - .dropdown-menu li a .identicon { - width: 17px; - height: 17px; - font-size: $gl-font-size-xs; - vertical-align: middle; - text-indent: 0; - line-height: $gl-font-size-xs + 2px; - display: inline-block; - } } .breadcrumbs-list { @@ -498,11 +519,6 @@ $search-input-field-x-min-width: 200px; visibility: visible; } -.with-performance-bar .navbar-gitlab, -.with-performance-bar .fixed-top { - top: $performance-bar-height; -} - .navbar-empty { justify-content: center; height: $header-height; @@ -558,7 +574,7 @@ $search-input-field-x-min-width: 200px; @include media-breakpoint-down(sm) { @include gl-display-block; - + .breadcrumbs-links { + + .breadcrumbs { @include gl-pl-4; @include gl-border-l-1; @include gl-border-l-solid; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index f27a36d1966..37a2264122d 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -74,7 +74,6 @@ } .user-avatar-link { - display: inline-block; text-decoration: none; } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 7a92adf7b7b..23dbe440d33 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -35,8 +35,9 @@ body { } } -.content-wrapper-margin { - margin-top: $header-height; +.layout-page { + padding-top: $calc-application-header-height; + padding-bottom: $calc-application-footer-height; } .content-wrapper { @@ -142,11 +143,6 @@ body { @include gl-overflow-hidden; } - -.with-performance-bar .layout-page { - margin-top: calc(#{$header-height} + #{$performance-bar-height}); -} - .fullscreen-layout { padding-top: 0; height: 100vh; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index c40cadafb9c..48aacc9606e 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -120,9 +120,10 @@ } .referenced-commands { + $radius: $border-radius-default - 1px; background: $blue-50; padding: $gl-padding-8 $gl-padding; - border-radius: $border-radius-default; + border-radius: 0 0 $radius $radius; p { margin: 0; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index b20ec1dc50a..aefac300839 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -223,18 +223,6 @@ } /* -* Mixin that handles the position of sticky alerts at the top. It accounts for the performance bar -*/ -// stylelint-disable-next-line length-zero-no-unit -@mixin sticky-top-positioning($extra: 0px) { - top: calc(#{$header-height} + #{$extra}); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height} + #{$extra}); - } -} - -/* * Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs) */ @mixin build-log($background: $black) { @@ -268,14 +256,8 @@ @mixin build-log-top-bar($height) { @include build-log-bar($height); - - position: -webkit-sticky; position: sticky; - top: $header-height; - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}); - } + top: $calc-application-header-height; } /* diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss index f11864f14af..84a34f12649 100644 --- a/app/assets/stylesheets/framework/page_title.scss +++ b/app/assets/stylesheets/framework/page_title.scss @@ -1,6 +1,6 @@ .page-title-holder { .page-title { - margin: $gl-padding 0; + margin: $gl-spacing-scale-4 0; color: $gl-text-color; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 7c3e346f4e6..946f2b28859 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -83,7 +83,7 @@ } .right-sidebar { - border-left: 1px solid $gray-100; + border-left: 1px solid $gray-50; &.right-sidebar-merge-requests { @include media-breakpoint-up(lg) { @@ -271,6 +271,24 @@ } } +.merge-request-approved-icon { + animation: approval-animate 350ms ease-in; +} + +@include keyframes(approval-animate) { + 0% { + transform: scale(0); + } + + 75% { + transform: scale(1.4); + } + + 100% { + transform: scale(1); + } +} + .assignee-grid, .reviewer-grid { [data-css-area='attention'] { @@ -288,21 +306,22 @@ @mixin right-sidebar { position: fixed; - top: $header-height; - // Default value for CSS var must contain a unit - // stylelint-disable-next-line length-zero-no-unit - bottom: var(--review-bar-height, 0px); + top: calc(#{$header-height} + #{$calc-application-bars-height}); + bottom: calc(#{$calc-application-footer-height} + var(--mr-review-bar-height)); right: 0; transition: width $gl-transition-duration-medium; background-color: $white; z-index: 200; overflow: hidden; - } .right-sidebar { &:not(.right-sidebar-merge-requests) { @include right-sidebar; + + @include media-breakpoint-down(sm) { + z-index: 251; + } } &.right-sidebar-merge-requests { @@ -312,10 +331,6 @@ } } - @include media-breakpoint-down(sm) { - z-index: 251; - } - a:not(.btn) { color: inherit; @@ -469,28 +484,14 @@ padding: 0; .issuable-context-form { - --initial-top: calc(#{$header-height} + 76px); - --top: var(--initial-top); - - @include gl-sticky; - @include gl-overflow-auto; + $issue-sticky-header-height: 76px; - top: var(--top); - height: calc(100vh - var(--top)); + top: calc(#{$calc-application-header-height} + #{$issue-sticky-header-height}); + height: calc(#{$calc-application-viewport-height} - #{$issue-sticky-header-height} - var(--mr-review-bar-height)); + position: sticky; + overflow: auto; padding: 0 15px; - margin-bottom: calc(var(--top) * -1); - - .with-performance-bar & { - --top: calc(var(--initial-top) + #{$performance-bar-height}); - } - - .with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height}); - } - - .with-performance-bar.with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); - } + margin-bottom: calc((#{$header-height} + $issue-sticky-header-height) * -1); } } } @@ -742,10 +743,6 @@ } } -.with-performance-bar .right-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height}); -} - .issuable-show-labels { .gl-label { margin-bottom: 5px; diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss index f9e95d16f63..91781bfe539 100644 --- a/app/assets/stylesheets/framework/sortable.scss +++ b/app/assets/stylesheets/framework/sortable.scss @@ -51,3 +51,12 @@ cursor: no-drop !important; } } + +.tree-item.is-dragging { + border-top: 0; + + .item-body { + background-color: $white; + border: 2px solid $gray-200; + } +} diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss index 046b8636f65..f1ee4c94942 100644 --- a/app/assets/stylesheets/framework/source_editor.scss +++ b/app/assets/stylesheets/framework/source_editor.scss @@ -41,6 +41,29 @@ } .monaco-editor.gl-source-editor { + // Fix unreadable headings in tooltips for syntax highlighting themes that don't match general theme + &.vs-dark .markdown-hover { + h1, + h2, + h3, + h4, + h5, + h6 { + color: $source-editor-hover-light-text-color; + } + } + + &.vs .markdown-hover { + h1, + h2, + h3, + h4, + h5, + h6 { + color: $source-editor-hover-dark-text-color; + } + } + .margin-view-overlays { .line-numbers { @include gl-display-flex; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 6b339f857cb..14eec335169 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -27,8 +27,8 @@ display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc(#{$header-height} + #{$calc-application-bars-height}); + bottom: $calc-application-footer-height; left: 0; background-color: var(--gray-10, $gray-10); border-right: 1px solid $t-gray-a-08; @@ -36,10 +36,6 @@ width: $super-sidebar-width; z-index: $super-sidebar-z-index; - &:focus { - @include gl-focus; - } - &.super-sidebar-loading { transform: translate3d(-100%, 0, 0); @@ -49,7 +45,9 @@ } &:not(.super-sidebar-loading) { - transition: transform $gl-transition-duration-medium; + @media (prefers-reduced-motion: no-preference) { + transition: transform $gl-transition-duration-medium; + } } .user-bar { @@ -78,8 +76,9 @@ } } - .counter .gl-icon { - color: var(--gray-500, $gray-500); + .counter .gl-icon, + .item-icon { + color: var(--gray-600, $gray-500); } .counter:hover, @@ -107,6 +106,9 @@ &[aria-expanded='true'] { background-color: $t-gray-a-08; } + + &:focus { + @include gl-focus($inset: true); } } .btn-with-notification { @@ -141,6 +143,29 @@ @include active-toggle; } } + + .nav-item-link { + button, + .draggable-icon { + opacity: 0; + } + + .draggable-icon { + cursor: grab; + } + + &:hover { + button, + .draggable-icon { + opacity: 1; + } + } + + &:focus button, + button:focus { + opacity: 1; + } + } } .super-sidebar-skip-to { @@ -151,9 +176,25 @@ display: none; } +.super-sidebar-peek { + @include gl-shadow; + border-right: 0; + + @media (prefers-reduced-motion: no-preference) { + transition: transform 100ms !important; + } +} + +.super-sidebar-hover-area { + z-index: $super-sidebar-z-index; +} + .page-with-super-sidebar { padding-left: 0; - transition: padding-left $gl-transition-duration-medium; + + @media (prefers-reduced-motion: no-preference) { + transition: padding-left $gl-transition-duration-medium; + } &:not(.page-with-super-sidebar-collapsed) { .super-sidebar-overlay { @@ -184,6 +225,10 @@ .page-with-super-sidebar-collapsed { .super-sidebar { transform: translate3d(-100%, 0, 0); + + &.super-sidebar-peek { + transform: translate3d(0, 0, 0); + } } @include media-breakpoint-up(xl) { @@ -195,19 +240,6 @@ } } -.container-limited .super-sidebar-toggle { - @media (min-width: $super-sidebar-toggle-position-breakpoint) { - position: absolute; - left: $gl-spacing-scale-3; - top: $gl-spacing-scale-3; - margin: 0; - } -} - -.with-performance-bar .super-sidebar { - top: $performance-bar-height; -} - .gl-dark { .super-sidebar { .gl-new-dropdown-custom-toggle { @@ -217,3 +249,38 @@ } } } + +.global-search-modal { + padding: 3rem 0.5rem 0; + + &.gl-modal .modal-dialog { + align-items: flex-start; + } + + @include gl-media-breakpoint-up(sm) { + padding: 5rem 1rem 0; + } + + // 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; + } + + .is-searching { + .in-search-scope-help { + position: absolute; + top: 0.625rem; + right: 2.5rem; + } + } + + .gl-search-box-by-type-input-borderless { + @include gl-rounded-base; + } + + .global-search-results { + max-height: 30rem; + } +} diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 590a66ff28e..946a241e6dd 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -36,57 +36,8 @@ } } -// System Header -.with-system-header { - // main navigation - // login page - .navbar-gitlab, - .fixed-top { - top: $system-header-height; - } - - // left sidebar eg: project page - // right sidebar eg: MR page - .nav-sidebar, - .super-sidebar, - .right-sidebar { - top: calc(#{$system-header-height} + #{$header-height}); - } - - .content-wrapper-margin { - margin-top: calc(#{$system-header-height} + #{$header-height}); - } - - // Performance Bar - // System Header - &.with-performance-bar { - // main navigation - header.navbar-gitlab, - .fixed-top { - top: $performance-bar-height + $system-header-height; - } - - .layout-page { - margin-top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); - } - - // left sidebar eg: project page - // right sidebar eg: MR page - .nav-sidebar, - .super-sidebar, - .right-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); - } - } -} - // System Footer .with-system-footer { - // left sidebar eg: project page - // right sidebar eg: mr page - .nav-sidebar, - .super-sidebar, - .right-sidebar, // navless pages' footer eg: login page // navless pages' footer border eg: login page &.devise-layout-html body .footer-container, @@ -94,13 +45,9 @@ bottom: $system-footer-height; } - .content-wrapper-margin { - margin-bottom: 16px; - } - .boards-list, .board-swimlanes { - height: calc(100vh - (#{$header-height} + #{$breadcrumb-min-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32})); + height: calc(100vh - (#{$header-height} + #{$top-bar-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32})); } } diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index a288701595e..b28a93749d1 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -160,3 +160,7 @@ table { border-top: 0; } } + +.gl-table-no-top-border th { + border-top: 0; +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 8edf5fc834a..88f990d2320 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -632,7 +632,7 @@ body { } .page-title { - margin: 0 0 #{2 * $grid-size}; + margin: $gl-spacing-scale-4 0; line-height: 1.3; &.with-button { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0bc2e0583bb..dba9cafbd71 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -11,9 +11,9 @@ $contextual-sidebar-width: 256px; $contextual-sidebar-collapsed-width: 56px; $toggle-sidebar-height: 48px; $super-sidebar-width: 256px; -$super-sidebar-toggle-position-breakpoint: 1360px; $super-sidebar-z-index: 600; $super-sidebar-overlay-z-index: 599; +$top-bar-z-index: 210; /** 🚨 Do not use this spacing scale — it is deprecated and being removed. 🚨 @@ -390,7 +390,6 @@ $nav-active-bg: $t-gray-a-08; * Text */ $gl-font-size: 14px; -$gl-font-size-xs: 11px; $gl-font-size-small: 12px; $gl-font-size-large: 16px; $gl-font-weight-normal: 400; @@ -481,10 +480,10 @@ $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; $system-header-height: 16px; $system-footer-height: $system-header-height; +$mr-review-bar-height: calc(2rem + 13px); $flash-height: 52px; -$flash-container-top: 48px; $context-header-height: 60px; -$breadcrumb-min-height: 48px; +$top-bar-height: 48px; $home-panel-title-row-height: 64px; $home-panel-avatar-mobile-size: 24px; $issuable-title-max-width: 350px; @@ -498,6 +497,14 @@ $gl-line-height-14: 14px; $pages-group-name-color: #4c4e54; /* + * Calculated heights + */ +$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height)); +$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height} + var(--top-bar-height)); +$calc-application-footer-height: var(--system-footer-height); +$calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height}); + +/* * Common component specific colors */ $user-mention-bg: rgba($blue-500, 0.044); @@ -669,8 +676,6 @@ $note-targe3-inside: #ffffd3; /* * Calendar */ -$calendar-hover-bg: #ecf3fe; -$calendar-border-color: rgba(#000, 0.1); $calendar-user-contrib-text: #959494; /* @@ -688,7 +693,7 @@ $issue-boards-filter-height: 68px; The following heights are used in environment_logs.scss and are used for calculation of the log viewer height. */ $environment-logs-breadcrumbs-height: 63px; -$environment-logs-breadcrumbs-height-md: $breadcrumb-min-height; +$environment-logs-breadcrumbs-height-md: $top-bar-height; $environment-logs-difference-xs-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height}); $environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height-md}); @@ -734,7 +739,7 @@ $calendar-activity-colors: ( */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); $commit-message-text-area-bg: rgba(0, 0, 0, 0); -$commit-stat-summary-height: 36px; +$commit-stat-summary-height: 32px; /* * Files @@ -908,13 +913,19 @@ $mr-tabs-height: 48px; /* Compare Branches */ -$compare-branches-sticky-header-height: 68px; +$compare-branches-sticky-header-height: 32px; /* Board Swimlanes */ $board-swimlanes-headers-height: 64px; +/* +Source Editor theme overrides +*/ +$source-editor-hover-light-text-color: #ececef; +$source-editor-hover-dark-text-color: #333238; + /** Bootstrap 4.2.0 introduced new icons for validating forms. Our design system does not use those, so we are disabling them for now: diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index ccb5d96e966..969a6665634 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -192,8 +192,8 @@ pre.code, } &.hll { - --highlight-border-color: #{$orange-200}; - background-color: $orange-50; + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; } } @@ -247,8 +247,8 @@ pre.code, } &.hll { - --highlight-border-color: #{$orange-200}; - background-color: $orange-50; + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; } } @@ -269,8 +269,8 @@ pre.code, } &.hll { - --highlight-border-color: #{$orange-200}; - background-color: $orange-50; + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; } } } diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 99e7f7ae0a4..f7ab78c1bcc 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -208,15 +208,11 @@ } .boards-sidebar { - top: $header-height !important; + top: $calc-application-header-height !important; height: auto; - bottom: 0; + bottom: $calc-application-footer-height; padding-bottom: 0.5rem; - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}) !important; - } - .sidebar-collapsed-icon { @include gl-display-none; } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index d40c03b7fd1..5114f484e53 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -6,30 +6,22 @@ } .archived-job { - top: $header-height; + top: $calc-application-header-height; border-radius: 2px 2px 0 0; color: var(--orange-600, $orange-600); background-color: var(--orange-50, $orange-50); border: 1px solid var(--border-color, $border-color); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}); - } } .top-bar { @include build-log-top-bar(50px); &.has-archived-block { - top: calc(#{$header-height} + 28px); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height} + 28px); - } + top: calc(#{$calc-application-header-height} + 28px); } &.affix { - top: $header-height; + top: $calc-application-header-height; // with sidebar &.sidebar-expanded { diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss index 143682e1cd7..f56eb4ae6fb 100644 --- a/app/assets/stylesheets/page_bundles/design_management.scss +++ b/app/assets/stylesheets/page_bundles/design_management.scss @@ -20,10 +20,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); .design-detail { background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity); - - .with-performance-bar & { - top: 35px; - } + bottom: $calc-application-footer-height; .comment-indicator { border-radius: 50%; diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 79595fa3a98..e0fb95a1359 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -66,7 +66,7 @@ .title { padding: 0; - margin-bottom: $gl-padding; + margin-bottom: $gl-spacing-scale-4; border-bottom: 0; word-wrap: break-word; overflow-wrap: break-word; diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss index b08e129a805..1ca0c5e7ce6 100644 --- a/app/assets/stylesheets/page_bundles/issuable_list.scss +++ b/app/assets/stylesheets/page_bundles/issuable_list.scss @@ -18,6 +18,11 @@ } } + .issuable-info, + .issuable-meta { + font-size: $gl-font-size-sm; + } + .issuable-meta { display: flex; flex-direction: column; diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/page_bundles/login.scss index 360ea20733d..495b7d58788 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/page_bundles/login.scss @@ -1,4 +1,5 @@ -@import 'framework/variables'; +@import 'mixins_and_variables_and_functions'; + /* Login Page */ .login-page { .container { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 396c590d912..97df87458ab 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -1,6 +1,5 @@ @import 'mixins_and_variables_and_functions'; -$mr-review-bar-height: calc(2rem + 13px); $mr-widget-margin-left: 40px; $mr-widget-min-height: 69px; $tabs-holder-z-index: 250; @@ -181,6 +180,10 @@ $tabs-holder-z-index: 250; .content + .content { @include gl-border-t; } + + .notes-content { + border: 0; + } } &.inline-diff-view { @@ -242,18 +245,6 @@ $tabs-holder-z-index: 250; } } -.with-system-header { - --system-header-height: #{$system-header-height}; -} - -.with-performance-bar { - --performance-bar-height: #{$performance-bar-height}; -} - -.review-bar-visible { - --review-bar-height: #{$mr-review-bar-height}; -} - .diff-tree-list { // This 11px value should match the additional value found in // /assets/stylesheets/framework/diffs.scss @@ -264,21 +255,19 @@ $tabs-holder-z-index: 250; // If they don't match, the file tree and the diff files stick // to the top at different heights, which is a bad-looking defect $diff-file-header-top: 11px; - --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); - --top-pos: var(--initial-pos); - position: -webkit-sticky; position: sticky; - top: calc(var(--top-pos) + var(--performance-bar-height, 0px)); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); min-height: 300px; - height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); + height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top})); .drag-handle { bottom: 16px; } &.is-sidebar-moved { - --top-pos: calc(var(--initial-pos) + 26px); + height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top} + 26px)); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top} + 26px); } } @@ -915,15 +904,6 @@ $tabs-holder-z-index: 250; &:not(:first-child) { margin-top: $gl-padding; } - - &:not(:last-child)::before { - content: ''; - border-left: 2px solid var(--border-color, $border-color); - position: absolute; - bottom: -17px; - left: 26px; - height: 16px; - } } .mr-version-controls { @@ -992,8 +972,8 @@ $tabs-holder-z-index: 250; .merge-request-overview { @include media-breakpoint-up(lg) { display: grid; - grid-template-columns: calc(95% - 285px) auto; - grid-gap: 5%; + grid-template-columns: calc(97% - 285px) auto; + grid-gap: 3%; } } @@ -1156,7 +1136,7 @@ $tabs-holder-z-index: 250; .review-bar-component { position: fixed; - bottom: 0; + bottom: $calc-application-footer-height; left: 0; z-index: $zindex-dropdown-menu; display: flex; @@ -1241,3 +1221,70 @@ $tabs-holder-z-index: 250; } } } + +.mr-state-loader { + svg { + vertical-align: middle; + } + + .gl-skeleton-loader { + max-width: 334px; + } +} + +.mr-system-note-icon { + width: 20px; + height: 20px; + margin-left: 6px; + + &.gl-bg-green-100 { + --bg-color: var(--green-100, #{$green-100}); + } + + &.gl-bg-red-100 { + --bg-color: var(--red-100, #{$red-100}); + } + + &.gl-bg-blue-100 { + --bg-color: var(--blue-100, #{$blue-100}); + } +} + +.mr-system-note-icon:not(.mr-system-note-empty)::before { + content: ''; + display: block; + position: absolute; + left: calc(50% - 1px); + bottom: 100%; + width: 2px; + height: 20px; + background: linear-gradient(to bottom, transparent, var(--bg-color)); + + .system-note:first-child & { + display: none; + } +} + +.mr-system-note-icon:not(.mr-system-note-empty)::after { + content: ''; + display: block; + position: absolute; + left: calc(50% - 1px); + top: 100%; + width: 2px; + height: 20px; + background: linear-gradient(to bottom, var(--bg-color), transparent); + + .system-note:last-child & { + display: none; + } +} + +.mr-system-note-empty { + width: 8px; + height: 8px; + margin-top: 6px; + margin-left: 12px; + margin-right: 8px; + border: 2px solid var(--gray-50, $gray-50); +} diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index 708d1a2895e..8dc07715989 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -131,42 +131,6 @@ } } -.milestone-page-header { - display: flex; - flex-flow: row; - align-items: center; - flex-wrap: wrap; - - .milestone-buttons { - margin-left: auto; - order: 2; - - .verbose { - display: none; - } - } - - .header-text-content { - order: 3; - width: 100%; - } - - @include media-breakpoint-up(xs) { - .milestone-buttons .verbose { - display: inline; - } - - .header-text-content { - order: 2; - width: auto; - } - - .milestone-buttons { - order: 3; - } - } -} - .issuable-row { background-color: var(--white, $white); } diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index f08d6e3ca95..51bffd99dd0 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -57,10 +57,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-h-full; @include gl-w-full; @include gl-overflow-x-auto; - @include gl-border-gray-100; - @include gl-border-1; - @include gl-border-solid; - @include gl-rounded-base; } .timeline-section { @@ -68,15 +64,12 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-top-0; z-index: 20; - .timeline-header-blank, + .timeline-header-label, .timeline-header-item { @include gl-float-left; - height: $header-item-height; - border-bottom: $border-style; - background-color: var(--white, $white); } - .timeline-header-blank { + .timeline-header-label { @include gl-sticky; @include gl-top-0; @include gl-left-0; @@ -85,13 +78,8 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi } .timeline-header-item { - &:last-of-type .item-label { - @include gl-border-r-0; - } - - .item-label, .item-sublabel .sublabel-value { - color: var(--gray-400, $gray-400); + color: var(--gray-700, $gray-700); @include gl-font-weight-normal; &.label-dark { @@ -103,11 +91,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi } } - .item-label { - border-right: $border-style; - border-bottom: $border-style; - } - .item-sublabel { @include gl-relative; @include gl-display-flex; @@ -118,7 +101,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi text-align: center; @include gl-font-base; - padding: 2px 0; } } @@ -131,10 +113,15 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-rounded-full; transform: translate(-50%, 50%); } + + &:first-of-type { + .week-item-sublabel .sublabel-value:nth-of-type(7) { + @include gl-border-r; + } + } } } -.timeline-section .timeline-header-blank, .list-section .details-cell { &::after { @include gl-h-full; @@ -159,7 +146,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-left-0; width: $details-cell-width; @include gl-font-base; - background-color: var(--white, $white); z-index: 10; } @@ -182,3 +168,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi transform: translateX(-50%); } } + +.rotation-asignee-container { + overflow-x: clip; +} diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss index 347bd1316c0..8f2cbc402c9 100644 --- a/app/assets/stylesheets/pages/storage_quota.scss +++ b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .storage-type-usage { &:first-child { @include gl-rounded-top-left-base; @@ -12,6 +14,6 @@ &:not(:last-child) { @include gl-border-r-2; @include gl-border-r-solid; - @include gl-border-white; + border-right-color: var(--white, $white); } } diff --git a/app/assets/stylesheets/page_bundles/releases.scss b/app/assets/stylesheets/page_bundles/releases.scss index 24ffbf9b90c..c011ec3fe4c 100644 --- a/app/assets/stylesheets/page_bundles/releases.scss +++ b/app/assets/stylesheets/page_bundles/releases.scss @@ -10,3 +10,17 @@ min-height: 46px; } } + +.release-tag-selector { + .popover-body { + padding-left: 0; + padding-right: 0; + padding-bottom: 0; + min-width: $gl-dropdown-width; + max-width: $gl-dropdown-width; + } + + .release-tag-list { + max-height: $dropdown-max-height; + } +} diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index cde570cfb0f..d37e87b5cd5 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -21,18 +21,18 @@ $border-radius-medium: 3px; } } +.language-filter-checkbox { + .custom-control-label { + flex-grow: 1; + } +} + .search-sidebar { @include media-breakpoint-up(md) { min-width: $search-sidebar-min-width; max-width: $search-sidebar-max-width; } - .language-filter-checkbox { - .custom-control-label { - flex-grow: 1; - } - } - .language-filter-max-height { max-height: $language-filter-max-height; } diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index d7d454bde45..69a3ec94fda 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -102,24 +102,42 @@ } } - .active > a { - color: var(--black, $black); - } - .active > .wiki-list { a, .wiki-list-expand-button, .wiki-list-collapse-button { - color: var(--black, $black); + color: $black; + } + } + + .wiki-list { + height: $gl-spacing-scale-8; + + &:hover { + background: $gray-10; + + .wiki-list-create-child-button { + display: block; + box-shadow: none; + + &:focus { + box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400; + } + + &:active { + background: $gray-100 !important; + box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400; + } + } } } .wiki-list-expand-button, .wiki-list-collapse-button { - color: var(--gray-400, $gray-400); + color: $gray-400; &:hover { - color: var(--black, $black); + color: $black; } } @@ -130,10 +148,6 @@ margin: 0; } - ul.wiki-pages li { - margin: 5px 0 10px; - } - ul.wiki-pages ul { padding-left: 20px; } @@ -172,6 +186,10 @@ ul.wiki-pages-list.content-list { } .wiki-list { + .wiki-list-create-child-button { + display: none; + } + .wiki-list-expand-button, .wiki-list-collapse-button { left: -$gl-spacing-scale-5; @@ -198,17 +216,12 @@ ul.wiki-pages-list.content-list { .drawio-editor { position: fixed; - top: calc(var(--header-height, 48px)); + top: 0; left: 0; bottom: 0; - width: 100%; - height: calc(100% - var(--header-height, 48px)); + width: 100vw; + height: 100vh; border: 0; z-index: 1100; visibility: hidden; } - -.with-performance-bar .drawio-editor { - top: calc(var(--header-height, 48px) + 35px); - height: calc(100% - var(--header-height, 48px) - 35px); -} diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 00c86c46ac8..ecbb872e1df 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -87,22 +87,20 @@ } } -.work-item-link-child { - @include gl-border-1; - @include gl-border-solid; - @include gl-border-transparent; - @include gl-rounded-base; - - &:hover, - &:focus-within { - @include gl-bg-white; - @include gl-border-gray-50; - } -} - // sticky error placement for errors in modals , by default it is 83px for full view #work-item-detail-modal { .flash-container.flash-container-page.sticky { top: -8px; } } + + +.work-item-notifications-form { + .gl-toggle { + @include gl-ml-auto; + } + + .gl-toggle-label { + @include gl-font-weight-normal; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 225c32c1989..83f51588f43 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -332,3 +332,18 @@ height: 100%; } } + +.add-review-item-modal { + .modal-content { + position: absolute; + top: 5%; + } + + .title-hint-text { + color: $gl-text-color-secondary; + } + + .gl-filtered-search-suggestion-list.dropdown-menu { + width: $gl-max-dropdown-max-height; + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 0151446321a..9b6a3362e71 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -77,8 +77,7 @@ ul.related-merge-requests > li gl-emoji { .issue { &.closed, &.merged { - background: $gray-light; - border-color: $border-color; + background: $gray-10; } } @@ -243,6 +242,8 @@ ul.related-merge-requests > li gl-emoji { } &:hover > a.anchor::after { + position: relative; + top: -3px; visibility: visible; } } @@ -253,7 +254,7 @@ ul.related-merge-requests > li gl-emoji { @include gl-left-0; width: var(--width); - top: $header-height; + top: $calc-application-header-height; // collapsed right sidebar @include media-breakpoint-up(sm) { @@ -267,10 +268,6 @@ ul.related-merge-requests > li gl-emoji { } } -.with-performance-bar .issue-sticky-header { - top: calc(#{$header-height} + #{$performance-bar-height}); -} - @include media-breakpoint-up(md) { // collapsed left sidebar + collapsed right sidebar .page-with-contextual-sidebar .issue-sticky-header { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 5b8b850ba35..0a17b2c47a4 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -209,19 +209,11 @@ $comparison-empty-state-height: 62px; } .merge-request-tabs-holder { - top: $header-height; + top: $calc-application-header-height; z-index: $tabs-holder-z-index; background-color: $body-bg; border-bottom: 1px solid $border-color; - .with-system-header & { - top: calc(#{$header-height} + #{$system-header-height}); - } - - .with-system-header.with-performance-bar & { - top: calc(#{$header-height} + #{$system-header-height} + #{$performance-bar-height}); - } - @include media-breakpoint-up(md) { position: sticky; } @@ -240,12 +232,6 @@ $comparison-empty-state-height: 62px; } } -.with-performance-bar { - .merge-request-tabs-holder { - top: calc(#{$header-height} + #{$performance-bar-height}); - } -} - .limit-container-width { .merge-request-tabs-container { max-width: $limited-layout-width; @@ -336,11 +322,7 @@ $comparison-empty-state-height: 62px; .mr-compare { .diff-file .file-title-flex-parent { - top: calc(#{$header-height} + #{$mr-tabs-height}); - - .with-performance-bar & { - top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height}); - } + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height}); } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 68a5176ad4b..b31ee069236 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -7,7 +7,7 @@ $system-note-icon-size: 1.5rem; $system-note-svg-size: 1rem; $icon-size-diff: $avatar-icon-size - $system-note-icon-size; -$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 0.1rem; +$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 1.3rem; $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; @mixin vertical-line($left) { @@ -15,10 +15,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; content: ''; border-left: 2px solid var(--gray-50, $gray-50); position: absolute; - top: $gl-padding-6; + top: 16px; bottom: 0; left: calc(#{$left} - 1px); - height: calc(100% + 1.5rem); + height: calc(100% + 20px); } } @@ -30,8 +30,30 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .issuable-discussion:not(.incident-timeline-events), .limited-width-notes { - .main-notes-list > li.timeline-entry:not(:last-of-type) { - @include vertical-line(1rem); + .main-notes-list::before, + .timeline-entry:last-child::before { + content: ''; + position: absolute; + width: 2px; + left: 15px; + top: 15px; + height: calc(100% - 15px); + } + + .main-notes-list::before { + background: var(--gray-50, $gray-50); + } + + .timeline-entry:last-child::before { + background: var(--white); + + .gl-dark & { + background: var(--gray-10); + } + + &.note-comment { + top: 30px; + } } } @@ -63,6 +85,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; height: 2rem; } + .gl-avatar { + border-color: var(--gray-50, $gray-50); + } + &.note-comment, &.note-skeleton, .draft-note { @@ -265,7 +291,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; &.being-posted { pointer-events: none; - opacity: 0.5; + + .timeline-entry-inner { + opacity: 0.5; + } .dummy-avatar { background-color: $gray-100; @@ -343,6 +372,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .note-header-info { padding-bottom: 0; + padding-top: 0; } &.timeline-entry::after { @@ -369,9 +399,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .timeline-content { - @include notes-media('min', map-get($grid-breakpoints, sm)) { - margin-left: 30px; - } + margin-left: 30px; } .note-header { @@ -450,40 +478,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } } - - .timeline-icon { - float: left; - } - - .system-note, - .discussion-filter-note { - .timeline-icon { - display: flex; - align-items: center; - background-color: $gray-50; - width: $system-note-icon-size; - height: $system-note-icon-size; - border: 1px solid $gray-50; - border-radius: $system-note-icon-size; - margin: -$gl-spacing-scale-1 0 0 $gl-spacing-scale-2; - - svg { - width: $system-note-svg-size; - height: $system-note-svg-size; - fill: $gray-600; - display: block; - margin: 0 auto; - } - } - } - - .discussion-filter-note { - .timeline-icon { - width: $system-note-icon-size; - height: $system-note-icon-size; - margin-top: -8px; - } - } } .card .notes { @@ -493,7 +487,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .timeline-icon { - margin: 8px 0 0 14px; + margin: 20px 0 0 28px; } } @@ -506,18 +500,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; border-radius: 0; margin-left: 2.5rem; - @media (min-width: map-get($grid-breakpoints, md)) { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); - - &.is-sidebar-moved { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px}); - } - - .with-performance-bar & { - --top: 123px; - } - } - &:hover { background-color: $gray-light; } @@ -603,15 +585,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .system-note { background-color: transparent; padding: 0; - - .timeline-icon { - margin-top: -2px; - } - - .timeline-entry-inner .timeline-icon { - margin-top: $system-note-icon-m-top; - margin-left: $system-note-icon-m-left; - } } } @@ -643,10 +616,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; padding: 0; vertical-align: top; white-space: normal; - - // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-foss/issues/53973 - // background-color is needed for dark code preference - padding-bottom: 1px; background-color: $white; &.parallel { @@ -673,6 +642,14 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } } + + .diff-grid-comments:last-child { + .notes-content { + border-bottom-width: 0; + border-bottom-left-radius: #{$border-radius-default - 1px}; + border-bottom-right-radius: #{$border-radius-default - 1px}; + } + } } .diffs { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index ee91d955019..e6c7e265cdb 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -291,9 +291,9 @@ .project-cell { @include gl-display-table-cell; - @include gl-border-b; @include gl-vertical-align-top; @include gl-py-4; + border-bottom: 1px solid $gray-50; } .project-row:last-of-type { diff --git a/app/assets/stylesheets/pages/registry.scss b/app/assets/stylesheets/pages/registry.scss index 31c6dbd2970..36b86771295 100644 --- a/app/assets/stylesheets/pages/registry.scss +++ b/app/assets/stylesheets/pages/registry.scss @@ -2,7 +2,7 @@ // until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 // // See app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue when this is changed. -.breadcrumbs-container .gl-breadcrumbs { +.breadcrumbs .gl-breadcrumbs { padding: 0; box-shadow: none; } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 2d78ab82b7d..012ae4bb86a 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -1,23 +1,4 @@ -.integration-settings-form { - .card.card-body, - .info-well { - padding: $gl-padding / 2; - box-shadow: none; - } - - .svg-container { - max-width: 150px; - } -} - .visibility-level-setting { - .option-title { - font-weight: $gl-font-weight-normal; - display: inline-block; - color: var(--gl-text-color, $gl-text-color); - vertical-align: top; - } - .option-description, .option-disabled-reason { color: var(--gray-700, $gray-700); @@ -69,30 +50,16 @@ } } -.push-pull-table { - margin-top: 1em; -} - .ci-variable-table, .deploy-freeze-table, .ci-secure-files-table { table { - thead { - border-bottom: 1px solid var(--gray-50, $gray-50); - } - tr { td, th { padding-left: 0; } - th { - background-color: transparent; - font-weight: $gl-font-weight-bold; - border: 0; - } - // When tables are "stacked", restore td padding @media(max-width: map-get($grid-breakpoints, lg)) { td { @@ -109,8 +76,8 @@ } } -.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap { +.gl-md-flex-nowrap.gl-md-flex-nowrap { @include gl-media-breakpoint-up(md) { - @include gl-flex-wrap-nowrap; + @include gl-flex-nowrap; } } diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 5024b082b99..cb153122767 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -128,6 +128,3 @@ color: $black; } -html.with-performance-bar .nav-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height}); -} diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 265f27f21fa..84181a00f34 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -63,9 +63,5 @@ a[href]::after { } .with-performance-bar .layout-page { - margin-top: 0; -} - -.content-wrapper-margin { - margin-top: 0; + padding-top: 0; } diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index b4e896325d6..ef75c650853 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -140,18 +140,6 @@ kbd kbd { background-color: #24232a; opacity: 1; } -.form-inline { - display: flex; - flex-flow: row wrap; - align-items: center; -} -@media (min-width: 576px) { - .form-inline .form-control { - display: inline-block; - width: auto; - vertical-align: middle; - } -} .btn { display: inline-block; font-weight: 400; @@ -562,17 +550,13 @@ strong { svg { vertical-align: baseline; } -.form-control, -.search form { +.form-control { font-size: 0.875rem; } .hidden { display: none !important; visibility: hidden !important; } -.hide { - display: none; -} .badge:not(.gl-badge) { padding: 4px 5px; font-size: 12px; @@ -593,6 +577,14 @@ svg { html { overflow-y: scroll; } +.layout-page { + padding-top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); + padding-bottom: var(--system-footer-height); +} @media (min-width: 576px) { .logged-out-marketing-header { --header-height: 72px; @@ -632,43 +624,22 @@ html { color: #bfbfc3; vertical-align: baseline; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} +.with-top-bar { + --top-bar-height: 48px; +} .gl-font-sm { font-size: 12px; } .dropdown { position: relative; } -.dropdown-menu-toggle:active { - box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; - outline: none; -} -.search-input-container .dropdown-menu { - margin-top: 11px; -} -.dropdown-menu-toggle { - padding: 6px 8px 6px 10px; - background-color: #333238; - color: #ececef; - font-size: 14px; - text-align: left; - border: 1px solid #535158; - border-radius: 0.25rem; - white-space: nowrap; -} -.dropdown-menu-toggle.no-outline { - outline: 0; -} -.dropdown-menu-toggle.dropdown-menu-toggle { - justify-content: flex-start; - overflow: hidden; - padding-top: 7px; - padding-bottom: 7px; - padding-right: 25px; - position: relative; - text-overflow: ellipsis; - line-height: 16px; - width: 160px; -} .dropdown-menu { display: none; position: absolute; @@ -750,11 +721,6 @@ html { min-width: 100%; } } -@media (max-width: 767.98px) { - .dropdown-menu-toggle.dropdown-menu-toggle { - width: 100%; - } -} input { border-radius: 0.25rem; color: #ececef; @@ -793,7 +759,7 @@ kbd { min-height: var(--header-height, 48px); border: 0; position: fixed; - top: 0; + top: calc(var(--system-header-height) + var(--performance-bar-height)); left: 0; right: 0; border-radius: 0; @@ -1075,11 +1041,15 @@ kbd { } .nav-sidebar { position: fixed; - bottom: 0; + bottom: var(--system-footer-height); left: 0; z-index: 600; width: 256px; - top: var(--header-height, 48px); + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); background-color: #1f1e24; border-right: 1px solid #e9e9e9; transform: translate3d(0, 0, 0); @@ -1496,8 +1466,11 @@ kbd { display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + ); + bottom: var(--system-footer-height); left: 0; background-color: var(--gray-10, #1f1e24); border-right: 1px solid rgba(251, 250, 253, 0.08); @@ -1513,9 +1486,13 @@ kbd { transform: translate3d(0, 0, 0); } } +@media (prefers-reduced-motion: no-preference) { +} .page-with-super-sidebar { padding-left: 0; } +@media (prefers-reduced-motion: no-preference) { +} @media (min-width: 1200px) { .page-with-super-sidebar { padding-left: 256px; @@ -1667,7 +1644,6 @@ svg.s16 { --gray-200: #535158; --gray-700: #bfbfc3; --gray-900: #ececef; - --gl-text-color: #ececef; --border-color: #434248; --white: #333238; --black: #fff; @@ -1779,16 +1755,6 @@ body.gl-dark .header-search .keyboard-shortcut-helper { color: #ececef; background-color: rgba(236, 236, 239, 0.2); } -body.gl-dark .search form { - background-color: rgba(236, 236, 239, 0.2); -} -body.gl-dark .search .search-input::placeholder { - color: rgba(236, 236, 239, 0.8); -} -body.gl-dark .search .search-input-wrap .search-icon, -body.gl-dark .search .search-input-wrap .clear-icon { - fill: rgba(236, 236, 239, 0.8); -} body.gl-dark .nav-sidebar li.active > a { color: #ececef; } @@ -1817,17 +1783,6 @@ body.gl-dark .navbar-gitlab .header-search:active { background-color: var(--gray-100) !important; box-shadow: inset 0 0 0 1px var(--blue-200) !important; } -body.gl-dark .navbar-gitlab .search form { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--border-color); -} -body.gl-dark .navbar-gitlab .search form:active { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--blue-200); -} -body.gl-dark .navbar-gitlab .search form .search-input { - color: var(--gl-text-color); -} .tab-width-8 { tab-size: 8; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 0a0fa83ff67..0dfc6be356f 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -140,18 +140,6 @@ kbd kbd { background-color: #fbfafd; opacity: 1; } -.form-inline { - display: flex; - flex-flow: row wrap; - align-items: center; -} -@media (min-width: 576px) { - .form-inline .form-control { - display: inline-block; - width: auto; - vertical-align: middle; - } -} .btn { display: inline-block; font-weight: 400; @@ -562,17 +550,13 @@ strong { svg { vertical-align: baseline; } -.form-control, -.search form { +.form-control { font-size: 0.875rem; } .hidden { display: none !important; visibility: hidden !important; } -.hide { - display: none; -} .badge:not(.gl-badge) { padding: 4px 5px; font-size: 12px; @@ -593,6 +577,14 @@ svg { html { overflow-y: scroll; } +.layout-page { + padding-top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); + padding-bottom: var(--system-footer-height); +} @media (min-width: 576px) { .logged-out-marketing-header { --header-height: 72px; @@ -632,43 +624,22 @@ html { color: #535158; vertical-align: baseline; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} +.with-top-bar { + --top-bar-height: 48px; +} .gl-font-sm { font-size: 12px; } .dropdown { position: relative; } -.dropdown-menu-toggle:active { - box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; -} -.search-input-container .dropdown-menu { - margin-top: 11px; -} -.dropdown-menu-toggle { - padding: 6px 8px 6px 10px; - background-color: #fff; - color: #333238; - font-size: 14px; - text-align: left; - border: 1px solid #bfbfc3; - border-radius: 0.25rem; - white-space: nowrap; -} -.dropdown-menu-toggle.no-outline { - outline: 0; -} -.dropdown-menu-toggle.dropdown-menu-toggle { - justify-content: flex-start; - overflow: hidden; - padding-top: 7px; - padding-bottom: 7px; - padding-right: 25px; - position: relative; - text-overflow: ellipsis; - line-height: 16px; - width: 160px; -} .dropdown-menu { display: none; position: absolute; @@ -750,11 +721,6 @@ html { min-width: 100%; } } -@media (max-width: 767.98px) { - .dropdown-menu-toggle.dropdown-menu-toggle { - width: 100%; - } -} input { border-radius: 0.25rem; color: #333238; @@ -793,7 +759,7 @@ kbd { min-height: var(--header-height, 48px); border: 0; position: fixed; - top: 0; + top: calc(var(--system-header-height) + var(--performance-bar-height)); left: 0; right: 0; border-radius: 0; @@ -1075,11 +1041,15 @@ kbd { } .nav-sidebar { position: fixed; - bottom: 0; + bottom: var(--system-footer-height); left: 0; z-index: 600; width: 256px; - top: var(--header-height, 48px); + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); background-color: #fbfafd; border-right: 1px solid #e9e9e9; transform: translate3d(0, 0, 0); @@ -1496,8 +1466,11 @@ kbd { display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + ); + bottom: var(--system-footer-height); left: 0; background-color: var(--gray-10, #fbfafd); border-right: 1px solid rgba(31, 30, 36, 0.08); @@ -1513,9 +1486,13 @@ kbd { transform: translate3d(0, 0, 0); } } +@media (prefers-reduced-motion: no-preference) { +} .page-with-super-sidebar { padding-left: 0; } +@media (prefers-reduced-motion: no-preference) { +} @media (min-width: 1200px) { .page-with-super-sidebar { padding-left: 256px; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 57f61508178..cd768c3bbc0 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -430,8 +430,13 @@ input.btn-block[type="button"] { cursor: not-allowed; color: #89888d; } +.gl-form-checkbox.custom-control { + padding-left: 1rem; +} .gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label { cursor: pointer; + padding-left: 0.5rem; + margin-bottom: 0.5rem; } .gl-form-checkbox.custom-control .custom-control-input @@ -440,6 +445,7 @@ input.btn-block[type="button"] { .custom-control-input ~ .custom-control-label::after { top: 0; + left: -1rem; } .gl-form-checkbox.custom-control .custom-control-input @@ -663,6 +669,13 @@ body.navless { .btn-block.btn { padding: 6px 0; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} .tab-content { overflow: visible; } @@ -690,7 +703,11 @@ hr { } .flash-container.sticky { position: sticky; - top: 48px; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); z-index: 251; } .flash-container.flash-container-page { @@ -756,223 +773,10 @@ input:-ms-input-placeholder { svg { fill: currentColor; } -.login-page .container { - max-width: 960px; -} -.login-page .navbar-gitlab .container { - max-width: none; -} -.login-page .flash-container { - margin-bottom: 16px; - position: relative; - top: 8px; -} -.login-page .brand-holder { - font-size: 18px; - line-height: 1.5; -} -.login-page .brand-holder p { - font-size: 16px; - color: #888; -} -.login-page .brand-holder h3 { - font-size: 22px; -} -.login-page .brand-holder img { - max-width: 100%; - margin-bottom: 30px; -} -.login-page .brand-holder a { - font-weight: 600; -} -.login-page p { - font-size: 13px; -} -.login-page .signin-text p { - margin-bottom: 0; - line-height: 1.5; -} -.login-page .borderless .login-box, -.login-page .borderless .omniauth-container { - box-shadow: none; -} -.login-page .borderless .g-recaptcha > div { - margin-left: auto; - margin-right: auto; -} -.login-page .login-box, -.login-page .omniauth-container { - box-shadow: 0 0 0 1px #dcdcde; - border-radius: 0.25rem; -} -.login-page .login-box .login-heading h3, -.login-page .omniauth-container .login-heading h3 { - font-weight: 400; - line-height: 1.5; - margin: 0 0 10px; -} -.login-page .login-box .login-footer, -.login-page .omniauth-container .login-footer { - margin-top: 10px; -} -.login-page .login-box .login-footer p:last-child, -.login-page .omniauth-container .login-footer p:last-child { - margin-bottom: 0; -} -.login-page .login-box a.forgot, -.login-page .omniauth-container a.forgot { - float: right; - padding-top: 6px; -} -.login-page .login-box .nav .active a, -.login-page .omniauth-container .nav .active a { - background: transparent; -} -.login-page .login-box .login-body, -.login-page .omniauth-container .login-body { - font-size: 13px; -} -.login-page .login-box .login-body input + p, -.login-page .login-box .login-body input ~ p.field-validation, -.login-page .omniauth-container .login-body input + p, -.login-page .omniauth-container .login-body input ~ p.field-validation { - margin-top: 5px; -} -.login-page .login-box .login-body .username .validation-success, -.login-page .omniauth-container .login-body .username .validation-success { - color: #217645; -} -.login-page .login-box .login-body .username .validation-error, -.login-page .omniauth-container .login-body .username .validation-error { - color: #dd2b0e; -} -.login-page .omniauth-container { - border-radius: 0.25rem; - font-size: 13px; -} -.login-page .omniauth-container p { - margin: 0; -} -.login-page .omniauth-container form { - padding: 0; - border: 0; - background: none; -} -.login-page .new-session-tabs { - display: flex; - box-shadow: 0 0 0 1px #dcdcde; - border-top-right-radius: 4px; - border-top-left-radius: 4px; -} -.login-page .new-session-tabs.nav-links-unboxed { - border-color: transparent; - box-shadow: none; -} -.login-page .new-session-tabs.nav-links-unboxed .nav-item { - border-left: 0; - border-right: 0; - border-bottom: 1px solid #dcdcde; - background-color: transparent; -} -.login-page .new-session-tabs.custom-provider-tabs { - flex-wrap: wrap; -} -.login-page .new-session-tabs.custom-provider-tabs li { - min-width: 85px; - flex-basis: auto; -} -.login-page .new-session-tabs.custom-provider-tabs li:nth-child(n + 5) { - border-top: 1px solid #dcdcde; -} -.login-page .new-session-tabs.custom-provider-tabs a { - font-size: 16px; -} -.login-page .new-session-tabs li { - flex: 1; - text-align: center; - border-left: 1px solid #dcdcde; -} -.login-page .new-session-tabs li:first-of-type { - border-left: 0; - border-top-left-radius: 4px; -} -.login-page .new-session-tabs li:last-of-type { - border-top-right-radius: 4px; -} -.login-page .new-session-tabs li:not(.active) { - background-color: #fbfafd; -} -.login-page .new-session-tabs li a { - width: 100%; - font-size: 18px; -} -.login-page .new-session-tabs li.active > a { - cursor: default; -} -.login-page .form-control:active, -.login-page .form-control:focus { - background-color: #fff; -} -.login-page .submit-container { - margin-top: 16px; -} -.login-page input[type="submit"] { - margin-bottom: 0; - display: block; - width: 100%; -} -.login-page .devise-errors h2 { - margin-top: 0; - font-size: 14px; - color: #ae1800; -} -@media (max-width: 575.98px) { - .login-page .col-md-5.float-right { - float: none !important; - margin-bottom: 45px; - } -} -.devise-layout-html { - margin: 0; - padding: 0; - height: 100%; -} -.devise-layout-html body { - height: calc(100% - 51px); - margin: 0; - padding: 0; -} -.devise-layout-html body.navless { - height: calc(100% - 11px); -} -.devise-layout-html body .page-wrap { - min-height: 100%; - position: relative; -} -.devise-layout-html body .footer-container, -.devise-layout-html body hr.footer-fixed { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 40px; - background: #fff; -} -.devise-layout-html body .login-page-broadcast { - margin-top: 40px; -} -.devise-layout-html body .navless-container { - padding: 0 15px 65px; -} -.devise-layout-html body .flash-container { - padding-bottom: 65px; -} -@media (max-width: 575.98px) { - .devise-layout-html body .flash-container { - padding-bottom: 0; - } -} +.fixed-top { + top: calc(var(--system-header-height) + var(--performance-bar-height)); +} .gl-display-flex { display: flex; } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index f37b426cd91..6e46100dbb3 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -159,6 +159,22 @@ background-color: $search-and-nav-links-a30 !important; } + &.is-focused { + input { + background-color: $white; + color: $gl-text-color !important; + box-shadow: inset 0 0 0 1px $gray-900; + + &:focus { + box-shadow: inset 0 0 0 1px $gray-900, 0 0 0 1px $white, 0 0 0 3px $blue-400; + } + + &::placeholder { + color: $gray-400; + } + } + } + svg.gl-search-box-by-type-search-icon { color: $search-and-nav-links-a80; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 11f73b592fc..e5f99879166 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -39,6 +39,12 @@ .border-radius-small { border-radius: $border-radius-small; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } +// Override Bootstrap class with offset for system-header and +// performance bar when present +.fixed-top { + top: $calc-application-bars-height; +} + .gl-children-ml-sm-3 > * { @include media-breakpoint-up(sm) { @include gl-ml-3; @@ -71,58 +77,11 @@ // https://gitlab.com/groups/gitlab-org/-/epics/2882 .gl-h-200\! { height: px-to-rem($grid-size * 25) !important; } -.gl-bg-purple-light { background-color: $purple-light; } - -// move this to GitLab UI once onboarding experiment is considered a success -.gl-py-8 { - padding-top: $gl-spacing-scale-8; - padding-bottom: $gl-spacing-scale-8; -} - -.gl-transition-property-stroke-opacity { - transition-property: stroke-opacity; -} - -.gl-transition-property-stroke { - transition-property: stroke; -} - -.gl-top-66vh { - top: 66vh; -} - -.gl-shadow-x0-y0-b3-s1-blue-500 { - box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500; -} - // This utility is used to force the z-index to match that of dropdown menu's .gl-z-dropdown-menu\! { z-index: $zindex-dropdown-menu !important; } -.gl-flex-basis-quarter { - flex-basis: 25%; -} - -// Will be moved to @gitlab/ui (without the !important) in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1462 -// We only need the bang (!) version until the non-bang version is added to -// @gitlab/ui utitlities.scss. Once there, it will get loaded in the correct -// order to properly override `.gl-mt-6` which is used for narrower screen -// widths (currently that style gets added to the application.css stylesheet -// after this one, so it takes precedence). -.gl-md-mt-11\! { - @media (min-width: $breakpoint-md) { - margin-top: $gl-spacing-scale-11 !important; - } -} - -// Same as above (also without the !important) but for overriding `.gl-pt-6` -.gl-md-pt-11\! { - @media (min-width: $breakpoint-md) { - padding-top: $gl-spacing-scale-11 !important; - } -} - // This is used to help prevent issues with margin collapsing. // See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing. .gl-force-block-formatting-context::after { @@ -130,34 +89,6 @@ display: flex; } -.gl-sm-mr-3 { - @include media-breakpoint-up(sm) { - margin-right: $gl-spacing-scale-3; - } -} - -.gl-xl-ml-3 { - @include media-breakpoint-up(lg) { - margin-left: $gl-spacing-scale-3; - } -} - -.gl-mr-n2 { - margin-right: -$gl-spacing-scale-2; -} - -.gl-w-grid-size-30 { - width: $grid-size * 30; -} - -.gl-w-grid-size-40 { - width: $grid-size * 40; -} - -.gl-max-w-50p { - max-width: 50%; -} - /** Note: ::-webkit-scrollbar is a non-standard rule only supported by webkit browsers. @@ -181,59 +112,6 @@ @include gl-focus($gl-border-size-1, $gray-900, true); } -/* -All of the following (up until the "End gitlab-ui#1709" comment) will be moved -to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 -*/ -.gl-md-grid-template-columns-3 { - @include media-breakpoint-up(md) { - grid-template-columns: repeat(3, 1fr); - } -} - -.gl-lg-grid-template-columns-4 { - @include media-breakpoint-up(lg) { - grid-template-columns: repeat(4, 1fr); - } -} - -.gl-max-w-48 { - max-width: $gl-spacing-scale-48; -} - -.gl-max-w-75 { - max-width: $gl-spacing-scale-75; -} - -.gl-md-pt-11 { - @include media-breakpoint-up(md) { - padding-top: $gl-spacing-scale-11 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence - } -} - -.gl-md-mb-6 { - @include media-breakpoint-up(md) { - margin-bottom: $gl-spacing-scale-6 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence - } -} - -.gl-md-mb-12 { - @include media-breakpoint-up(md) { - margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence - } -} - -.gl-mt-n5 { - margin-top: -$gl-spacing-scale-5; -} - -// Utils below are very specific so cannot be part of GitLab UI -.gl-md-mt-5 { - @include gl-media-breakpoint-up(md) { - margin-top: $gl-spacing-scale-5; - } -} - .gl-sm-mr-0\! { @include gl-media-breakpoint-down(md) { margin-right: 0 !important; @@ -246,66 +124,20 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } -.gl-md-mb-3\! { +.gl-md-w-15 { @include gl-media-breakpoint-up(md) { - margin-bottom: $gl-spacing-scale-3 !important; + width: $gl-spacing-scale-15; } } -.gl-font-xs { - font-size: px-to-rem(10px); -} - -.gl-line-height-12 { - line-height: px-to-rem(12px); -} - -.gl-letter-spacing-06em { - letter-spacing: 0.06em; -} - -.gl-flex-flow-row-wrap { - flex-flow: row wrap; -} - -.gl-isolate { - isolation: isolate; -} - -.gl-text-transform-uppercase { - text-transform: uppercase; -} - -/* - * The below style will be moved to @gitlab/ui by - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2177 - */ -.gl-gap-2 { - gap: $gl-spacing-scale-2; -} - -.gl-bg-t-gray-a-08 { - background-color: $t-gray-a-08; -} - -.gl-hover-bg-t-gray-a-08:hover { - background-color: $t-gray-a-08; -} - -.gl-inset-border-1-gray-a-08 { - box-shadow: inset 0 0 0 $gl-border-size-1 $t-gray-a-08; -} - -.gl-line-height-1 { - line-height: 1; -} - -.gl-focus:focus { - @include gl-focus; +.gl-md-w-20 { + @include gl-media-breakpoint-up(md) { + width: $gl-spacing-scale-20; + } } -.gl-md-justify-content-space-between { +.gl-md-w-30 { @include gl-media-breakpoint-up(md) { - justify-content: space-between; + width: $gl-spacing-scale-30; } } diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb deleted file mode 100644 index cf7ba0e5aaf..00000000000 --- a/app/channels/awareness_channel.rb +++ /dev/null @@ -1,85 +0,0 @@ -# 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: 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, - username: user.username, - 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/diffs/overflow_warning_component.html.haml b/app/components/diffs/overflow_warning_component.html.haml index 551d995cb22..bfc2f3f23a7 100644 --- a/app/components/diffs/overflow_warning_component.html.haml +++ b/app/components/diffs/overflow_warning_component.html.haml @@ -1,4 +1,4 @@ -= render Pajamas::AlertComponent.new(title: _('Too many changes to show.'), += render Pajamas::AlertComponent.new(title: _('Some changes are not shown.'), variant: :warning, alert_options: { class: 'gl-mb-5', data: { testid: "too-many-changes-alert" } }) do |c| = c.body do diff --git a/app/components/diffs/overflow_warning_component.rb b/app/components/diffs/overflow_warning_component.rb index 5123809cfdc..34882885027 100644 --- a/app/components/diffs/overflow_warning_component.rb +++ b/app/components/diffs/overflow_warning_component.rb @@ -54,8 +54,8 @@ module Diffs def message_text _( - "To preserve performance only %{strong_open}%{display_size} " \ - "of %{real_size}%{strong_close} files are displayed." + "For a faster browsing experience, only %{strong_open}%{display_size} of %{real_size}%{strong_close} " \ + "files are shown. Download one of the files below to see all changes." ) end diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index f6b4fbac8d5..edeac57bc42 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -55,7 +55,7 @@ class AbuseReportsController < ApplicationController private def report_params - params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url, links_to_spam: []) + params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url, :screenshot, links_to_spam: []) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 0bbfeae6656..96d78034ad6 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -234,6 +234,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController params[:application_setting][:valid_runner_registrars]&.delete("") params[:application_setting][:restricted_visibility_levels]&.delete("") + params[:application_setting][:package_metadata_purl_types]&.delete("") + params[:application_setting][:package_metadata_purl_types]&.map!(&:to_i) + if params[:application_setting].key?(:required_instance_ci_template) if params[:application_setting][:required_instance_ci_template].empty? params[:application_setting][:required_instance_ci_template] = nil @@ -276,6 +279,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :default_branch_name, disabled_oauth_sign_in_sources: [], import_sources: [], + package_metadata_purl_types: [], restricted_visibility_levels: [], repository_storages_weighted: {}, valid_runner_registrars: [] diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 76564981c9b..d97fcc5df74 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -47,10 +47,9 @@ class Admin::ApplicationsController < Admin::ApplicationController @application.renew_secret if @application.save - flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.') - render :show + render json: { secret: @application.plaintext_secret } else - redirect_to admin_application_url(@application) + render json: { errors: @application.errors }, status: :unprocessable_entity end end diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb index b904196c5ab..a5211961d81 100644 --- a/app/controllers/admin/background_migrations_controller.rb +++ b/app/controllers/admin/background_migrations_controller.rb @@ -10,6 +10,7 @@ module Admin def index @relations_by_tab = { 'queued' => batched_migration_class.queued.queue_order, + 'finalizing' => batched_migration_class.finalizing.queue_order, 'failed' => batched_migration_class.with_status(:failed).queue_order, 'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order } diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb index c811de12914..4ab67e54766 100644 --- a/app/controllers/admin/ci/variables_controller.rb +++ b/app/controllers/admin/ci/variables_controller.rb @@ -3,7 +3,7 @@ module Admin module Ci class VariablesController < ApplicationController - feature_category :pipeline_composition + feature_category :secrets_management def show respond_to do |format| diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index ef45eaac437..0f9ecc60648 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -5,7 +5,7 @@ class Admin::GroupsController < Admin::ApplicationController before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update] - feature_category :subgroups + feature_category :subgroups, [:create, :destroy, :edit, :index, :members_update, :new, :show, :update] def index @groups = groups.sort_by_attribute(@sort = params[:sort]) diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 70c2d262b72..84eb90ce334 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -68,6 +68,10 @@ class Admin::ProjectsController < Admin::ApplicationController result = ::Projects::UpdateService.new(@project, current_user, project_params).execute if result[:status] == :success + unless Gitlab::Utils.to_boolean(project_params['runner_registration_enabled']) + Ci::Runners::ResetRegistrationTokenService.new(@project, current_user).execute + end + redirect_to [:admin, @project], notice: format(_("Project '%{project_name}' was successfully updated."), project_name: @project.name) else render "edit" @@ -103,7 +107,8 @@ class Admin::ProjectsController < Admin::ApplicationController def allowed_project_params [ :description, - :name + :name, + :runner_registration_enabled ] end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 00b17bf381f..7fc534be253 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -114,10 +114,16 @@ class Admin::UsersController < Admin::ApplicationController def block result = Users::BlockService.new(current_user).execute(user) - if result[:status] == :success - redirect_back_or_admin_user(notice: _("Successfully blocked")) - else - redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked")) + respond_to do |format| + if result[:status] == :success + notice = _("Successfully blocked") + format.json { render json: { notice: notice } } + format.html { redirect_back_or_admin_user(notice: notice) } + else + alert = _("Error occurred. User was not blocked") + format.json { render json: { error: alert } } + format.html { redirect_back_or_admin_user(alert: alert) } + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ff888cf9d72..dbdf4a3055f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -89,7 +89,7 @@ class ApplicationController < ActionController::Base render_403 end - rescue_from Gitlab::Auth::IpBlacklisted do + rescue_from Gitlab::Auth::IpBlocked do Gitlab::AuthLogger.error( message: 'Rack_Attack', env: :blocklist, diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb index 2401d8b1044..dd5be596ad1 100644 --- a/app/controllers/clusters/base_controller.rb +++ b/app/controllers/clusters/base_controller.rb @@ -8,7 +8,7 @@ class Clusters::BaseController < ApplicationController helper_method :clusterable - feature_category :kubernetes_management + feature_category :deployment_management urgency :low, [ :index, :show, :environments, :cluster_status, :prometheus_proxy, :destroy, :new_cluster_docs, :connect, :new, :create_user diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 51150700860..873aa5e18dc 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -10,7 +10,6 @@ class Clusters::ClustersController < Clusters::BaseController before_action :authorize_read_cluster!, only: [:show, :index] before_action :authorize_create_cluster!, only: [:connect] before_action :authorize_update_cluster!, only: [:update] - before_action :update_applications_status, only: [:cluster_status] before_action :ensure_feature_enabled!, except: [:index, :new_cluster_docs] helper_method :token_in_session @@ -223,10 +222,6 @@ class Clusters::ClustersController < Clusters::BaseController @expires_at_in_session ||= session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] end - - def update_applications_status - @cluster.applications.each(&:schedule_status_update) - end end Clusters::ClustersController.prepend_mod_with('Clusters::ClustersController') diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb index 6a84c436aae..84cbdda1581 100644 --- a/app/controllers/concerns/access_tokens_actions.rb +++ b/app/controllers/concerns/access_tokens_actions.rb @@ -68,7 +68,7 @@ module AccessTokensActions # user in the resource without multiple queries. resource.members.load - @scopes = Gitlab::Auth.resource_bot_scopes + @scopes = Gitlab::Auth.available_scopes_for(resource) @active_access_tokens = active_access_tokens end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 7e1ba49d442..2e6b21e41cb 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -53,6 +53,8 @@ module Integrations :issues_events, :issues_url, :jenkins_url, + :jira_issue_prefix, + :jira_issue_regex, :jira_issue_transition_automatic, :jira_issue_transition_id, :manual_configuration, @@ -61,6 +63,7 @@ module Integrations :namespace, :new_issue_url, :notify_only_broken_pipelines, + :package_name, :password, :priority, :project_key, diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index d364daf93c3..a86a8a0415a 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -76,7 +76,7 @@ module IssuableActions title_text: issuable.title, description: view_context.markdown_field(issuable, :description), description_text: issuable.description, - task_status: issuable.task_status, + task_completion_status: issuable.task_completion_status, lock_version: issuable.lock_version } @@ -190,7 +190,7 @@ module IssuableActions end def discussion_cache_context - [current_user&.cache_key, project.team.human_max_access(current_user&.id)].join(':') + [current_user&.cache_key, project.team.human_max_access(current_user&.id), 'v2'].join(':') end def discussion_serializer diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb index ef58ab1972b..c66bf7c9e8c 100644 --- a/app/controllers/concerns/kas_cookie.rb +++ b/app/controllers/concerns/kas_cookie.rb @@ -3,6 +3,18 @@ module KasCookie extend ActiveSupport::Concern + included do + content_security_policy_with_context do |p| + next unless ::Gitlab::Kas::UserAccess.enabled? + + kas_url = ::Gitlab::Kas.tunnel_url + next if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception + + kas_url += '/' unless kas_url.end_with?('/') + p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url) + end + end + def set_kas_cookie return unless ::Gitlab::Kas::UserAccess.enabled? diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index 5ed2b2a82eb..fc33770b4d8 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -5,30 +5,6 @@ module ProductAnalyticsTracking include RedisTracking extend ActiveSupport::Concern - MIGRATED_EVENTS = %w[ - g_analytics_valuestream - i_search_paid - i_search_total - i_search_advanced - i_ecosystem_jira_service_list_issues - users_viewing_analytics_group_devops_adoption - i_analytics_dev_ops_adoption - i_analytics_dev_ops_score - p_analytics_merge_request - i_analytics_instance_statistics - g_analytics_contribution - p_analytics_pipelines - p_analytics_code_reviews - p_analytics_valuestream - p_analytics_insights - p_analytics_issues - p_analytics_repo - g_analytics_insights - g_analytics_issues - g_analytics_productivity - i_analytics_cohorts - ].freeze - class_methods do def track_event(*controller_actions, name:, action: nil, label: nil, conditions: nil, destinations: [:redis_hll], &block) custom_conditions = [:trackable_html_request?, *conditions] @@ -44,7 +20,7 @@ module ProductAnalyticsTracking def route_events_to(destinations, name, action, label, &block) track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll) - return unless destinations.include?(:snowplow) && event_enabled?(name) + return unless destinations.include?(:snowplow) raise "action is required when destination is snowplow" unless action raise "label is required when destination is snowplow" unless label @@ -63,18 +39,4 @@ module ProductAnalyticsTracking **optional_arguments ) end - - def event_enabled?(event) - return true if MIGRATED_EVENTS.include?(event) - - events_to_ff = { - g_edit_by_sfe: :_phase4, - g_compliance_dashboard: :_phase4, - g_compliance_audit_events: :_phase4, - i_compliance_audit_events: :_phase4, - i_compliance_credential_inventory: :_phase4 - } - - Feature.enabled?("route_hll_to_snowplow#{events_to_ff[event.to_sym]}", tracking_namespace_source) - end end diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index f1f5a1179c9..fca394f9fe1 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -33,6 +33,6 @@ module RendersCommits def valid_ref?(ref_name) return true unless ref_name.present? - Gitlab::GitRefValidator.validate(ref_name) + Gitlab::GitRefValidator.validate(ref_name, skip_head_ref_check: true) end end diff --git a/app/controllers/concerns/renders_member_access.rb b/app/controllers/concerns/renders_member_access.rb index 745830181c1..133d797c8ac 100644 --- a/app/controllers/concerns/renders_member_access.rb +++ b/app/controllers/concerns/renders_member_access.rb @@ -15,7 +15,8 @@ module RendersMemberAccess method_name = "max_member_access_for_#{klass.name.underscore}_ids" - current_user.public_send(method_name, collection.ids) # rubocop:disable GitlabSecurity/PublicSend + collection_ids = collection.try(:map, &:id) || collection.ids + current_user.public_send(method_name, collection_ids) # rubocop:disable GitlabSecurity/PublicSend end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/concerns/renders_projects_list.rb b/app/controllers/concerns/renders_projects_list.rb index 739b2be3fe9..2d37bc3f9a5 100644 --- a/app/controllers/concerns/renders_projects_list.rb +++ b/app/controllers/concerns/renders_projects_list.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true module RendersProjectsList + include RendersMemberAccess + def prepare_projects_for_rendering(projects) preload_max_member_access_for_collection(Project, projects) + current_user.preloaded_member_roles_for_projects(projects) if current_user # Call the count methods on every project, so the BatchLoader would load them all at # once when the entities are rendered diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index e53d0bc65a0..db756ae336f 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -5,7 +5,7 @@ module UploadsActions include Gitlab::Utils::StrongMemoize include SendFileUpload - UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon].freeze + UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon screenshot].freeze included do prepend_before_action :set_request_format_from_path_extension diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb index 95deacdc5b9..80c65948fff 100644 --- a/app/controllers/dashboard/application_controller.rb +++ b/app/controllers/dashboard/application_controller.rb @@ -14,3 +14,5 @@ class Dashboard::ApplicationController < ApplicationController @projects ||= current_user.authorized_projects.sorted_by_updated_desc.non_archived end end + +Dashboard::ApplicationController.prepend_mod diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 645b3eb9eb5..e26ac083622 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -66,8 +66,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end def load_projects(finder_params) - @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute - @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute + @all_user_projects = ProjectsFinder.new(params: { non_public: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute + @all_starred_projects = ProjectsFinder.new(params: { starred: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute finder_params[:use_cte] = true if use_cte_for_finder? diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 34745815f3d..eebcbe88ebf 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -88,8 +88,8 @@ class Explore::ProjectsController < Explore::ApplicationController private def load_project_counts - @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute - @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute + @all_user_projects = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute + @all_starred_projects = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute end def load_projects diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index 536c5e347e7..8a3183ba615 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -6,7 +6,7 @@ module GoogleApi before_action :validate_session_key! - feature_category :kubernetes_management + feature_category :deployment_management urgency :low ## diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 685c8292787..d614cc1cb24 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -16,6 +16,10 @@ class Groups::GroupMembersController < Groups::ApplicationController before_action :authorize_admin_group_member!, except: admin_not_required_endpoints before_action :authorize_read_group_member!, only: :index + before_action only: [:index] do + push_frontend_feature_flag(:service_accounts_crud, @group) + end + skip_before_action :check_two_factor_requirement, only: :leave skip_cross_project_access_check :index, :update, :destroy, :request_access, :approve_access_request, :leave, :resend_invite, :override diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 859bb0adb4e..f267c1cb857 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -2,14 +2,20 @@ class Groups::RunnersController < Groups::ApplicationController before_action :authorize_read_group_runners!, only: [:index, :show] + before_action :authorize_create_group_runners!, only: [:new, :register] before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume] - before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] + before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show, :register] + + before_action only: [:index] do + push_frontend_feature_flag(:create_runner_workflow_for_namespace, group) + end feature_category :runner urgency :low def index @group_runner_registration_token = @group.runners_token if can?(current_user, :register_group_runners, group) + @group_new_runner_path = new_group_runner_path(@group) if can?(current_user, :create_runner, group) Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group) end @@ -28,6 +34,16 @@ class Groups::RunnersController < Groups::ApplicationController end end + def new + render_404 unless create_runner_workflow_for_namespace_enabled? + + @group_runner_registration_token = @group.runners_token + end + + def register + render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available? + end + private def runner @@ -47,6 +63,16 @@ class Groups::RunnersController < Groups::ApplicationController render_404 end + + def authorize_create_group_runners! + return if can?(current_user, :create_runner, group) + + render_404 + end + + def create_runner_workflow_for_namespace_enabled? + Feature.enabled?(:create_runner_workflow_for_namespace, group) + end end Groups::RunnersController.prepend_mod diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb index 2bf5c95937b..3ae1ae824a0 100644 --- a/app/controllers/groups/settings/applications_controller.rb +++ b/app/controllers/groups/settings/applications_controller.rb @@ -46,10 +46,9 @@ module Groups @application.renew_secret if @application.save - flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.') - render :show + render json: { secret: @application.plaintext_secret } else - redirect_to group_settings_application_url(@group, @application) + render json: { errors: @application.errors }, status: :unprocessable_entity end end diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb index 4f858cd130a..125c8fde004 100644 --- a/app/controllers/groups/usage_quotas_controller.rb +++ b/app/controllers/groups/usage_quotas_controller.rb @@ -6,7 +6,7 @@ module Groups before_action :verify_usage_quotas_enabled! before_action :push_frontend_feature_flags - feature_category :subscription_cost_management + feature_category :consumables_cost_management urgency :low def index diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 7aea5e1a5c9..fad3a6ab9f5 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -6,7 +6,7 @@ module Groups skip_cross_project_access_check :show, :update - feature_category :pipeline_composition + feature_category :secrets_management urgency :low, [:show] diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index a0c82998108..b7578bcf465 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -32,18 +32,12 @@ class GroupsController < Groups::ApplicationController before_action :check_export_rate_limit!, only: [:export, :download_export] - before_action :track_experiment_event, only: [:new] - before_action only: :issues do push_frontend_feature_flag(:or_issuable_queries, group) push_frontend_feature_flag(:frontend_caching, group) push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?) end - before_action only: :show do - push_frontend_feature_flag(:show_group_readme, group) - end - helper_method :captcha_required? skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects @@ -402,12 +396,6 @@ class GroupsController < Groups::ApplicationController captcha_enabled? && !params[:parent_id] end - def track_experiment_event - return if params[:parent_id] - - experiment(:require_verification_for_namespace_creation, user: current_user).track(:start_create_group) - end - def group_feature_attributes [] end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index f0a80593926..bd0c0976729 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -11,6 +11,10 @@ class Import::GithubController < Import::BaseController before_action :provider_auth, only: [:status, :realtime_changes, :create] before_action :expire_etag_cache, only: [:status, :create] + before_action only: [:status] do + push_frontend_feature_flag(:import_details_page) + end + rescue_from Octokit::Unauthorized, with: :provider_unauthorized rescue_from Octokit::TooManyRequests, with: :provider_rate_limit rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded @@ -67,6 +71,10 @@ class Import::GithubController < Import::BaseController end end + def details + render_404 unless Feature.enabled?(:import_details_page) + end + def create result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider_name) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 0a2c98af8ec..8a8ae38c6f3 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -83,7 +83,7 @@ class InvitesController < ApplicationController def authenticate_user! return if current_user - store_location_for(:user, invite_landing_url) if member + store_location_for(:user, invite_details[:path]) if member if user_sign_up? set_session_invite_params @@ -120,10 +120,6 @@ class InvitesController < ApplicationController end end - def invite_landing_url - root_url + invite_details[:path] - end - def invite_details @invite_details ||= case member.source when Project diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 7a31738188a..2d5421f9f74 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -45,10 +45,9 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController @application.renew_secret if @application.save - flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.') - render :show + render json: { secret: @application.plaintext_secret } else - redirect_to oauth_application_url(@application) + render json: { errors: @application.errors }, status: :unprocessable_entity end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index daed4023d02..b9964e8ca01 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -183,7 +183,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController persist_accepted_terms_if_required(user) if new_user store_after_sign_up_path_for_user if intent_to_register? - sign_in_and_redirect_or_confirm_identity(user, auth_user, new_user) + sign_in_and_redirect_or_verify_identity(user, auth_user, new_user) end else fail_login(user) @@ -315,7 +315,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end # overridden in EE - def sign_in_and_redirect_or_confirm_identity(user, _, _) + def sign_in_and_redirect_or_verify_identity(user, _, _) sign_in_and_redirect(user, event: :authentication) end end diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb index ae757c30d1c..564a84a0829 100644 --- a/app/controllers/profiles/chat_names_controller.rb +++ b/app/controllers/profiles/chat_names_controller.rb @@ -11,6 +11,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController end def new + @integration_name = integration_name end def create @@ -65,4 +66,10 @@ class Profiles::ChatNamesController < Profiles::ApplicationController def chat_names @chat_names ||= current_user.chat_names end + + def integration_name + s_('Integrations|Mattermost slash commands') + end end + +Profiles::ChatNamesController.prepend_mod diff --git a/app/controllers/profiles/saved_replies_controller.rb b/app/controllers/profiles/comment_templates_controller.rb index 5ac5d645efb..d6725c27f76 100644 --- a/app/controllers/profiles/saved_replies_controller.rb +++ b/app/controllers/profiles/comment_templates_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Profiles - class SavedRepliesController < Profiles::ApplicationController + class CommentTemplatesController < Profiles::ApplicationController feature_category :user_profile before_action do diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 8f482cf6e2f..bc6e67a3a7d 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -169,18 +169,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController gon.push(webauthn: { options: options, app_id: u2f_app_id }) end - # Adds delete path to u2f registrations - # to reduce logic in view template - def u2f_registrations - current_user.u2f_registrations.map do |u2f_registration| - { - name: u2f_registration.name, - created_at: u2f_registration.created_at, - delete_path: profile_u2f_registration_path(u2f_registration) - } - end - end - def webauthn_registrations current_user.webauthn_registrations.map do |webauthn_registration| { @@ -235,10 +223,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController @qr_code = build_qr_code @account_string = account_string - if Feature.enabled?(:webauthn) - setup_webauthn_registration - else - setup_u2f_registration - end + setup_webauthn_registration end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 70487915707..da15b393e6c 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -10,9 +10,6 @@ class ProfilesController < Profiles::ApplicationController check_rate_limit!(:profile_update_username, scope: current_user) end skip_before_action :require_email, only: [:show, :update] - before_action do - push_frontend_feature_flag(:webauthn) - end feature_category :user_profile, [:show, :update, :reset_incoming_email_token, :reset_feed_token, :reset_static_object_token, :update_username] @@ -133,6 +130,7 @@ class ProfilesController < Profiles::ApplicationController :organization, :private_profile, :include_private_contributions, + :achievements_enabled, :timezone, :job_title, :pronouns, diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index d41b347dc5a..dbc82f5b314 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -8,62 +8,49 @@ class Projects::BlameController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_read_code! + before_action :load_blob feature_category :source_code_management urgency :low, [:show] def show - @blob = @repository.blob_at(@commit.id, @path) - - unless @blob - return redirect_to_tree_root_for_missing_path(@project, @ref, @path) - end - - environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } - environment_params[:find_latest] = true - @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last + load_environment + load_blame + end - permitted_params = params.permit(:page, :no_pagination, :streaming) - blame_service = Projects::BlameService.new(@blob, @commit, permitted_params) + def page + load_environment + load_blame - @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! + render partial: 'page' + end - @entire_blame_path = full_blame_path(no_pagination: true) - @blame_pages_url = blame_pages_url(permitted_params) - if blame_service.streaming_possible - @entire_blame_path = full_blame_path(streaming: true) - end + private - @streaming_enabled = blame_service.streaming_enabled - @blame_pagination = blame_service.pagination unless @streaming_enabled + def load_blob + @blob = @repository.blob_at(@commit.id, @path) - @blame_per_page = blame_service.per_page + return if @blob - render locals: { total_extra_pages: blame_service.total_extra_pages } + redirect_to_tree_root_for_missing_path(@project, @ref, @path) end - def page - @blob = @repository.blob_at(@commit.id, @path) - + def load_environment environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } environment_params[:find_latest] = true @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last - - blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :streaming)) - - @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! - - render partial: 'page' end - private + def load_blame + @blame_mode = Gitlab::Git::BlameMode.new(@commit.project, blame_params) + @blame_pagination = Gitlab::Git::BlamePagination.new(@blob, @blame_mode, blame_params) - def full_blame_path(params) - namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, **params) + blame = Gitlab::Blame.new(@blob, @commit, range: @blame_pagination.blame_range) + @blame = Gitlab::View::Presenter::Factory.new(blame, project: @project, path: @path, page: @blame_pagination.page).fabricate! end - def blame_pages_url(params) - namespace_project_blame_page_url(namespace_id: @project.namespace, project_id: @project, id: @id, **params) + def blame_params + params.permit(:page, :no_pagination, :streaming) end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 2d0c4a0a6c1..53c6676b62b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -49,7 +49,7 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) - push_frontend_feature_flag(:file_line_blame, @project) + push_frontend_feature_flag(:synchronize_fork, @project&.fork_source) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end @@ -101,6 +101,7 @@ class Projects::BlobController < Projects::ApplicationController ) rescue Files::UpdateService::FileChangedError @conflict = true + @different_project = different_project? render :edit end diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb index e0c9763abb6..bd58bd3e470 100644 --- a/app/controllers/projects/cluster_agents_controller.rb +++ b/app/controllers/projects/cluster_agents_controller.rb @@ -3,23 +3,15 @@ class Projects::ClusterAgentsController < Projects::ApplicationController include KasCookie - before_action :authorize_can_read_cluster_agent! + before_action :authorize_read_cluster_agent! before_action :set_kas_cookie, only: [:show], if: -> { current_user } - feature_category :kubernetes_management + feature_category :deployment_management urgency :low def show @agent_name = params[:name] end - - private - - def authorize_can_read_cluster_agent! - return if can?(current_user, :read_cluster, project) - - access_denied! - end end Projects::ClusterAgentsController.prepend_mod_with('Projects::ClusterAgentsController') diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index a86a0fb3bd2..8aca6a3fd5b 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -48,7 +48,11 @@ class Projects::CommitController < Projects::ApplicationController end def diff_files - render template: 'projects/commit/diff_files', layout: false, locals: { diffs: @diffs, environment: @environment } + respond_to do |format| + format.html do + render template: 'projects/commit/diff_files', layout: false, locals: { diffs: @diffs, environment: @environment } + end + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index dbed5adf2e8..da0bda19602 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -6,10 +6,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include CycleAnalyticsParams include GracefulTimeoutHandling include ProductAnalyticsTracking + include Gitlab::Utils::StrongMemoize extend ::Gitlab::Utils::Override before_action :authorize_read_cycle_analytics! - before_action :load_value_stream, only: :show track_event :show, name: 'p_analytics_valuestream', @@ -24,6 +24,11 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController push_licensed_feature(:cycle_analytics_for_groups) if project.licensed_feature_available?(:cycle_analytics_for_groups) push_licensed_feature(:group_level_analytics_dashboard) if project.licensed_feature_available?(:group_level_analytics_dashboard) push_frontend_feature_flag(:group_analytics_dashboards_page, @project.namespace) + + if project.licensed_feature_available?(:cycle_analytics_for_projects) + push_licensed_feature(:cycle_analytics_for_projects) + push_frontend_feature_flag(:vsa_group_and_project_parity, @project) + end end def show @@ -46,12 +51,13 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController override :all_cycle_analytics_params def all_cycle_analytics_params - super.merge({ namespace: @project.project_namespace, value_stream: @value_stream }) + super.merge({ namespace: @project.project_namespace, value_stream: value_stream }) end - def load_value_stream - @value_stream = Analytics::CycleAnalytics::ValueStream.build_default_value_stream(@project.project_namespace) + def value_stream + Analytics::CycleAnalytics::ValueStream.build_default_value_stream(@project.project_namespace) end + strong_memoize_attr :value_stream def cycle_analytics_json { @@ -69,3 +75,5 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController project end end + +Projects::CycleAnalyticsController.prepend_mod diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 22a42d22914..9cdbd2a30f6 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -82,7 +82,7 @@ class Projects::DeployKeysController < Projects::ApplicationController def create_params create_params = params.require(:deploy_key) - .permit(:key, :title, deploy_keys_projects_attributes: [:can_push]) + .permit(:key, :title, :expires_at, deploy_keys_projects_attributes: [:can_push]) create_params.dig(:deploy_keys_projects_attributes, '0')&.merge!(project_id: @project.id) create_params end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 8e4fbf24ca2..3842a88d15b 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -10,7 +10,6 @@ class Projects::IncidentsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) - push_frontend_feature_flag(:incident_event_tags, project) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 6e38de8b0ea..efe88d17cab 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -46,7 +46,8 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:preserve_unchanged_markdown, project) - push_frontend_feature_flag(:content_editor_on_issues, project) + push_frontend_feature_flag(:content_editor_on_issues, project&.group) + push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project) push_frontend_feature_flag(:saved_replies, current_user) end @@ -66,7 +67,6 @@ class Projects::IssuesController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:epic_widget_edit_confirmation, project) - push_frontend_feature_flag(:incident_event_tags, project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index be44c78ac9d..6d1b1ced4eb 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -7,6 +7,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont feature_category :code_review_workflow + before_action do + push_frontend_feature_flag(:content_editor_on_issues, project&.group) + push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) + end + private def merge_request diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a204023e34d..ef944dad6e9 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -33,15 +33,20 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show, :diffs] do - push_frontend_feature_flag(:content_editor_on_issues, project) + push_frontend_feature_flag(:content_editor_on_issues, project&.group) + push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) push_frontend_feature_flag(:core_security_mr_widget_counts, project) push_frontend_feature_flag(:issue_assignees_widget, @project) push_frontend_feature_flag(:refactor_security_extension, @project) - push_frontend_feature_flag(:refactor_code_quality_inline_findings, project) + push_frontend_feature_flag(:deprecate_vulnerabilities_feedback, @project) push_frontend_feature_flag(:moved_mr_sidebar, project) + push_frontend_feature_flag(:single_file_file_by_file, project) push_frontend_feature_flag(:mr_experience_survey, project) push_frontend_feature_flag(:realtime_mr_status_change, project) push_frontend_feature_flag(:saved_replies, current_user) + push_frontend_feature_flag(:code_quality_inline_drawer, project) + push_frontend_feature_flag(:hide_create_issue_resolve_all, project) + push_frontend_feature_flag(:auto_merge_labels_mr_widget, project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] @@ -264,6 +269,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo status = merge! + Gitlab::ApplicationContext.push(merge_action_status: status.to_s) + if @merge_request.merge_error render json: { status: status, merge_error: @merge_request.merge_error } else diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb index b702edb858e..e534000f494 100644 --- a/app/controllers/projects/ml/candidates_controller.rb +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -3,18 +3,29 @@ module Projects module Ml class CandidatesController < ApplicationController - before_action :check_feature_flag + before_action :check_feature_flag, :set_candidate feature_category :mlops - def show - @candidate = ::Ml::Candidate.with_project_id_and_iid(@project.id, params['iid']) + def show; end - render_404 unless @candidate.present? + def destroy + @experiment = @candidate.experiment + @candidate.destroy! + + redirect_to project_ml_experiment_path(@project, @experiment.iid), + status: :found, + notice: s_("MlExperimentTracking|Candidate removed") end private + def set_candidate + @candidate = ::Ml::Candidate.with_project_id_and_iid(@project.id, params['iid']) + + render_404 unless @candidate.present? + end + def check_feature_flag render_404 unless Feature.enabled?(:ml_experiment_tracking, @project) end diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb index 00b965542f6..dece3f98c57 100644 --- a/app/controllers/projects/ml/experiments_controller.rb +++ b/app/controllers/projects/ml/experiments_controller.rb @@ -6,6 +6,7 @@ module Projects include Projects::Ml::ExperimentsHelper before_action :check_feature_flag + before_action :set_experiment, only: [:show, :destroy] feature_category :mlops @@ -22,21 +23,34 @@ module Projects end def show - @experiment = ::Ml::Experiment.by_project_id_and_iid(@project.id, params[:id]) - - return redirect_to project_ml_experiments_path(@project) unless @experiment.present? - find_params = params .transform_keys(&:underscore) .permit(:name, :order_by, :sort, :order_by_type) - paginator = CandidateFinder - .new(@experiment, find_params) - .execute - .keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE) + finder = CandidateFinder.new(@experiment, find_params) - @candidates = paginator.records.each(&:artifact_lazy) - @page_info = page_info(paginator) + respond_to do |format| + format.csv do + csv_data = ::Ml::CandidatesCsvPresenter.new(finder.execute).present + + send_data(csv_data, type: 'text/csv; charset=utf-8', filename: 'candidates.csv') + end + + format.html do + paginator = finder.execute.keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE) + + @candidates = paginator.records + @page_info = page_info(paginator) + end + end + end + + def destroy + @experiment.destroy + + redirect_to project_ml_experiments_path(@project), + status: :found, + notice: s_("MlExperimentTracking|Experiment removed") end private @@ -44,6 +58,12 @@ module Projects def check_feature_flag render_404 unless Feature.enabled?(:ml_experiment_tracking, @project) end + + def set_experiment + @experiment = ::Ml::Experiment.by_project_id_and_iid(@project.id, params[:iid]) + + render_404 unless @experiment + end end end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 13c2a3ab750..332d33b8e52 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -77,7 +77,7 @@ class Projects::PagesController < Projects::ApplicationController def project_params_attributes attributes = %i[pages_https_only] - return attributes unless Feature.enabled?(:pages_unique_domain) + return attributes unless Feature.enabled?(:pages_unique_domain, @project) attributes + [ project_setting_attributes: [ diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 6fdd4906613..a8107a46b4f 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -9,20 +9,19 @@ class Projects::PipelinesController < Projects::ApplicationController urgency :low, [ :index, :new, :builds, :show, :failures, :create, :stage, :retry, :dag, :cancel, :test_report, - :charts, :config_variables, :destroy, :status + :charts, :destroy, :status ] before_action :disable_query_limiting, only: [:create, :retry] - before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables] + before_action :pipeline, except: [:index, :new, :create, :charts] before_action :set_pipeline_path, only: [:show] before_action :authorize_read_pipeline! before_action :authorize_read_build!, only: [:index, :show] before_action :authorize_read_ci_cd_analytics!, only: [:charts] - before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables] + before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] - before_action :push_frontend_feature_flags, only: [:show] # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } @@ -46,7 +45,7 @@ class Projects::PipelinesController < Projects::ApplicationController POLLING_INTERVAL = 10_000 feature_category :continuous_integration, [ - :charts, :show, :config_variables, :stage, :cancel, :retry, + :charts, :show, :stage, :cancel, :retry, :builds, :dag, :failures, :status, :index, :create, :new, :destroy ] @@ -62,9 +61,7 @@ class Projects::PipelinesController < Projects::ApplicationController @pipelines_count = limited_pipelines_count(project) respond_to do |format| - format.html do - enable_runners_availability_section_experiment - end + format.html format.json do Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) @@ -217,18 +214,6 @@ class Projects::PipelinesController < Projects::ApplicationController end end - def config_variables - respond_to do |format| - format.json do - # Even if the parameter name is `sha`, it is actually a ref name. We always send `ref` to the endpoint. - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/389065 - result = Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha]) - - result.nil? ? head(:no_content) : render(json: result) - end - end - end - def downloadable_artifacts render json: Ci::DownloadableArtifactSerializer.new( project: project, @@ -246,7 +231,7 @@ class Projects::PipelinesController < Projects::ApplicationController @pipelines, disable_coverage: true, preload: true, - disable_manual_and_scheduled_actions: Feature.enabled?(:lazy_load_pipeline_dropdown_actions, @project) + disable_manual_and_scheduled_actions: true ) end @@ -332,17 +317,6 @@ class Projects::PipelinesController < Projects::ApplicationController params.permit(:scope, :username, :ref, :status, :source) end - def enable_runners_availability_section_experiment - return unless current_user - return unless can?(current_user, :create_pipeline, project) - return if @pipelines_count.to_i > 0 - return if helpers.has_gitlab_ci?(project) - - experiment(:runners_availability_section, namespace: project.root_ancestor) do |e| - e.candidate {} - end - end - def should_track_ci_cd_pipelines? params[:chart].blank? || params[:chart] == 'pipelines' end @@ -370,10 +344,6 @@ class Projects::PipelinesController < Projects::ApplicationController def tracking_project_source project end - - def push_frontend_feature_flags - push_frontend_feature_flag(:refactor_ci_minutes_consumption, @project) - 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 543ffa637e1..f4b96177b0f 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -47,7 +47,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def membershipable_members - query_members_via_project_namespace_enabled? ? project.namespace_members : project.members + project.namespace_members end def plain_source_type @@ -67,15 +67,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def members_and_requesters - query_members_via_project_namespace_enabled? ? project.namespace_members_and_requesters : super + project.namespace_members_and_requesters end def requesters - query_members_via_project_namespace_enabled? ? project.namespace_requesters : super - end - - def query_members_via_project_namespace_enabled? - Feature.enabled?(:project_members_index_by_project_namespace, project) + project.namespace_requesters end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index f5588a35ad5..28ae730eda5 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -14,6 +14,7 @@ module Projects before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) + push_frontend_feature_flag(:ci_limit_environment_scope, @project) end helper_method :highlight_badge @@ -131,7 +132,7 @@ module Projects @shared_runners_count = active_shared_runners.count @shared_runners = active_shared_runners.page(params[:shared_runners_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags - parent_group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id) + parent_group_runners = ::Ci::Runner.belonging_to_parent_groups_of_project(@project.id) @group_runners_count = parent_group_runners.count @group_runners = parent_group_runners.page(params[:group_runners_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index a8b54933487..0631c02355e 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -18,7 +18,7 @@ class Projects::TreeController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) - push_frontend_feature_flag(:file_line_blame, @project) + push_frontend_feature_flag(:synchronize_fork, @project.fork_source) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb index d3757eaf481..7037cf8811a 100644 --- a/app/controllers/projects/usage_quotas_controller.rb +++ b/app/controllers/projects/usage_quotas_controller.rb @@ -1,14 +1,18 @@ # frozen_string_literal: true -class Projects::UsageQuotasController < Projects::ApplicationController - before_action :authorize_read_usage_quotas! +module Projects + class UsageQuotasController < Projects::ApplicationController + before_action :authorize_read_usage_quotas! - layout "project_settings" + layout "project_settings" - feature_category :subscription_cost_management - urgency :low + feature_category :consumables_cost_management + urgency :low - def index - @hide_search_settings = true + def index + @hide_search_settings = true + end end end + +Projects::UsageQuotasController.prepend_mod diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index e50ddf75183..f7542d68642 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -3,7 +3,7 @@ class Projects::VariablesController < Projects::ApplicationController before_action :authorize_admin_build! - feature_category :pipeline_composition + feature_category :secrets_management urgency :low, [:show, :update] diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb index 34a71dbbb91..7da31c199a1 100644 --- a/app/controllers/projects/work_items_controller.rb +++ b/app/controllers/projects/work_items_controller.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true class Projects::WorkItemsController < Projects::ApplicationController + include WorkhorseAuthorization + extend Gitlab::Utils::Override + + EXTENSION_ALLOWLIST = %w[csv].map(&:downcase).freeze + + before_action :authorize_import_access!, only: [:import_csv, :authorize] # rubocop:disable Rails/LexicallyScopedActionFilter before_action do push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) @@ -9,7 +15,57 @@ class Projects::WorkItemsController < Projects::ApplicationController end feature_category :team_planning + urgency :high, [:authorize] urgency :low + + def import_csv + file = import_params[:file] + return render json: { errors: invalid_file_message }, status: :bad_request unless file_is_valid?(file) + + result = WorkItems::PrepareImportCsvService.new(project, current_user, file: file).execute + + if result.status == :error + render json: { errors: result.message }, status: :bad_request + else + render json: { message: result.message }, status: :ok + end + end + + private + + def import_params + params.permit(:file) + end + + def authorize_import_access! + can_import = can?(current_user, :import_work_items, project) + import_csv_feature_available = Feature.enabled?(:import_export_work_items_csv, project) + return if can_import && import_csv_feature_available + + if current_user || action_name == 'authorize' + render_404 + else + authenticate_user! + end + end + + def invalid_file_message + supported_file_extensions = ".#{EXTENSION_ALLOWLIST.join(', .')}" + format(_("The uploaded file was invalid. Supported file extensions are %{extensions}."), + { extensions: supported_file_extensions }) + end + + def uploader_class + FileUploader + end + + def maximum_size + Gitlab::CurrentSettings.max_attachment_size.megabytes + end + + def file_extension_allowlist + EXTENSION_ALLOWLIST + end end Projects::WorkItemsController.prepend_mod diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c12caecdc23..a6bc754d09e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -38,8 +38,7 @@ class ProjectsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) - push_frontend_feature_flag(:file_line_blame, @project) - push_frontend_feature_flag(:synchronize_fork, @project) + push_frontend_feature_flag(:synchronize_fork, @project&.fork_source) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) @@ -209,7 +208,7 @@ class ProjectsController < Projects::ApplicationController end def new_issuable_address - return render_404 unless Gitlab::IncomingEmail.supports_issue_creation? + return render_404 unless Gitlab::Email::IncomingEmail.supports_issue_creation? current_user.reset_incoming_email_token! render json: { new_address: @project.new_issuable_address(current_user, params[:issuable_type]) } diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index b4eee3549a0..70698c0dcb2 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -40,6 +40,7 @@ class RegistrationsController < Devise::RegistrationsController set_resource_fields super do |new_user| + record_arkose_data accept_pending_invitations if new_user.persisted? persist_accepted_terms_if_required(new_user) @@ -135,8 +136,10 @@ class RegistrationsController < Devise::RegistrationsController # after user confirms and comes back, he will be redirected store_location_for(:redirect, after_sign_up_path) - if custom_confirmation_enabled? + if identity_verification_enabled? session[:verification_user_id] = resource.id # This is needed to find the user on the identity verification page + User.sticking.stick_or_unstick_request(request.env, :user, resource.id) + return identity_verification_redirect_path end @@ -290,11 +293,16 @@ class RegistrationsController < Devise::RegistrationsController current_user end - def identity_verification_redirect_path + def record_arkose_data # overridden by EE module end - def custom_confirmation_enabled? + def identity_verification_enabled? + # overridden by EE module + false + end + + def identity_verification_redirect_path # overridden by EE module end diff --git a/app/controllers/time_tracking/timelogs_controller.rb b/app/controllers/time_tracking/timelogs_controller.rb new file mode 100644 index 00000000000..a2cac071796 --- /dev/null +++ b/app/controllers/time_tracking/timelogs_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module TimeTracking + class TimelogsController < ApplicationController + feature_category :team_planning + urgency :low + + def index + render_404 unless Feature.enabled?(:global_time_tracking_report, current_user) + end + end +end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index ea99aa12350..1a966739401 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -16,6 +16,7 @@ class UploadsController < ApplicationController "projects/topic" => Projects::Topic, 'alert_management_metric_image' => ::AlertManagement::MetricImage, "achievements/achievement" => Achievements::Achievement, + "abuse_report" => AbuseReport, nil => PersonalSnippet }.freeze diff --git a/app/controllers/users/pins_controller.rb b/app/controllers/users/pins_controller.rb new file mode 100644 index 00000000000..81709dd4a2b --- /dev/null +++ b/app/controllers/users/pins_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Users + class PinsController < ApplicationController + feature_category :navigation + respond_to :json + + def update + panel = pins_params[:panel] + pinned_nav_items = current_user.pinned_nav_items.merge({ panel => pins_params[:menu_item_ids] }) + if current_user.update(pinned_nav_items: pinned_nav_items) + render json: current_user.pinned_nav_items[panel].to_json + else + head :bad_request + end + end + + private + + def pins_params + params.permit(:panel, menu_item_ids: []) + end + end +end diff --git a/app/events/packages/package_created_event.rb b/app/events/packages/package_created_event.rb new file mode 100644 index 00000000000..5818a1ad19f --- /dev/null +++ b/app/events/packages/package_created_event.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Packages + class PackageCreatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'name' => { 'type' => 'string' }, + 'version' => { 'type' => %w[string null] }, + 'package_type' => { 'type' => 'string', 'enum' => ::Packages::Package.package_types.keys }, + 'id' => { 'type' => 'integer' } + }, + 'required' => %w[project_id id name package_type] + } + end + + def generic? + data[:package_type] == 'generic' + end + end +end diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb deleted file mode 100644 index 914c5c4a29e..00000000000 --- a/app/experiments/require_verification_for_namespace_creation_experiment.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment - control { false } - candidate { true } - - exclude :existing_user - - EXPERIMENT_START_DATE = Date.new(2022, 1, 31) - - def candidate? - run - end - - private - - def existing_user - return false unless user_or_actor - - user_or_actor.created_at < EXPERIMENT_START_DATE - end -end diff --git a/app/experiments/security_actions_continuous_onboarding_experiment.rb b/app/experiments/security_actions_continuous_onboarding_experiment.rb deleted file mode 100644 index 6adfbedc744..00000000000 --- a/app/experiments/security_actions_continuous_onboarding_experiment.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class SecurityActionsContinuousOnboardingExperiment < ApplicationExperiment - def control_behavior - end - - def candidate_behavior - end -end diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb deleted file mode 100644 index 0a5778950fa..00000000000 --- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment - control {} - candidate {} -end diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb index c3159198261..6a6d0413194 100644 --- a/app/finders/abuse_reports_finder.rb +++ b/app/finders/abuse_reports_finder.rb @@ -3,6 +3,7 @@ class AbuseReportsFinder attr_reader :params, :reports + DEFAULT_STATUS_FILTER = 'open' DEFAULT_SORT = 'created_at_desc' ALLOWED_SORT = [DEFAULT_SORT, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze @@ -30,9 +31,13 @@ class AbuseReportsFinder end def filter_by_status + return unless Feature.enabled?(:abuse_reports_list) return unless params[:status].present? - case params[:status] + status = params[:status] + status = DEFAULT_STATUS_FILTER unless status.in?(AbuseReport.statuses.keys) + + case status when 'open' @reports = @reports.open when 'closed' diff --git a/app/finders/access_requests_finder.rb b/app/finders/access_requests_finder.rb index 7b98df68f29..140d68cfe91 100644 --- a/app/finders/access_requests_finder.rb +++ b/app/finders/access_requests_finder.rb @@ -18,11 +18,7 @@ class AccessRequestsFinder def execute!(current_user) raise Gitlab::Access::AccessDeniedError unless can_see_access_requests?(current_user) - if Feature.enabled?(:project_members_index_by_project_namespace, source) - source.namespace_requesters - else - source.requesters - end + source.namespace_requesters end private diff --git a/app/finders/achievements/achievements_finder.rb b/app/finders/achievements/achievements_finder.rb new file mode 100644 index 00000000000..98bd12afcd4 --- /dev/null +++ b/app/finders/achievements/achievements_finder.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Achievements + class AchievementsFinder + attr_reader :namespace, :params + + def initialize(namespace, params = {}) + @namespace = namespace + @params = params + end + + def execute + achievements = namespace.achievements + by_ids(achievements) + end + + private + + def by_ids(achievements) + return achievements unless ids? + + achievements.id_in(params[:ids]) + end + + def ids? + params[:ids].present? + end + end +end diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index 99e68991836..7ecf5c98ac0 100644 --- a/app/finders/autocomplete/users_finder.rb +++ b/app/finders/autocomplete/users_finder.rb @@ -11,8 +11,8 @@ module Autocomplete LIMIT = 20 attr_reader :current_user, :project, :group, :search, :skip_users, - :author_id, :todo_filter, :todo_state_filter, - :filter_by_current_user, :states + :author_id, :todo_filter, :todo_state_filter, + :filter_by_current_user, :states def initialize(params:, current_user:, project:, group:) @current_user = current_user diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index bc1dcb3ad5f..5f03ae77338 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -74,7 +74,7 @@ module Ci end def project_runners - raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_project, @project) + raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :read_project_runners, @project) @runners = ::Ci::Runner.owned_or_instance_wide(@project.id) end diff --git a/app/finders/clusters/agent_authorizations_finder.rb b/app/finders/clusters/agent_authorizations_finder.rb deleted file mode 100644 index 70c0868cc7f..00000000000 --- a/app/finders/clusters/agent_authorizations_finder.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module Clusters - class AgentAuthorizationsFinder - def initialize(project) - @project = project - end - - def execute - # closest, most-specific authorization for a given agent wins - (project_authorizations + implicit_authorizations + group_authorizations) - .uniq(&:agent_id) - end - - private - - attr_reader :project - - def implicit_authorizations - project.cluster_agents.map do |agent| - Clusters::Agents::ImplicitAuthorization.new(agent: agent) - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def project_authorizations - namespace_ids = project.group ? all_namespace_ids : project.namespace_id - - Clusters::Agents::ProjectAuthorization - .where(project_id: project.id) - .joins(agent: :project) - .preload(agent: :project) - .where(cluster_agents: { projects: { namespace_id: namespace_ids } }) - .with_available_ci_access_fields(project) - .to_a - end - - def group_authorizations - return [] unless project.group - - authorizations = Clusters::Agents::GroupAuthorization.arel_table - - ordered_ancestors_cte = Gitlab::SQL::CTE.new( - :ordered_ancestors, - project.group.self_and_ancestors(hierarchy_order: :asc).reselect(:id) - ) - - cte_join_sources = authorizations.join(ordered_ancestors_cte.table).on( - authorizations[:group_id].eq(ordered_ancestors_cte.table[:id]) - ).join_sources - - Clusters::Agents::GroupAuthorization - .with(ordered_ancestors_cte.to_arel) - .joins(cte_join_sources) - .joins(agent: :project) - .with_available_ci_access_fields(project) - .where(projects: { namespace_id: all_namespace_ids }) - .order(Arel.sql('agent_id, array_position(ARRAY(SELECT id FROM ordered_ancestors)::bigint[], agent_group_authorizations.group_id)')) - .select('DISTINCT ON (agent_id) agent_group_authorizations.*') - .preload(agent: :project) - .to_a - end - # rubocop: enable CodeReuse/ActiveRecord - - def all_namespace_ids - project.root_ancestor.self_and_descendants.select(:id) - end - end -end diff --git a/app/finders/clusters/agent_tokens_finder.rb b/app/finders/clusters/agent_tokens_finder.rb index 72692777bc6..0e777564db5 100644 --- a/app/finders/clusters/agent_tokens_finder.rb +++ b/app/finders/clusters/agent_tokens_finder.rb @@ -11,7 +11,7 @@ module Clusters end def execute - return ::Clusters::AgentToken.none unless can_read_cluster_agents? + return ::Clusters::AgentToken.none unless can_read_cluster_agent? agent.agent_tokens.then { |agent_tokens| by_status(agent_tokens) } end @@ -24,8 +24,8 @@ module Clusters params[:status].present? ? agent_tokens.with_status(params[:status]) : agent_tokens end - def can_read_cluster_agents? - current_user&.can?(:read_cluster, agent&.project) + def can_read_cluster_agent? + current_user&.can?(:read_cluster_agent, agent) end end end diff --git a/app/finders/clusters/agents/authorizations/ci_access/finder.rb b/app/finders/clusters/agents/authorizations/ci_access/finder.rb new file mode 100644 index 00000000000..97d378669a4 --- /dev/null +++ b/app/finders/clusters/agents/authorizations/ci_access/finder.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + class Finder + def initialize(project) + @project = project + end + + def execute + # closest, most-specific authorization for a given agent wins + (project_authorizations + implicit_authorizations + group_authorizations) + .uniq(&:agent_id) + end + + private + + attr_reader :project + + def implicit_authorizations + project.cluster_agents.map do |agent| + Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization.new(agent: agent) + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def project_authorizations + namespace_ids = project.group ? all_namespace_ids : project.namespace_id + + Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization + .where(project_id: project.id) + .joins(agent: :project) + .preload(agent: :project) + .where(cluster_agents: { projects: { namespace_id: namespace_ids } }) + .with_available_ci_access_fields(project) + .to_a + end + + def group_authorizations + return [] unless project.group + + authorizations = Clusters::Agents::Authorizations::CiAccess::GroupAuthorization.arel_table + + ordered_ancestors_cte = Gitlab::SQL::CTE.new( + :ordered_ancestors, + project.group.self_and_ancestors(hierarchy_order: :asc).reselect(:id) + ) + + cte_join_sources = authorizations.join(ordered_ancestors_cte.table).on( + authorizations[:group_id].eq(ordered_ancestors_cte.table[:id]) + ).join_sources + + Clusters::Agents::Authorizations::CiAccess::GroupAuthorization + .with(ordered_ancestors_cte.to_arel) + .joins(cte_join_sources) + .joins(agent: :project) + .with_available_ci_access_fields(project) + .where(projects: { namespace_id: all_namespace_ids }) + .order(Arel.sql('agent_id, array_position(ARRAY(SELECT id FROM ordered_ancestors)::bigint[], agent_group_authorizations.group_id)')) + .select('DISTINCT ON (agent_id) agent_group_authorizations.*') + .preload(agent: :project) + .to_a + end + # rubocop: enable CodeReuse/ActiveRecord + + def all_namespace_ids + project.root_ancestor.self_and_descendants.select(:id) + end + end + end + end + end +end diff --git a/app/finders/clusters/agents_finder.rb b/app/finders/clusters/agents_finder.rb index 14277db3f85..0cdebe93f32 100644 --- a/app/finders/clusters/agents_finder.rb +++ b/app/finders/clusters/agents_finder.rb @@ -29,7 +29,7 @@ module Clusters end def can_read_cluster_agents? - current_user&.can?(:read_cluster, object) + current_user&.can?(:read_cluster_agent, object) end end end diff --git a/app/finders/concerns/finder_with_group_hierarchy.rb b/app/finders/concerns/finder_with_group_hierarchy.rb index 4ced544ba2c..70c38f00f72 100644 --- a/app/finders/concerns/finder_with_group_hierarchy.rb +++ b/app/finders/concerns/finder_with_group_hierarchy.rb @@ -27,11 +27,8 @@ module FinderWithGroupHierarchy # we can preset root group for all of them to optimize permission checks Group.preset_root_ancestor_for(groups) - # Preloading the max access level for the given groups to avoid N+1 queries - # during the access check. - if !skip_authorization && current_user && Feature.enabled?(:preload_max_access_levels_for_labels_finder, group) - Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute - end + preload_associations(groups) if !skip_authorization && current_user && Feature.enabled?( + :preload_max_access_levels_for_labels_finder, group) groups_user_can_read_items(groups).map(&:id) end @@ -77,4 +74,10 @@ module FinderWithGroupHierarchy groups.select { |group| authorized_to_read_item?(group) } end end + + def preload_associations(groups) + Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute + end end + +FinderWithGroupHierarchy.prepend_mod_with('FinderWithGroupHierarchy') diff --git a/app/finders/concerns/updated_at_filter.rb b/app/finders/concerns/updated_at_filter.rb index 2d6bd7bf9f3..0e9a3fb5e8c 100644 --- a/app/finders/concerns/updated_at_filter.rb +++ b/app/finders/concerns/updated_at_filter.rb @@ -2,8 +2,12 @@ module UpdatedAtFilter def by_updated_at(items) - items = items.updated_before(params[:updated_before]) if params[:updated_before].present? - items = items.updated_after(params[:updated_after]) if params[:updated_after].present? + updated_before = params[:updated_before]&.in_time_zone + updated_after = params[:updated_after]&.in_time_zone + return items.none if [updated_before, updated_after].all?(&:present?) && updated_before < updated_after + + items = items.updated_before(updated_before) if updated_before.present? + items = items.updated_after(updated_after) if updated_after.present? items end diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb index 4a45817cc61..a186ca92c7b 100644 --- a/app/finders/context_commits_finder.rb +++ b/app/finders/context_commits_finder.rb @@ -21,20 +21,24 @@ class ContextCommitsFinder attr_reader :project, :merge_request, :search, :author, :committed_before, :committed_after, :limit def init_collection - if search.present? + if search_params_present? search_commits else project.repository.commits(merge_request.target_branch, { limit: limit }) end end + def search_params_present? + [search, author, committed_before, committed_after].map(&:present?).any? + end + def filter_existing_commits(commits) commits.select! { |commit| already_included_ids.exclude?(commit.id) } commits end def search_commits - key = search.strip + key = search&.strip commits = [] if Commit.valid_hash?(key) mr_existing_commits_ids = merge_request.commits.map(&:id) diff --git a/app/finders/data_transfer/group_data_transfer_finder.rb b/app/finders/data_transfer/group_data_transfer_finder.rb new file mode 100644 index 00000000000..19ab99d4477 --- /dev/null +++ b/app/finders/data_transfer/group_data_transfer_finder.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module DataTransfer + class GroupDataTransferFinder + def initialize(group:, from:, to:, user:) + @group = group + @from = from + @to = to + @user = user + end + + def execute + return ::Projects::DataTransfer.none unless Ability.allowed?(user, :read_usage_quotas, group) + + ::Projects::DataTransfer + .with_namespace_between_dates(group, from, to) + .select('SUM(repository_egress + + artifacts_egress + + packages_egress + + registry_egress + ) as total_egress, + SUM(repository_egress) as repository_egress, + SUM(artifacts_egress) as artifacts_egress, + SUM(packages_egress) as packages_egress, + SUM(registry_egress) as registry_egress, + date, + namespace_id') + end + + private + + attr_reader :group, :from, :to, :user + end +end diff --git a/app/finders/data_transfer/mocked_transfer_finder.rb b/app/finders/data_transfer/mocked_transfer_finder.rb new file mode 100644 index 00000000000..9c5551005ea --- /dev/null +++ b/app/finders/data_transfer/mocked_transfer_finder.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Mocked data for data transfer +# Follow this epic for recent progress: https://gitlab.com/groups/gitlab-org/-/epics/9330 +module DataTransfer + class MockedTransferFinder + def execute + start_date = Date.new(2023, 0o1, 0o1) + date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') } + + 0.upto(11).map do |i| + { + date: date_for_index.call(i), + repository_egress: rand(70000..550000), + artifacts_egress: rand(70000..550000), + packages_egress: rand(70000..550000), + registry_egress: rand(70000..550000) + }.tap do |hash| + hash[:total_egress] = hash + .slice(:repository_egress, :artifacts_egress, :packages_egress, :registry_egress) + .values + .sum + end + end + end + end +end diff --git a/app/finders/data_transfer/project_data_transfer_finder.rb b/app/finders/data_transfer/project_data_transfer_finder.rb new file mode 100644 index 00000000000..bcabbdb00a5 --- /dev/null +++ b/app/finders/data_transfer/project_data_transfer_finder.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module DataTransfer + class ProjectDataTransferFinder + def initialize(project:, from:, to:, user:) + @project = project + @from = from + @to = to + @user = user + end + + def execute + return ::Projects::DataTransfer.none unless Ability.allowed?(user, :read_usage_quotas, project) + + ::Projects::DataTransfer + .with_project_between_dates(project, from, to) + .select(:project_id, :date, :repository_egress, :artifacts_egress, :packages_egress, :registry_egress, + "repository_egress + artifacts_egress + packages_egress + registry_egress as total_egress") + end + + private + + attr_reader :project, :from, :to, :user + end +end diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index c5f8510ca16..3d40da78dbc 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -52,10 +52,6 @@ class DeploymentsFinder private - def raise_for_inefficient_updated_at_query? - params.fetch(:raise_for_inefficient_updated_at_query, Rails.env.development? || Rails.env.test?) - end - def validate! if filter_by_updated_at? && filter_by_finished_at? raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified' @@ -68,7 +64,7 @@ class DeploymentsFinder Gitlab::ErrorTracking.log_exception(error) - raise error if raise_for_inefficient_updated_at_query? + raise error if Feature.enabled?(:deployments_raise_updated_at_inefficient_error) end if filter_by_finished_at? && !order_by_finished_at? diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb index c1769ea28f9..a96acd5838e 100644 --- a/app/finders/fork_targets_finder.rb +++ b/app/finders/fork_targets_finder.rb @@ -24,7 +24,7 @@ class ForkTargetsFinder def fork_targets(options) if options[:only_groups] - user.manageable_groups(include_groups_with_developer_maintainer_access: true) + Groups::AcceptingProjectCreationsFinder.new(user).execute # rubocop: disable CodeReuse/Finder else user.forkable_namespaces.sort_by_type end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 033af0f42a6..07f39f98b12 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -64,9 +64,7 @@ class GroupDescendantsFinder def direct_child_groups # rubocop: disable CodeReuse/Finder - GroupsFinder.new(current_user, - parent: parent_group, - all_available: true).execute + GroupsFinder.new(current_user, parent: parent_group, all_available: true).execute # rubocop: enable CodeReuse/Finder end @@ -78,12 +76,11 @@ class GroupDescendantsFinder .in(Gitlab::VisibilityLevel.levels_for_user(current_user)) if current_user - authorized_groups = GroupsFinder.new(current_user, - all_available: false) - .execute.arel.as('authorized') + authorized_groups = GroupsFinder.new(current_user, all_available: false) + .execute.arel.as('authorized') authorized_to_user = groups_table.project(1).from(authorized_groups) - .where(authorized_groups[:id].eq(groups_table[:id])) - .exists + .where(authorized_groups[:id].eq(groups_table[:id])) + .exists visible_to_user = visible_to_user.or(authorized_to_user) end @@ -161,9 +158,11 @@ class GroupDescendantsFinder projects_nested_in_group = Project.where(namespace_id: parent_group.self_and_descendants.as_ids) params_with_search = params.merge(search: params[:filter]) - ProjectsFinder.new(params: params_with_search, - current_user: current_user, - project_ids_relation: projects_nested_in_group).execute + ProjectsFinder.new( + params: params_with_search, + current_user: current_user, + project_ids_relation: projects_nested_in_group + ).execute # rubocop: enable CodeReuse/Finder end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 05645dacab9..1025e0ebc9b 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -30,7 +30,11 @@ class GroupMembersFinder < UnionFinder def execute(include_relations: DEFAULT_RELATIONS) groups = groups_by_relations(include_relations) - members = all_group_members(groups).distinct_on_user_with_max_access_level + shared_from_groups = if include_relations&.include?(:shared_from_groups) + Group.shared_into_ancestors(group).public_or_visible_to_user(user) + end + + members = all_group_members(groups, shared_from_groups).distinct_on_user_with_max_access_level filter_members(members) end @@ -47,9 +51,8 @@ class GroupMembersFinder < UnionFinder related_groups << Group.by_id(group.id) if include_relations&.include?(:direct) related_groups << group.ancestors if include_relations&.include?(:inherited) related_groups << group.descendants if include_relations&.include?(:descendants) - related_groups << Group.shared_into_ancestors(group).public_or_visible_to_user(user) if include_relations&.include?(:shared_from_groups) - find_union(related_groups, Group) + related_groups end def filter_members(members) @@ -78,12 +81,49 @@ class GroupMembersFinder < UnionFinder group.members end - def all_group_members(groups) - members_of_groups(groups).non_minimal_access + def all_group_members(groups, shared_from_groups) + members_of_groups(groups, shared_from_groups).non_minimal_access + end + + def members_of_groups(groups, shared_from_groups) + if Feature.disabled?(:members_with_shared_group_access, @group.root_ancestor) + groups << shared_from_groups unless shared_from_groups.nil? + return GroupMember.non_request.of_groups(find_union(groups, Group)) + end + + members = GroupMember.non_request.of_groups(find_union(groups, Group)) + return members if shared_from_groups.nil? + + shared_members = GroupMember.non_request.of_groups(shared_from_groups) + select_attributes = GroupMember.attribute_names + members_shared_with_group_access = members_shared_with_group_access(shared_members, select_attributes) + + # `members` and `members_shared_with_group_access` should have even select values + find_union([members.select(select_attributes), members_shared_with_group_access], GroupMember) + end + + def members_shared_with_group_access(shared_members, select_attributes) + group_group_link_table = GroupGroupLink.arel_table + group_member_table = GroupMember.arel_table + + member_columns = select_attributes.map do |column_name| + if column_name == 'access_level' + args = [group_group_link_table[:group_access], group_member_table[:access_level]] + smallest_value_arel(args, 'access_level') + else + group_member_table[column_name] + end + end + + # rubocop:disable CodeReuse/ActiveRecord + shared_members + .joins("LEFT OUTER JOIN group_group_links ON members.source_id = group_group_links.shared_with_group_id") + .select(member_columns) + # rubocop:enable CodeReuse/ActiveRecord end - def members_of_groups(groups) - GroupMember.non_request.of_groups(groups) + def smallest_value_arel(args, column_alias) + Arel::Nodes::As.new(Arel::Nodes::NamedFunction.new('LEAST', args), Arel::Nodes::SqlLiteral.new(column_alias)) end def check_relation_arguments!(include_relations) diff --git a/app/finders/groups/accepting_project_creations_finder.rb b/app/finders/groups/accepting_project_creations_finder.rb new file mode 100644 index 00000000000..a7057b3f672 --- /dev/null +++ b/app/finders/groups/accepting_project_creations_finder.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Groups + class AcceptingProjectCreationsFinder + def initialize(current_user) + @current_user = current_user + end + + def execute + if Feature.disabled?(:include_groups_from_group_shares_in_project_creation_locations) + return current_user.manageable_groups(include_groups_with_developer_maintainer_access: true) + end + + groups_accepting_project_creations = + [ + current_user + .manageable_groups(include_groups_with_developer_maintainer_access: true) + .project_creation_allowed, + owner_maintainer_groups_originating_from_group_shares + .project_creation_allowed, + *developer_groups_originating_from_group_shares + ] + + # We move the UNION query into a materialized CTE to improve query performance during text search. + union_query = ::Group.from_union(groups_accepting_project_creations) + cte = Gitlab::SQL::CTE.new(:my_union_cte, union_query) + + Group.with(cte.to_arel).from(cte.alias_to(Group.arel_table)) # rubocop: disable CodeReuse/ActiveRecord + end + + private + + attr_reader :current_user + + def owner_maintainer_groups_originating_from_group_shares + GroupGroupLink + .with_owner_or_maintainer_access + .groups_accessible_via( + groups_that_user_has_owner_or_maintainer_access_via_direct_membership + .select(:id) + ) + end + + def groups_that_user_has_owner_or_maintainer_access_via_direct_membership + current_user.owned_or_maintainers_groups + end + + def developer_groups_originating_from_group_shares + # Example: + # + # Group A -----shared to---> Group B + # + + # Now, there are 2 ways a user in Group A can get "Developer" access to Group B (and it's subgroups) + [ + # 1. User has Developer or above access in Group A, + # but the group_group_link has MAX access level set to Developer + GroupGroupLink + .with_developer_access + .groups_accessible_via( + groups_that_user_has_developer_access_and_above_via_direct_membership + .select(:id) + ).with_project_creation_levels(project_creations_levels_allowing_developers_to_create_projects), + + # 2. User has exactly Developer access in Group A, + # but the group_group_link has MAX access level set to Developer or above. + GroupGroupLink + .with_developer_maintainer_owner_access + .groups_accessible_via( + groups_that_user_has_developer_access_via_direct_membership + .select(:id) + ).with_project_creation_levels(project_creations_levels_allowing_developers_to_create_projects) + ] + + # Lastly, we should make sure that such groups indeed allow Developers to create projects in them, + # based on the value of `groups.project_creation_level`, + # which is why we use the scope .with_project_creation_levels on each set. + end + + def groups_that_user_has_developer_access_and_above_via_direct_membership + current_user.developer_maintainer_owned_groups + end + + def groups_that_user_has_developer_access_via_direct_membership + current_user.developer_groups + end + + def project_creations_levels_allowing_developers_to_create_projects + project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS] + + # When the value of application_settings.default_project_creation is set to `DEVELOPER_MAINTAINER_PROJECT_ACCESS`, + # it means that a `nil` value for `groups.project_creation_level` is telling us: + # such groups also have `project_creation_level` implicitly set to `DEVELOPER_MAINTAINER_PROJECT_ACCESS`. + # ie, `nil` is a placeholder value for inheriting the value from the ApplicationSetting. + # So we will include `nil` in the list, + # when the application_setting's value is `DEVELOPER_MAINTAINER_PROJECT_ACCESS` + + if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS + project_creation_levels << nil + end + + project_creation_levels + end + end +end diff --git a/app/finders/groups/accepting_project_shares_finder.rb b/app/finders/groups/accepting_project_shares_finder.rb index 253961b8e52..c85e5a0f538 100644 --- a/app/finders/groups/accepting_project_shares_finder.rb +++ b/app/finders/groups/accepting_project_shares_finder.rb @@ -25,6 +25,8 @@ module Groups groups_with_guest_access_plus end + groups = by_hierarchy(groups) + groups = by_ignorable(groups) groups = by_search(groups) sort(groups).with_route @@ -48,5 +50,25 @@ module Groups Ability.allowed?(current_user, :admin_project, project_to_be_shared) && project_to_be_shared.allowed_to_share_with_group? end + + def by_ignorable(groups) + # groups already linked to this project or groups above the project's + # current hierarchy needs to be ignored. + groups.id_not_in(project_to_be_shared.related_group_ids) + end + + def by_hierarchy(groups) + return groups if project_to_be_shared.personal? || sharing_outside_hierarchy_allowed? + + groups.id_in(root_ancestor.self_and_descendants_ids) + end + + def sharing_outside_hierarchy_allowed? + !root_ancestor.prevent_sharing_groups_outside_hierarchy + end + + def root_ancestor + project_to_be_shared.root_ancestor + end end end diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb index b58c1323b1f..83e012b3dbe 100644 --- a/app/finders/groups/user_groups_finder.rb +++ b/app/finders/groups/user_groups_finder.rb @@ -36,7 +36,7 @@ module Groups def by_permission_scope if permission_scope_create_projects? - target_user.manageable_groups(include_groups_with_developer_maintainer_access: true) + Groups::AcceptingProjectCreationsFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder elsif permission_scope_transfer_projects? Groups::AcceptingProjectTransfersFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder else diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index 9f9d0da6efd..b1387f2a104 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -11,6 +11,11 @@ class LabelsFinder < UnionFinder def initialize(current_user, params = {}) @current_user = current_user @params = params + # Preload container records (project, group) by default, in some cases we invoke + # the LabelsPreloader on the loaded records to prevent all N+1 queries. + # In that case we disable the default with_preloaded_container scope because it + # interferes with the LabelsPreloader. + @preload_parent_association = params.fetch(:preload_parent_association, true) end def execute(skip_authorization: false) @@ -19,7 +24,9 @@ class LabelsFinder < UnionFinder items = with_title(items) items = by_subscription(items) items = by_search(items) - sort(items.with_preloaded_container) + + items = items.with_preloaded_container if @preload_parent_association + sort(items) end private diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 1641219a14c..d2122eccab1 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -31,11 +31,7 @@ class MembersFinder attr_reader :project, :current_user, :group def find_members(include_relations) - project_members = if Feature.enabled?(:project_members_index_by_project_namespace, project) - project.namespace_members - else - project.project_members - end + project_members = project.namespace_members if params[:active_without_invites_and_requests].present? project_members = project_members.active_without_invites_and_requests diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 81017290f12..3d764f67990 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -31,6 +31,7 @@ class NotesFinder notes = since_fetch_at(notes) notes = notes.with_notes_filter(@params[:notes_filter]) if notes_filter? notes = redact_internal(notes) + notes = notes.without_hidden if without_hidden_notes? sort(notes) end @@ -189,6 +190,13 @@ class NotesFinder notes.not_internal end + + def without_hidden_notes? + return false unless Feature.enabled?(:hidden_notes) + return false if @current_user&.can_admin_all_resources? + + true + end end NotesFinder.prepend_mod_with('NotesFinder') diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb index a367fda37de..953e8299138 100644 --- a/app/finders/packages/npm/package_finder.rb +++ b/app/finders/packages/npm/package_finder.rb @@ -21,7 +21,11 @@ module Packages return result unless @last_of_each_version - result.last_of_each_version + if Feature.enabled?(:npm_allow_packages_in_multiple_projects) + Packages::Package.id_in(result.last_of_each_version_ids) + else + result.last_of_each_version + end end private diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 401bc473216..57a9538db15 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -31,6 +31,7 @@ # class ProjectsFinder < UnionFinder include CustomAttributesFilter + include UpdatedAtFilter attr_accessor :params attr_reader :current_user, :project_ids_relation @@ -87,6 +88,7 @@ class ProjectsFinder < UnionFinder collection = by_last_activity_before(collection) collection = by_language(collection) collection = by_feature_availability(collection) + collection = by_updated_at(collection) by_repository_storage(collection) end diff --git a/app/finders/security/security_jobs_finder.rb b/app/finders/security/security_jobs_finder.rb index 5754492cfa7..8cfb699a62a 100644 --- a/app/finders/security/security_jobs_finder.rb +++ b/app/finders/security/security_jobs_finder.rb @@ -13,7 +13,7 @@ module Security class SecurityJobsFinder < JobsFinder def self.allowed_job_types - [:sast, :sast_iac, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning] + [:sast, :sast_iac, :breach_and_attack_simulation, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning] end end end diff --git a/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb b/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb index c1b35d3eaf7..4ce9baff8cb 100644 --- a/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb +++ b/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb @@ -1,21 +1,25 @@ # frozen_string_literal: true module BatchLoaders - module AwardEmojiVotesBatchLoader - private + class AwardEmojiVotesBatchLoader + def self.load_upvotes(object, awardable_class: nil) + load_votes_for(object, AwardEmoji::UPVOTE_NAME, awardable_class: awardable_class) + end + + def self.load_downvotes(object, awardable_class: nil) + load_votes_for(object, AwardEmoji::DOWNVOTE_NAME, awardable_class: awardable_class) + end - def load_votes(object, vote_type) - BatchLoader::GraphQL.for(object.id).batch(key: "#{object.issuing_parent_id}-#{vote_type}") do |ids, loader, args| - counts = AwardEmoji.votes_for_collection(ids, object.class.name).named(vote_type).index_by(&:awardable_id) + def self.load_votes_for(object, vote_type, awardable_class: nil) + awardable_class ||= object.class.name + + BatchLoader::GraphQL.for(object.id).batch(key: "#{object.issuing_parent_id}-#{vote_type}") do |ids, loader, _args| + counts = AwardEmoji.votes_for_collection(ids, awardable_class).named(vote_type).index_by(&:awardable_id) ids.each do |id| loader.call(id, counts[id]&.count || 0) end end end - - def authorized_resource?(object) - Ability.allowed?(current_user, "read_#{object.to_ability_name}".to_sym, object) - end end end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 37adf4c2d3b..eed7959a2f1 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -20,7 +20,7 @@ class GitlabSchema < GraphQL::Schema use Gitlab::Graphql::GenericTracing use Gitlab::Graphql::Tracers::TimerTracer - use GraphQL::Subscriptions::ActionCableSubscriptions + use Gitlab::Graphql::Subscriptions::ActionCableWithLoadBalancing use BatchLoader::GraphQL use Gitlab::Graphql::Pagination::Connections use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 89656f1e018..d1798d2ade7 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -2,60 +2,62 @@ module GraphqlTriggers def self.issuable_assignees_updated(issuable) - GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable) + GitlabSchema.subscriptions.trigger(:issuable_assignees_updated, { issuable_id: issuable.to_gid }, issuable) end def self.issue_crm_contacts_updated(issue) - GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue) + GitlabSchema.subscriptions.trigger(:issue_crm_contacts_updated, { issuable_id: issue.to_gid }, issue) end def self.issuable_title_updated(issuable) - GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable) + GitlabSchema.subscriptions.trigger(:issuable_title_updated, { issuable_id: issuable.to_gid }, issuable) end def self.issuable_description_updated(issuable) - GitlabSchema.subscriptions.trigger('issuableDescriptionUpdated', { issuable_id: issuable.to_gid }, issuable) + GitlabSchema.subscriptions.trigger(:issuable_description_updated, { issuable_id: issuable.to_gid }, issuable) end def self.issuable_labels_updated(issuable) - GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable) + GitlabSchema.subscriptions.trigger(:issuable_labels_updated, { issuable_id: issuable.to_gid }, issuable) end def self.issuable_dates_updated(issuable) - GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable) + GitlabSchema.subscriptions.trigger(:issuable_dates_updated, { issuable_id: issuable.to_gid }, issuable) end def self.issuable_milestone_updated(issuable) - GitlabSchema.subscriptions.trigger('issuableMilestoneUpdated', { issuable_id: issuable.to_gid }, issuable) + GitlabSchema.subscriptions.trigger(:issuable_milestone_updated, { issuable_id: issuable.to_gid }, issuable) end def self.work_item_note_created(work_item_gid, note_data) - GitlabSchema.subscriptions.trigger('workItemNoteCreated', { noteable_id: work_item_gid }, note_data) + GitlabSchema.subscriptions.trigger(:work_item_note_created, { noteable_id: work_item_gid }, note_data) end def self.work_item_note_deleted(work_item_gid, note_data) - GitlabSchema.subscriptions.trigger('workItemNoteDeleted', { noteable_id: work_item_gid }, note_data) + GitlabSchema.subscriptions.trigger(:work_item_note_deleted, { noteable_id: work_item_gid }, note_data) end def self.work_item_note_updated(work_item_gid, note_data) - GitlabSchema.subscriptions.trigger('workItemNoteUpdated', { noteable_id: work_item_gid }, note_data) + GitlabSchema.subscriptions.trigger(:work_item_note_updated, { noteable_id: work_item_gid }, note_data) end def self.merge_request_reviewers_updated(merge_request) GitlabSchema.subscriptions.trigger( - 'mergeRequestReviewersUpdated', { issuable_id: merge_request.to_gid }, merge_request + :merge_request_reviewers_updated, { issuable_id: merge_request.to_gid }, merge_request ) end def self.merge_request_merge_status_updated(merge_request) + return unless Feature.enabled?(:realtime_mr_status_change, merge_request.project) + GitlabSchema.subscriptions.trigger( - 'mergeRequestMergeStatusUpdated', { issuable_id: merge_request.to_gid }, merge_request + :merge_request_merge_status_updated, { issuable_id: merge_request.to_gid }, merge_request ) end def self.merge_request_approval_state_updated(merge_request) GitlabSchema.subscriptions.trigger( - 'mergeRequestApprovalStateUpdated', { issuable_id: merge_request.to_gid }, merge_request + :merge_request_approval_state_updated, { issuable_id: merge_request.to_gid }, merge_request ) end end diff --git a/app/graphql/mutations/achievements/delete.rb b/app/graphql/mutations/achievements/delete.rb new file mode 100644 index 00000000000..0b510b44b4e --- /dev/null +++ b/app/graphql/mutations/achievements/delete.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Achievements + class Delete < BaseMutation + graphql_name 'AchievementsDelete' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :achievement, + ::Types::Achievements::AchievementType, + null: true, + description: 'Achievement.' + + argument :achievement_id, ::Types::GlobalIDType[::Achievements::Achievement], + required: true, + description: 'Global ID of the achievement being deleted.' + + authorize :admin_achievement + + def resolve(args) + achievement = authorized_find!(id: args[:achievement_id]) + + result = ::Achievements::DestroyService.new(current_user, achievement).execute + { achievement: result.payload, errors: result.errors } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement) + end + end + end +end diff --git a/app/graphql/mutations/achievements/update.rb b/app/graphql/mutations/achievements/update.rb new file mode 100644 index 00000000000..2a9e6580629 --- /dev/null +++ b/app/graphql/mutations/achievements/update.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Mutations + module Achievements + class Update < BaseMutation + graphql_name 'AchievementsUpdate' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :achievement, + ::Types::Achievements::AchievementType, + null: true, + description: 'Achievement.' + + argument :achievement_id, ::Types::GlobalIDType[::Achievements::Achievement], + required: true, + description: 'Global ID of the achievement being updated.' + + argument :name, GraphQL::Types::String, + required: false, + description: 'Name for the achievement.' + + argument :avatar, ApolloUploadServer::Upload, + required: false, + description: 'Avatar for the achievement.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of or notes for the achievement.' + + authorize :admin_achievement + + def resolve(args) + achievement = authorized_find!(id: args[:achievement_id]) + + args.delete(:achievement_id) + result = ::Achievements::UpdateService.new(current_user, achievement, args).execute + { achievement: result.payload, errors: result.errors } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement) + end + end + end +end diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb index dc2d46269e6..65065de0de4 100644 --- a/app/graphql/mutations/award_emojis/base.rb +++ b/app/graphql/mutations/award_emojis/base.rb @@ -3,8 +3,6 @@ module Mutations module AwardEmojis class Base < BaseMutation - include ::Mutations::FindsByGid - NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.' authorize :award_emoji diff --git a/app/graphql/mutations/boards/update.rb b/app/graphql/mutations/boards/update.rb index 7cfce9d2d91..f611608d1b6 100644 --- a/app/graphql/mutations/boards/update.rb +++ b/app/graphql/mutations/boards/update.rb @@ -29,12 +29,6 @@ module Mutations errors: errors_on_object(board) } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/ci/runner/common_mutation_arguments.rb b/app/graphql/mutations/ci/runner/common_mutation_arguments.rb index bfeed4881c6..f4fbd0a38c7 100644 --- a/app/graphql/mutations/ci/runner/common_mutation_arguments.rb +++ b/app/graphql/mutations/ci/runner/common_mutation_arguments.rb @@ -38,11 +38,6 @@ module Mutations argument :tag_list, [GraphQL::Types::String], required: false, description: 'Tags associated with the runner.' - - argument :associated_projects, [::Types::GlobalIDType[::Project]], - required: false, - description: 'Projects associated with the runner. Available only for project runners.', - prepare: ->(global_ids, _ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } } end end end diff --git a/app/graphql/mutations/ci/runner/create.rb b/app/graphql/mutations/ci/runner/create.rb index 98300ee4c38..7eca6c27d10 100644 --- a/app/graphql/mutations/ci/runner/create.rb +++ b/app/graphql/mutations/ci/runner/create.rb @@ -10,25 +10,49 @@ module Mutations include Mutations::Ci::Runner::CommonMutationArguments + argument :runner_type, ::Types::Ci::RunnerTypeEnum, + required: true, + description: 'Type of the runner to create.' + + argument :group_id, ::Types::GlobalIDType[Group], + required: false, + description: 'Global ID of the group that the runner is created in (valid only for group runner).' + + argument :project_id, ::Types::GlobalIDType[Project], + required: false, + description: 'Global ID of the project that the runner is created in (valid only for project runner).' + field :runner, Types::Ci::RunnerType, null: true, description: 'Runner after mutation.' - def resolve(**args) - if Feature.disabled?(:create_runner_workflow_for_admin, current_user) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - '`create_runner_workflow_for_admin` feature flag is disabled.' + def ready?(**args) + case args[:runner_type] + when 'group_type' + raise Gitlab::Graphql::Errors::ArgumentError, '`group_id` is missing' unless args[:group_id].present? + when 'project_type' + raise Gitlab::Graphql::Errors::ArgumentError, '`project_id` is missing' unless args[:project_id].present? end - create_runner(args) + parse_gid(**args) + + check_feature_flag(**args) + + super end - private + def resolve(**args) + case args[:runner_type] + when 'group_type', 'project_type' + args[:scope] = authorized_find!(**args) + args.except!(:group_id, :project_id) + else + raise_resource_not_available_error! unless current_user.can?(:create_instance_runner) + end - def create_runner(params) response = { runner: nil, errors: [] } - result = ::Ci::Runners::CreateRunnerService.new(user: current_user, type: nil, params: params).execute + result = ::Ci::Runners::CreateRunnerService.new(user: current_user, params: args).execute if result.success? response[:runner] = result.payload[:runner] @@ -38,6 +62,45 @@ module Mutations response end + + private + + def find_object(**args) + obj = parse_gid(**args) + + GitlabSchema.find_by_gid(obj) if obj + end + + def parse_gid(runner_type:, **args) + case runner_type + when 'group_type' + GitlabSchema.parse_gid(args[:group_id], expected_type: ::Group) + when 'project_type' + GitlabSchema.parse_gid(args[:project_id], expected_type: ::Project) + end + end + + def check_feature_flag(**args) + case args[:runner_type] + when 'instance_type' + if Feature.disabled?(:create_runner_workflow_for_admin, current_user) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + '`create_runner_workflow_for_admin` feature flag is disabled.' + end + when 'group_type' + namespace = find_object(**args).sync + if Feature.disabled?(:create_runner_workflow_for_namespace, namespace) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + '`create_runner_workflow_for_namespace` feature flag is disabled.' + end + when 'project_type' + project = find_object(**args).sync + if project && Feature.disabled?(:create_runner_workflow_for_namespace, project.namespace) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + '`create_runner_workflow_for_namespace` feature flag is disabled.' + end + end + end end end end diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb index db68914a4eb..ba309ca754d 100644 --- a/app/graphql/mutations/ci/runner/delete.rb +++ b/app/graphql/mutations/ci/runner/delete.rb @@ -15,16 +15,12 @@ module Mutations description: 'ID of the runner to delete.' def resolve(id:, **runner_attrs) - runner = authorized_find!(id) + runner = authorized_find!(id: id) ::Ci::Runners::UnregisterRunnerService.new(runner, current_user).execute { errors: runner.errors.full_messages } end - - def find_object(id) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb index 70f08e03553..da28397bb71 100644 --- a/app/graphql/mutations/ci/runner/update.rb +++ b/app/graphql/mutations/ci/runner/update.rb @@ -21,13 +21,18 @@ module Mutations description: 'Indicates the runner is allowed to receive jobs.', deprecated: { reason: :renamed, replacement: 'paused', milestone: '14.8' } + argument :associated_projects, [::Types::GlobalIDType[::Project]], + required: false, + description: 'Projects associated with the runner. Available only for project runners.', + prepare: ->(global_ids, _ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } } + field :runner, Types::Ci::RunnerType, null: true, description: 'Runner after mutation.' def resolve(id:, **runner_attrs) - runner = authorized_find!(id) + runner = authorized_find!(id: id) associated_projects_ids = runner_attrs.delete(:associated_projects) @@ -40,10 +45,6 @@ module Mutations response end - def find_object(id) - GitlabSchema.find_by_gid(id) - end - private def associate_runner_projects(response, runner, associated_project_ids) diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb index 1b104652bd2..e717ff4d798 100644 --- a/app/graphql/mutations/clusters/agent_tokens/create.rb +++ b/app/graphql/mutations/clusters/agent_tokens/create.rb @@ -54,12 +54,6 @@ module Mutations errors: Array.wrap(result.message) } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/clusters/agent_tokens/revoke.rb b/app/graphql/mutations/clusters/agent_tokens/revoke.rb index 6e988799921..c4187746464 100644 --- a/app/graphql/mutations/clusters/agent_tokens/revoke.rb +++ b/app/graphql/mutations/clusters/agent_tokens/revoke.rb @@ -21,12 +21,6 @@ module Mutations { errors: errors_on_object(token) } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/clusters/agents/delete.rb b/app/graphql/mutations/clusters/agents/delete.rb index fb482e02794..ddb4e36a68e 100644 --- a/app/graphql/mutations/clusters/agents/delete.rb +++ b/app/graphql/mutations/clusters/agents/delete.rb @@ -24,12 +24,6 @@ module Mutations errors: Array.wrap(result.message) } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb deleted file mode 100644 index 157f87a413d..00000000000 --- a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module FindsByGid - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - end -end diff --git a/app/graphql/mutations/concerns/mutations/finds_namespace.rb b/app/graphql/mutations/concerns/mutations/finds_namespace.rb new file mode 100644 index 00000000000..bc9dfbcffe5 --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/finds_namespace.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Mutations + module FindsNamespace + private + + def find_object(full_path) + Routable.find_by_full_path(full_path) + end + end +end 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 72daaf3ee44..f009abdba70 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -40,6 +40,14 @@ module Mutations ::Types::WorkItems::Widgets::NotificationsUpdateInputType, required: false, description: 'Input for notifications widget.' + argument :current_user_todos_widget, + ::Types::WorkItems::Widgets::CurrentUserTodosInputType, + required: false, + description: 'Input for to-dos widget.' + argument :award_emoji_widget, + ::Types::WorkItems::Widgets::AwardEmojiUpdateInputType, + required: false, + description: 'Input for award emoji widget.' end end end diff --git a/app/graphql/mutations/container_repositories/destroy_base.rb b/app/graphql/mutations/container_repositories/destroy_base.rb index 1c2c4d87a5f..46851c15702 100644 --- a/app/graphql/mutations/container_repositories/destroy_base.rb +++ b/app/graphql/mutations/container_repositories/destroy_base.rb @@ -4,12 +4,6 @@ module Mutations module ContainerRepositories class DestroyBase < Mutations::BaseMutation include ::Mutations::PackageEventable - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/design_management/update.rb b/app/graphql/mutations/design_management/update.rb index 5dc20730a90..67732b70f29 100644 --- a/app/graphql/mutations/design_management/update.rb +++ b/app/graphql/mutations/design_management/update.rb @@ -28,12 +28,6 @@ module Mutations errors: errors_on_object(design) } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb index fce6e4f416f..dc5731add3a 100644 --- a/app/graphql/mutations/discussions/toggle_resolve.rb +++ b/app/graphql/mutations/discussions/toggle_resolve.rb @@ -53,10 +53,6 @@ module Mutations end end - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - def resolve!(discussion) ::Discussions::ResolveService.new( discussion.project, diff --git a/app/graphql/mutations/environments/canary_ingress/update.rb b/app/graphql/mutations/environments/canary_ingress/update.rb index 1cddfdd815b..43e9b6c0881 100644 --- a/app/graphql/mutations/environments/canary_ingress/update.rb +++ b/app/graphql/mutations/environments/canary_ingress/update.rb @@ -35,10 +35,6 @@ module Mutations { errors: Array.wrap(result[:message]) } end - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - private def certificate_based_clusters_enabled? diff --git a/app/graphql/mutations/members/projects/bulk_update.rb b/app/graphql/mutations/members/projects/bulk_update.rb index cfb88e60c44..9bf7968670e 100644 --- a/app/graphql/mutations/members/projects/bulk_update.rb +++ b/app/graphql/mutations/members/projects/bulk_update.rb @@ -5,6 +5,9 @@ module Mutations module Projects class BulkUpdate < BulkUpdateBase graphql_name 'ProjectMemberBulkUpdate' + description 'Updates multiple members of a project. ' \ + 'To use this mutation, you must have at least the Maintainer role.' + authorize :admin_project_member field :project_members, diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb index d458bdcf82b..225d313c487 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb @@ -83,10 +83,6 @@ module Mutations super(**args) end - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - def annotation_create_params(args) annotation_source = AnnotationSource.new(object: annotation_source(args)) diff --git a/app/graphql/mutations/notes/base.rb b/app/graphql/mutations/notes/base.rb index fb74805db17..d656835c335 100644 --- a/app/graphql/mutations/notes/base.rb +++ b/app/graphql/mutations/notes/base.rb @@ -13,12 +13,6 @@ module Mutations Types::Notes::NoteType, null: true, description: 'Note after mutation.' - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb index f48e62af767..69cd1426218 100644 --- a/app/graphql/mutations/notes/create/base.rb +++ b/app/graphql/mutations/notes/create/base.rb @@ -47,10 +47,6 @@ module Mutations private - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - def create_note_params(noteable, args) { noteable: noteable, diff --git a/app/graphql/mutations/packages/destroy.rb b/app/graphql/mutations/packages/destroy.rb index a398b1ff9dc..95832ec8b85 100644 --- a/app/graphql/mutations/packages/destroy.rb +++ b/app/graphql/mutations/packages/destroy.rb @@ -23,12 +23,6 @@ module Mutations errors: errors } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/packages/destroy_file.rb b/app/graphql/mutations/packages/destroy_file.rb index f2a8f2b853a..c7dd2df704e 100644 --- a/app/graphql/mutations/packages/destroy_file.rb +++ b/app/graphql/mutations/packages/destroy_file.rb @@ -21,12 +21,6 @@ module Mutations { errors: package_file.errors.full_messages } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/projects/sync_fork.rb b/app/graphql/mutations/projects/sync_fork.rb index 121c16df87b..13dd0b60d26 100644 --- a/app/graphql/mutations/projects/sync_fork.rb +++ b/app/graphql/mutations/projects/sync_fork.rb @@ -7,8 +7,6 @@ module Mutations include FindsProject - authorize :push_code - argument :project_path, GraphQL::Types::ID, required: true, description: 'Full path of the project to initialize.' @@ -22,9 +20,12 @@ module Mutations description: 'Updated fork details.' def resolve(project_path:, target_branch:) - project = authorized_find!(project_path) + project = authorized_find!(project_path, target_branch) + + return respond(nil, ['Feature flag is disabled']) unless Feature.enabled?(:synchronize_fork, + project.fork_source) - return respond(nil, ['Feature flag is disabled']) unless Feature.enabled?(:synchronize_fork, project) + return respond(nil, ['Target branch does not exist']) unless project.repository.branch_exists?(target_branch) details_resolver = Resolvers::Projects::ForkDetailsResolver.new(object: project, context: context, field: nil) details = details_resolver.resolve(ref: target_branch) @@ -56,6 +57,14 @@ module Mutations def respond(details, errors) { details: details, errors: errors } end + + def authorized_find!(project_path, target_branch) + project = find_object(project_path) + + return project if ::Gitlab::UserAccess.new(current_user, container: project).can_update_branch?(target_branch) + + raise_resource_not_available_error! + end end end end diff --git a/app/graphql/mutations/release_asset_links/delete.rb b/app/graphql/mutations/release_asset_links/delete.rb index 9a75b472411..891d8e5a4d8 100644 --- a/app/graphql/mutations/release_asset_links/delete.rb +++ b/app/graphql/mutations/release_asset_links/delete.rb @@ -19,7 +19,7 @@ module Mutations description: 'Deleted release asset link.' def resolve(id:) - link = authorized_find!(id) + link = authorized_find!(id: id) result = ::Releases::Links::DestroyService .new(link.release, current_user) @@ -31,10 +31,6 @@ module Mutations { link: nil, errors: result.message } end end - - def find_object(id) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/release_asset_links/update.rb b/app/graphql/mutations/release_asset_links/update.rb index 2e9054c290d..3df2d28b88c 100644 --- a/app/graphql/mutations/release_asset_links/update.rb +++ b/app/graphql/mutations/release_asset_links/update.rb @@ -44,7 +44,7 @@ module Mutations end def resolve(id:, **link_attrs) - link = authorized_find!(id) + link = authorized_find!(id: id) result = ::Releases::Links::UpdateService .new(link.release, current_user, link_attrs) @@ -56,10 +56,6 @@ module Mutations { link: nil, errors: result.message } end end - - def find_object(id) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/terraform/state/base.rb b/app/graphql/mutations/terraform/state/base.rb index 01f69934ea3..9a264836ef5 100644 --- a/app/graphql/mutations/terraform/state/base.rb +++ b/app/graphql/mutations/terraform/state/base.rb @@ -10,12 +10,6 @@ module Mutations Types::GlobalIDType[::Terraform::State], required: true, description: 'Global ID of the Terraform state.' - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb deleted file mode 100644 index 9a94c5d1e6d..00000000000 --- a/app/graphql/mutations/todos/base.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module Todos - class Base < ::Mutations::BaseMutation - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - end - end -end diff --git a/app/graphql/mutations/todos/create.rb b/app/graphql/mutations/todos/create.rb index 489d2f490ff..8a0906da724 100644 --- a/app/graphql/mutations/todos/create.rb +++ b/app/graphql/mutations/todos/create.rb @@ -2,7 +2,7 @@ module Mutations module Todos - class Create < ::Mutations::Todos::Base + class Create < ::Mutations::BaseMutation graphql_name 'TodoCreate' authorize :create_todo @@ -17,7 +17,7 @@ module Mutations description: 'To-do item created.' def resolve(target_id:) - target = authorized_find!(target_id) + target = authorized_find!(id: target_id) todo = TodoService.new.mark_todo(target, current_user)&.first errors = errors_on_object(todo) if todo @@ -27,12 +27,6 @@ module Mutations errors: errors } end - - private - - def find_object(id) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb index fe4023515a4..7f8d15e033a 100644 --- a/app/graphql/mutations/todos/mark_all_done.rb +++ b/app/graphql/mutations/todos/mark_all_done.rb @@ -2,7 +2,7 @@ module Mutations module Todos - class MarkAllDone < ::Mutations::Todos::Base + class MarkAllDone < ::Mutations::BaseMutation graphql_name 'TodosMarkAllDone' authorize :update_user diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb index 4fecba55242..05d69fbc969 100644 --- a/app/graphql/mutations/todos/mark_done.rb +++ b/app/graphql/mutations/todos/mark_done.rb @@ -2,7 +2,7 @@ module Mutations module Todos - class MarkDone < ::Mutations::Todos::Base + class MarkDone < ::Mutations::BaseMutation graphql_name 'TodoMarkDone' authorize :update_todo diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb index def24cb71bc..a169ec58a9a 100644 --- a/app/graphql/mutations/todos/restore.rb +++ b/app/graphql/mutations/todos/restore.rb @@ -2,7 +2,7 @@ module Mutations module Todos - class Restore < ::Mutations::Todos::Base + class Restore < ::Mutations::BaseMutation graphql_name 'TodoRestore' authorize :update_todo diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index f2f944860c2..106ba18b852 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -2,7 +2,7 @@ module Mutations module Todos - class RestoreMany < ::Mutations::Todos::Base + class RestoreMany < ::Mutations::BaseMutation graphql_name 'TodoRestoreMany' MAX_UPDATE_AMOUNT = 50 diff --git a/app/graphql/mutations/work_items/convert.rb b/app/graphql/mutations/work_items/convert.rb new file mode 100644 index 00000000000..e8a2d72bd04 --- /dev/null +++ b/app/graphql/mutations/work_items/convert.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + class Convert < BaseMutation + graphql_name 'WorkItemConvert' + description "Converts the work item to a new type" + + include Mutations::SpamProtection + + authorize :update_work_item + + argument :id, ::Types::GlobalIDType[::WorkItem], + required: true, + description: 'Global ID of the work item.' + argument :work_item_type_id, ::Types::GlobalIDType[::WorkItems::Type], + required: true, + description: 'Global ID of the new work item type.' + + field :work_item, Types::WorkItemType, + null: true, + description: 'Updated work item.' + + def resolve(attributes) + work_item = authorized_find!(id: attributes[:id]) + + return { errors: ['Feature flag disabled'] } unless Feature.enabled?(:work_item_conversion, work_item.project) + + work_item_type = find_work_item_type!(attributes[:work_item_type_id]) + authorize_work_item_type!(work_item, work_item_type) + + spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) + + update_result = ::WorkItems::UpdateService.new( + container: work_item.project, + current_user: current_user, + params: { work_item_type: work_item_type, issue_type: work_item_type.base_type }, + spam_params: spam_params + ).execute(work_item) + + check_spam_action_response!(work_item) + + { + work_item: (update_result[:work_item] if update_result[:status] == :success), + errors: Array.wrap(update_result[:message]) + } + end + + private + + def find_work_item_type!(gid) + work_item_type = ::WorkItems::Type.find_by_id(gid.model_id) + + return work_item_type if work_item_type.present? + + message = format(_('Work Item type with id %{id} was not found'), id: gid.model_id) + raise_resource_not_available_error! message + end + + def authorize_work_item_type!(work_item, work_item_type) + return if current_user.can?(:"create_#{work_item_type.base_type}", work_item) + + message = format(_('You are not allowed to change the Work Item type to %{name}.'), name: work_item_type.name) + raise_resource_not_available_error! message + end + + def find_object(id:) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 9f124de7ab2..dfd2d5d1f88 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -6,13 +6,15 @@ module Mutations graphql_name 'WorkItemCreate' include Mutations::SpamProtection - include FindsProject + include FindsNamespace include Mutations::WorkItems::Widgetable description "Creates a work item." authorize :create_work_item + MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR = 'Please provide either projectPath or namespacePath argument, but not both.' + argument :confidential, GraphQL::Types::Boolean, required: false, description: 'Sets the work item confidentiality.' @@ -25,9 +27,16 @@ module Mutations argument :milestone_widget, ::Types::WorkItems::Widgets::MilestoneInputType, required: false, description: 'Input for milestone widget.' + argument :namespace_path, GraphQL::Types::ID, + required: false, + description: 'Full path of the namespace(project or group) the work item is created in.' argument :project_path, GraphQL::Types::ID, - required: true, - description: 'Full path of the project the work item is associated with.' + required: false, + description: 'Full path of the project the work item is associated with.', + deprecated: { + reason: 'Please use namespace_path instead. That will cover for both projects and groups', + milestone: '15.10' + } argument :title, GraphQL::Types::String, required: true, description: copy_field_description(Types::WorkItemType, :title) @@ -39,8 +48,17 @@ module Mutations null: true, description: 'Created work item.' - def resolve(project_path:, **attributes) - project = authorized_find!(project_path) + def ready?(**args) + if args.slice(:project_path, :namespace_path)&.length != 1 + raise Gitlab::Graphql::Errors::ArgumentError, MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + end + + super + end + + def resolve(project_path: nil, namespace_path: nil, **attributes) + container_path = project_path || namespace_path + container = authorized_find!(container_path) spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) params = global_id_compatibility_params(attributes).merge(author_id: current_user.id) @@ -48,7 +66,7 @@ module Mutations widget_params = extract_widget_params!(type, params) create_result = ::WorkItems::CreateService.new( - container: project, + container: container, current_user: current_user, params: params, spam_params: spam_params, diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb index 4ef8269a42f..23ae09b23fd 100644 --- a/app/graphql/mutations/work_items/create_from_task.rb +++ b/app/graphql/mutations/work_items/create_from_task.rb @@ -46,12 +46,6 @@ module Mutations response end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb index ec0244fa65e..bce59448412 100644 --- a/app/graphql/mutations/work_items/delete.rb +++ b/app/graphql/mutations/work_items/delete.rb @@ -29,12 +29,6 @@ module Mutations errors: result.errors } end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/work_items/delete_task.rb b/app/graphql/mutations/work_items/delete_task.rb index 47ab3748ab4..b13d7e2e3bf 100644 --- a/app/graphql/mutations/work_items/delete_task.rb +++ b/app/graphql/mutations/work_items/delete_task.rb @@ -53,11 +53,6 @@ module Mutations raise_resource_not_available_error! end end - - # method used by `authorized_find!(id: id)` - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index 60b5536df56..3bcec7ebb1c 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -42,10 +42,6 @@ module Mutations private - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - def interpret_quick_actions!(work_item, current_user, widget_params, attributes = {}) return unless work_item.work_item_type.widgets.include?(::WorkItems::Widgets::Description) diff --git a/app/graphql/resolvers/achievements/achievements_resolver.rb b/app/graphql/resolvers/achievements/achievements_resolver.rb index 1d71fa1d9c1..eb3f6eaf92e 100644 --- a/app/graphql/resolvers/achievements/achievements_resolver.rb +++ b/app/graphql/resolvers/achievements/achievements_resolver.rb @@ -7,12 +7,20 @@ module Resolvers type ::Types::Achievements::AchievementType.connection_type, null: true + argument :ids, [::Types::GlobalIDType[::Achievements::Achievement]], + required: false, + description: 'Filter achievements by IDs.' + alias_method :namespace, :object - def resolve_with_lookahead + def resolve_with_lookahead(**args) return ::Achievements::Achievement.none if Feature.disabled?(:achievements, namespace) - apply_lookahead(namespace.achievements) + params = {} + params[:ids] = args[:ids].map(&:model_id) if args[:ids].present? + + achievements = ::Achievements::AchievementsFinder.new(namespace, params).execute + apply_lookahead(achievements) end private diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb new file mode 100644 index 00000000000..648f314a961 --- /dev/null +++ b/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + module CycleAnalytics + class BaseCountResolver < BaseResolver + type Types::Analytics::CycleAnalytics::MetricType, null: true + + argument :from, Types::TimeType, + required: true, + description: 'After the date.' + + argument :to, Types::TimeType, + required: true, + description: 'Before the date.' + + def ready?(**args) + start_date = args[:from] + end_date = args[:to] + + if start_date >= end_date + raise Gitlab::Graphql::Errors::ArgumentError, + '`from` argument must be before `to` argument' + end + + max_days = Gitlab::Analytics::CycleAnalytics::RequestParams::MAX_RANGE_DAYS + + if (end_date.beginning_of_day - start_date.beginning_of_day) > max_days + raise Gitlab::Graphql::Errors::ArgumentError, + "Max of #{max_days.inspect} timespan is allowed" + end + + super + end + end + end + end +end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb index f08de3c5d7e..8128023aecb 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb @@ -3,7 +3,7 @@ module Resolvers module Analytics module CycleAnalytics - class BaseIssueResolver < BaseResolver + class BaseIssueResolver < BaseCountResolver type Types::Analytics::CycleAnalytics::MetricType, null: true argument :assignee_usernames, [GraphQL::Types::String], @@ -22,14 +22,6 @@ module Resolvers required: false, description: 'Labels applied to the issue.' - argument :from, Types::TimeType, - required: true, - description: 'Issues created after the date.' - - argument :to, Types::TimeType, - required: true, - description: 'Issues created before the date.' - def finder_params { project_id: object.project.id } end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb index be17601e7a2..51a1afdd5ab 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb @@ -1,19 +1,10 @@ # frozen_string_literal: true +# rubocop:disable Graphql/ResolverType (inherited from Resolvers::Analytics::CycleAnalytics::BaseCountResolver) module Resolvers module Analytics module CycleAnalytics - class DeploymentCountResolver < BaseResolver - type Types::Analytics::CycleAnalytics::MetricType, null: true - - argument :from, Types::TimeType, - required: true, - description: 'Deployments finished after the date.' - - argument :to, Types::TimeType, - required: true, - description: 'Deployments finished before the date.' - + class DeploymentCountResolver < BaseCountResolver def resolve(**args) value = count(args) { @@ -57,6 +48,7 @@ module Resolvers end end end +# rubocop:enable Graphql/ResolverType mod = Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver mod.prepend_mod_with('Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver') diff --git a/app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb b/app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb new file mode 100644 index 00000000000..406c52eb0d5 --- /dev/null +++ b/app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module AwardEmoji + class BaseVotesCountResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type GraphQL::Types::Int, null: true + + private + + def authorized_resource?(object) + Ability.allowed?(current_user, "read_#{object.to_ability_name}".to_sym, object) + end + + def votes_batch_loader + BatchLoaders::AwardEmojiVotesBatchLoader + end + end + end +end diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb index fb5fa4465f9..0b8180dbce7 100644 --- a/app/graphql/resolvers/blobs_resolver.rb +++ b/app/graphql/resolvers/blobs_resolver.rb @@ -38,7 +38,7 @@ module Resolvers private def validate_ref(ref) - unless Gitlab::GitRefValidator.validate(ref) + unless Gitlab::GitRefValidator.validate(ref, skip_head_ref_check: true) raise Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid' end end diff --git a/app/graphql/resolvers/ci/all_jobs_resolver.rb b/app/graphql/resolvers/ci/all_jobs_resolver.rb index d918bed9f57..1240138c0bd 100644 --- a/app/graphql/resolvers/ci/all_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/all_jobs_resolver.rb @@ -3,14 +3,35 @@ module Resolvers module Ci class AllJobsResolver < BaseResolver + include LooksAhead + type ::Types::Ci::JobType.connection_type, null: true argument :statuses, [::Types::Ci::JobStatusEnum], required: false, description: 'Filter jobs by status.' - def resolve(statuses: nil) - ::Ci::JobsFinder.new(current_user: current_user, params: { scope: statuses }).execute + def resolve_with_lookahead(statuses: nil) + jobs = ::Ci::JobsFinder.new(current_user: current_user, params: { scope: statuses }).execute + + apply_lookahead(jobs) + end + + private + + def preloads + { + previous_stage_jobs_or_needs: [:needs, :pipeline], + artifacts: [:job_artifacts], + pipeline: [:user], + project: [{ project: [:route, { namespace: [:route] }] }], + commit_path: [:pipeline, { project: { namespace: [:route] } }], + ref_path: [{ project: [:route, { namespace: [:route] }] }], + browse_artifacts_path: [{ project: { namespace: [:route] } }], + play_path: [{ project: { namespace: [:route] } }], + web_path: [{ project: { namespace: [:route] } }], + tags: [:tags] + } end end end diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb index 467a3525867..9fe25a4d13d 100644 --- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb @@ -36,7 +36,11 @@ module Resolvers { pipeline: [:merge_request] }, { project: [:route, { namespace: :route }] } ], - commit_path: [:pipeline, { project: [:route, { namespace: [:route] }] }], + commit_path: [:pipeline, { project: { namespace: [:route] } }], + ref_path: [{ project: [:route, { namespace: [:route] }] }], + browse_artifacts_path: [{ project: { namespace: [:route] } }], + play_path: [{ project: { namespace: [:route] } }], + web_path: [{ project: { namespace: [:route] } }], short_sha: [:pipeline], tags: [:tags] } diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb index 13a493c42a5..625efc615c8 100644 --- a/app/graphql/resolvers/ci/runner_projects_resolver.rb +++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb @@ -34,25 +34,30 @@ module Resolvers .where(runner_id: runner_ids) .pluck(:runner_id, :project_id) - project_ids = plucked_runner_and_project_ids.collect { |_runner_id, project_id| project_id }.uniq + unique_project_ids = plucked_runner_and_project_ids.collect { |_runner_id, project_id| project_id }.uniq projects = ProjectsFinder .new(current_user: current_user, params: project_finder_params(args), - project_ids_relation: project_ids) + project_ids_relation: unique_project_ids) .execute projects = apply_lookahead(projects) Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute + sorted_project_ids = projects.map(&:id) projects_by_id = projects.index_by(&:id) # In plucked_runner_and_project_ids, first() represents the runner ID, and second() the project ID, # so let's group the project IDs by runner ID - runner_project_ids_by_runner_id = + project_ids_by_runner_id = plucked_runner_and_project_ids .group_by(&:first) - .transform_values { |values| values.map(&:second).filter_map { |project_id| projects_by_id[project_id] } } + .transform_values { |runner_id_and_project_id| runner_id_and_project_id.map(&:second) } + # Reorder the project IDs according to the order in sorted_project_ids + sorted_project_ids_by_runner_id = + project_ids_by_runner_id.transform_values { |project_ids| sorted_project_ids.intersection(project_ids) } runner_ids.each do |runner_id| - runner_projects = runner_project_ids_by_runner_id[runner_id] || [] + runner_project_ids = sorted_project_ids_by_runner_id[runner_id] || [] + runner_projects = runner_project_ids.map { |id| projects_by_id[id] } loader.call(runner_id, runner_projects) end diff --git a/app/graphql/resolvers/ci/runner_status_resolver.rb b/app/graphql/resolvers/ci/runner_status_resolver.rb index 447ab306ba7..6c7a4aafa83 100644 --- a/app/graphql/resolvers/ci/runner_status_resolver.rb +++ b/app/graphql/resolvers/ci/runner_status_resolver.rb @@ -22,6 +22,8 @@ module Resolvers } def resolve(legacy_mode:, **args) + legacy_mode = nil if Feature.enabled?(:disable_runner_graphql_legacy_mode) + runner.status(legacy_mode) end end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index c68e120ee24..b9326015ac0 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -40,6 +40,7 @@ module ResolvesMergeRequests def preloads { assignees: [:assignees], + award_emoji: { award_emoji: [:awardable] }, reviewers: [:reviewers], participants: MergeRequest.participant_includes, author: [:author], diff --git a/app/graphql/resolvers/data_transfer/data_transfer_arguments.rb b/app/graphql/resolvers/data_transfer/data_transfer_arguments.rb new file mode 100644 index 00000000000..da75a78b2ac --- /dev/null +++ b/app/graphql/resolvers/data_transfer/data_transfer_arguments.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + module DataTransfer + module DataTransferArguments + extend ActiveSupport::Concern + + included do + argument :from, Types::DateType, + description: + 'Retain egress data for one year. Data for the current month will increase dynamically as egress occurs.', + required: false + argument :to, Types::DateType, + description: 'End date for the data.', + required: false + end + end + end +end diff --git a/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb new file mode 100644 index 00000000000..83bb144017c --- /dev/null +++ b/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + module DataTransfer + class GroupDataTransferResolver < BaseResolver + include DataTransferArguments + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + authorize :read_usage_quotas + + type Types::DataTransfer::GroupDataTransferType, null: false + + alias_method :group, :object + + def resolve(**args) + return { egress_nodes: [] } unless Feature.enabled?(:data_transfer_monitoring, group) + + results = if Feature.enabled?(:data_transfer_monitoring_mock_data, group) + ::DataTransfer::MockedTransferFinder.new.execute + else + ::DataTransfer::GroupDataTransferFinder.new( + group: group, + from: args[:from], + to: args[:to], + user: current_user + ).execute.map(&:attributes) + end + + { egress_nodes: results.to_a } + end + end + end +end diff --git a/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb new file mode 100644 index 00000000000..c3296f7d4c3 --- /dev/null +++ b/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + module DataTransfer + class ProjectDataTransferResolver < BaseResolver + include DataTransferArguments + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + authorize :read_usage_quotas + + type Types::DataTransfer::ProjectDataTransferType, null: false + + alias_method :project, :object + + def resolve(**args) + return { egress_nodes: [] } unless Feature.enabled?(:data_transfer_monitoring, project.group) + + results = if Feature.enabled?(:data_transfer_monitoring_mock_data, project.group) + ::DataTransfer::MockedTransferFinder.new.execute + else + ::DataTransfer::ProjectDataTransferFinder.new( + project: project, + from: args[:from], + to: args[:to], + user: current_user + ).execute + end + + { egress_nodes: results } + end + end + end +end diff --git a/app/graphql/resolvers/data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer_resolver.rb deleted file mode 100644 index 1a240d2811f..00000000000 --- a/app/graphql/resolvers/data_transfer_resolver.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - class DataTransferResolver < BaseResolver - argument :from, Types::DateType, - description: 'Retain egress data for 1 year. Current month will increase dynamically as egress occurs.', - required: false - argument :to, Types::DateType, - description: 'End date for the data.', - required: false - - type ::Types::DataTransfer::BaseType, null: false - - def self.source - raise NotImplementedError - end - - def self.project - Class.new(self) do - type Types::DataTransfer::ProjectDataTransferType, null: false - - def self.source - "Project" - end - end - end - - def self.group - Class.new(self) do - type Types::DataTransfer::GroupDataTransferType, null: false - - def self.source - "Group" - end - end - end - - def resolve(**_args) - return unless Feature.enabled?(:data_transfer_monitoring) - - start_date = Date.new(2023, 0o1, 0o1) - date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') } - - nodes = 0.upto(3).map do |i| - { - date: date_for_index.call(i), - repository_egress: 250_000, - artifacts_egress: 250_000, - packages_egress: 250_000, - registry_egress: 250_000 - } - end - - { egress_nodes: nodes } - end - end -end diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb index 7895981d67c..0d2479ded40 100644 --- a/app/graphql/resolvers/design_management/version_resolver.rb +++ b/app/graphql/resolvers/design_management/version_resolver.rb @@ -16,10 +16,6 @@ module Resolvers def resolve(id:) authorized_find!(id: id) end - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/resolvers/down_votes_count_resolver.rb b/app/graphql/resolvers/down_votes_count_resolver.rb index 0e7772f988a..5f5340578cd 100644 --- a/app/graphql/resolvers/down_votes_count_resolver.rb +++ b/app/graphql/resolvers/down_votes_count_resolver.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true module Resolvers - class DownVotesCountResolver < BaseResolver - include Gitlab::Graphql::Authorize::AuthorizeResource - include BatchLoaders::AwardEmojiVotesBatchLoader - + class DownVotesCountResolver < Resolvers::AwardEmoji::BaseVotesCountResolver type GraphQL::Types::Int, null: true def resolve authorize!(object) - load_votes(object, AwardEmoji::DOWNVOTE_NAME) + votes_batch_loader.load_downvotes(object) end end end diff --git a/app/graphql/resolvers/group_labels_resolver.rb b/app/graphql/resolvers/group_labels_resolver.rb index a22fa9761d6..932834de895 100644 --- a/app/graphql/resolvers/group_labels_resolver.rb +++ b/app/graphql/resolvers/group_labels_resolver.rb @@ -13,5 +13,11 @@ module Resolvers required: false, description: 'Include only group level labels.', default_value: false + + before_connection_authorization do |nodes, current_user| + if Feature.enabled?(:preload_max_access_levels_for_labels_finder) + Preloaders::LabelsPreloader.new(nodes, current_user).preload_all + end + end end end diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb index 9db104287a6..74c5cbe55f1 100644 --- a/app/graphql/resolvers/kas/agent_configurations_resolver.rb +++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb @@ -21,7 +21,7 @@ module Resolvers private def can_read_agent_configuration? - current_user.can?(:read_cluster, project) + current_user.can?(:read_cluster_agent, project) end def kas_client diff --git a/app/graphql/resolvers/labels_resolver.rb b/app/graphql/resolvers/labels_resolver.rb index f0e099e8fb2..a6b00030121 100644 --- a/app/graphql/resolvers/labels_resolver.rb +++ b/app/graphql/resolvers/labels_resolver.rb @@ -17,6 +17,12 @@ module Resolvers description: 'Include labels from ancestor groups.', default_value: false + before_connection_authorization do |nodes, current_user| + if Feature.enabled?(:preload_max_access_levels_for_labels_finder) + Preloaders::LabelsPreloader.new(nodes, current_user).preload_all + end + end + def resolve(**args) return Label.none if parent.nil? @@ -24,6 +30,13 @@ module Resolvers # LabelsFinder uses `search` param, so we transform `search_term` into `search` args[:search] = args.delete(:search_term) + + # Optimization: + # Rely on the LabelsPreloader rather than the default parent record preloading in the + # finder because LabelsPreloader preloads more associations which are required for the + # permission check. + args[:preload_parent_association] = false if Feature.disabled?(:preload_max_access_levels_for_labels_finder) + LabelsFinder.new(current_user, parent_param.merge(args)).execute end diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index 72372ae6b42..c725f165682 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -55,6 +55,10 @@ module Resolvers required: false, description: 'Limit result to draft merge requests.' + argument :approved, GraphQL::Types::Boolean, + required: false, + description: 'Limit results to approved merge requests.' + argument :created_after, Types::TimeType, required: false, description: 'Merge requests created after this timestamp.' diff --git a/app/graphql/resolvers/notes/synthetic_note_resolver.rb b/app/graphql/resolvers/notes/synthetic_note_resolver.rb index d4eafcd2c49..619f54d80b4 100644 --- a/app/graphql/resolvers/notes/synthetic_note_resolver.rb +++ b/app/graphql/resolvers/notes/synthetic_note_resolver.rb @@ -26,10 +26,6 @@ module Resolvers synthetic_notes.find { |note| note.discussion_id == sha } end - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb index 6c4e978125e..8fd80b1a9b9 100644 --- a/app/graphql/resolvers/paginated_tree_resolver.rb +++ b/app/graphql/resolvers/paginated_tree_resolver.rb @@ -22,7 +22,7 @@ module Resolvers alias_method :repository, :object def resolve(**args) - return unless repository.exists? + return if repository.empty? cursor = args.delete(:after) args[:ref] ||= :head diff --git a/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb new file mode 100644 index 00000000000..7e2661f3f77 --- /dev/null +++ b/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class BranchesTippingAtCommitResolver < RefTippingAtCommitResolver + MAX_LIMIT = 100 + + calls_gitaly! + + type ::Types::Projects::CommitParentNamesType, null: true + + # the methode ref_prefix is implemented + # because this class is prepending Resolver::CommitParentNamesResolver module + # through it's parent ::Resolvers::RefTippingAtCommitResolver + def ref_prefix + Gitlab::Git::BRANCH_REF_PREFIX + end + end + end +end diff --git a/app/graphql/resolvers/projects/commit_parent_names_resolver.rb b/app/graphql/resolvers/projects/commit_parent_names_resolver.rb new file mode 100644 index 00000000000..f52776d715a --- /dev/null +++ b/app/graphql/resolvers/projects/commit_parent_names_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + module CommitParentNamesResolver + extend ActiveSupport::Concern + + prepended do + argument :commit_sha, GraphQL::Types::String, + required: true, + description: 'Project commit SHA identifier. For example, `287774414568010855642518513f085491644061`.' + + argument :limit, GraphQL::Types::Int, + required: false, + description: 'Number of branch names to return.' + + alias_method :project, :object + end + + def compute_limit(limit) + max = self.class::MAX_LIMIT + + limit ? [limit, max].min : max + end + + def get_tipping_refs(project, sha, limit: 0) + # the methode ref_prefix needs to be implemented in all classes prepending this module + refs = project.repository.refs_by_oid(oid: sha, ref_patterns: [ref_prefix], limit: limit) + refs.map { |n| n.delete_prefix(ref_prefix) } + end + end + end +end diff --git a/app/graphql/resolvers/projects/fork_details_resolver.rb b/app/graphql/resolvers/projects/fork_details_resolver.rb index a3c60f55e14..620ce395915 100644 --- a/app/graphql/resolvers/projects/fork_details_resolver.rb +++ b/app/graphql/resolvers/projects/fork_details_resolver.rb @@ -14,8 +14,6 @@ module Resolvers def resolve(**args) return unless project.forked? return unless authorized_fork_source? - return unless project.repository.branch_exists?(args[:ref]) - return unless Feature.enabled?(:fork_divergence_counts, project) ::Projects::Forks::Details.new(project, args[:ref]) end diff --git a/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb new file mode 100644 index 00000000000..3259a29ac9c --- /dev/null +++ b/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class RefTippingAtCommitResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + prepend CommitParentNamesResolver + + type ::Types::Projects::CommitParentNamesType, null: true + + authorize :read_code + + def resolve(commit_sha:, limit: nil) + final_limit = compute_limit(limit) + + names = get_tipping_refs(project, commit_sha, limit: final_limit) + + { + names: names, + total_count: nil + } + end + end + end +end diff --git a/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb new file mode 100644 index 00000000000..78ee9c997d5 --- /dev/null +++ b/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class TagsTippingAtCommitResolver < RefTippingAtCommitResolver + MAX_LIMIT = 100 + + calls_gitaly! + + type ::Types::Projects::CommitParentNamesType, null: true + + # the methode ref_prefix is implemented + # because this class is prepending Resolver::CommitParentNamesResolver module + # through it's parent ::Resolvers::RefTippingAtCommitResolver + def ref_prefix + Gitlab::Git::TAG_REF_PREFIX + end + end + end +end diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb index dc42a5f38c9..d2b67451698 100644 --- a/app/graphql/resolvers/timelog_resolver.rb +++ b/app/graphql/resolvers/timelog_resolver.rb @@ -121,7 +121,7 @@ module Resolvers def apply_user_filter(timelogs, args) return timelogs unless args[:username] - user = UserFinder.new(args[:username]).find_by_username! + user = UserFinder.new(args[:username]).find_by_username timelogs.for_user(user) end diff --git a/app/graphql/resolvers/up_votes_count_resolver.rb b/app/graphql/resolvers/up_votes_count_resolver.rb index 1c78facb694..8b2d705c07a 100644 --- a/app/graphql/resolvers/up_votes_count_resolver.rb +++ b/app/graphql/resolvers/up_votes_count_resolver.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true module Resolvers - class UpVotesCountResolver < BaseResolver - include Gitlab::Graphql::Authorize::AuthorizeResource - include BatchLoaders::AwardEmojiVotesBatchLoader - + class UpVotesCountResolver < Resolvers::AwardEmoji::BaseVotesCountResolver type GraphQL::Types::Int, null: true def resolve authorize!(object) - load_votes(object, AwardEmoji::UPVOTE_NAME) + votes_batch_loader.load_upvotes(object) end end end diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb index b174a0d2693..34e2f329efd 100644 --- a/app/graphql/resolvers/work_item_resolver.rb +++ b/app/graphql/resolvers/work_item_resolver.rb @@ -13,11 +13,5 @@ module Resolvers def resolve(id:) authorized_find!(id: id) end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 7115b028481..14eec4f696a 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -31,7 +31,7 @@ module Resolvers def preloads { work_item_type: :work_item_type, - web_url: { project: { namespace: :route } }, + web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] }, widgets: { work_item_type: :enabled_widget_definitions } } end @@ -56,7 +56,8 @@ module Resolvers children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] }, labels: :labels, milestone: { milestone: [:project, :group] }, - subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }] + subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }], + award_emoji: { award_emoji: :awardable } } end diff --git a/app/graphql/subscriptions/base_subscription.rb b/app/graphql/subscriptions/base_subscription.rb index 5f7931787df..dcc9fe708d6 100644 --- a/app/graphql/subscriptions/base_subscription.rb +++ b/app/graphql/subscriptions/base_subscription.rb @@ -12,6 +12,18 @@ module Subscriptions current_user.reset if current_user end + # We override graphql-ruby's default `subscribe` since it returns + # :no_response instead, which leads to empty hashes rendered out + # to the caller which has caused problems in the client. + # + # Eventually, we should move to an approach where the caller receives + # a response here upon subscribing, but we don't need this currently + # because Vue components also perform an initial fetch query. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/402614 + def subscribe(*) + nil + end + def authorized?(*) raise NotImplementedError end diff --git a/app/graphql/subscriptions/issuable_updated.rb b/app/graphql/subscriptions/issuable_updated.rb index ad78fd4b4a1..63fe81bbc32 100644 --- a/app/graphql/subscriptions/issuable_updated.rb +++ b/app/graphql/subscriptions/issuable_updated.rb @@ -10,10 +10,6 @@ module Subscriptions required: true, description: 'ID of the issuable.' - def subscribe(issuable_id:) - nil - end - def authorized?(issuable_id:) issuable = force(GitlabSchema.find_by_gid(issuable_id)) diff --git a/app/graphql/subscriptions/notes/base.rb b/app/graphql/subscriptions/notes/base.rb index 3653c01e0e2..c117dc295f2 100644 --- a/app/graphql/subscriptions/notes/base.rb +++ b/app/graphql/subscriptions/notes/base.rb @@ -9,10 +9,6 @@ module Subscriptions required: false, description: 'ID of the noteable.' - def subscribe(*args) - nil - end - def authorized?(noteable_id:) noteable = force(GitlabSchema.find_by_gid(noteable_id)) diff --git a/app/graphql/types/achievements/user_achievement_type.rb b/app/graphql/types/achievements/user_achievement_type.rb index d2146807445..bf161d2f1e5 100644 --- a/app/graphql/types/achievements/user_achievement_type.rb +++ b/app/graphql/types/achievements/user_achievement_type.rb @@ -5,7 +5,7 @@ module Types class UserAchievementType < BaseObject graphql_name 'UserAchievement' - authorize :read_achievement + authorize :read_user_achievement field :id, ::Types::GlobalIDType[::Achievements::UserAchievement], diff --git a/app/graphql/types/branch_protections/base_access_level_type.rb b/app/graphql/types/branch_protections/base_access_level_type.rb index 472733a6bc5..e6514ba8d7d 100644 --- a/app/graphql/types/branch_protections/base_access_level_type.rb +++ b/app/graphql/types/branch_protections/base_access_level_type.rb @@ -14,7 +14,7 @@ module Types type: GraphQL::Types::String, null: false, description: 'Human readable representation for this access level.', - hash_key: 'humanize' + method: 'humanize' end end end diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb new file mode 100644 index 00000000000..b5947826fa1 --- /dev/null +++ b/app/graphql/types/ci/catalog/resource_type.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + # rubocop: disable Graphql/AuthorizeTypes + class ResourceType < BaseObject + graphql_name 'CiCatalogResource' + + connection_type_class(Types::CountableConnectionType) + + field :id, GraphQL::Types::ID, null: false, description: 'ID of the catalog resource.', + alpha: { milestone: '15.11' } + + field :name, GraphQL::Types::String, null: true, description: 'Name of the catalog resource.', + alpha: { milestone: '15.11' } + + field :description, GraphQL::Types::String, null: true, description: 'Description of the catalog resource.', + alpha: { milestone: '15.11' } + + field :icon, GraphQL::Types::String, null: true, description: 'Icon for the catalog resource.', + method: :avatar_path, alpha: { milestone: '15.11' } + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/ci/config/include_type_enum.rb b/app/graphql/types/ci/config/include_type_enum.rb index 328824ae996..7ebcf786dd8 100644 --- a/app/graphql/types/ci/config/include_type_enum.rb +++ b/app/graphql/types/ci/config/include_type_enum.rb @@ -11,6 +11,7 @@ module Types value 'local', description: 'Local include.', value: :local value 'file', description: 'Project file include.', value: :file value 'template', description: 'Template include.', value: :template + value 'component', description: 'Component include.', value: :component end end end diff --git a/app/graphql/types/ci/job_trace_type.rb b/app/graphql/types/ci/job_trace_type.rb new file mode 100644 index 00000000000..a68e26106b8 --- /dev/null +++ b/app/graphql/types/ci/job_trace_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# rubocop: disable Graphql/AuthorizeTypes +module Types + module Ci + class JobTraceType < BaseObject + graphql_name 'CiJobTrace' + + field :html_summary, GraphQL::Types::String, null: false, + alpha: { milestone: '15.11' }, # As we want the option to change from 10 if needed + description: "HTML summary containing the last 10 lines of the trace." + + def html_summary + object.html(last_lines: 10).html_safe + end + end + end +end +# rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 60c1c2e601d..1d12c296b2e 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -25,8 +25,8 @@ module Types description: 'References to builds that must complete before the jobs run.' field :pipeline, Types::Ci::PipelineType, null: true, description: 'Pipeline the job belongs to.' - field :runner_machine, ::Types::Ci::RunnerMachineType, null: true, - description: 'Runner machine assigned to the job.', + field :runner_manager, ::Types::Ci::RunnerManagerType, null: true, + description: 'Runner manager assigned to the job.', alpha: { milestone: '15.11' } field :stage, Types::Ci::StageType, null: true, description: 'Stage of the job.' @@ -101,6 +101,8 @@ module Types description: 'Short SHA1 ID of the commit.' field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?, description: 'Indicates the job is stuck.' + field :trace, Types::Ci::JobTraceType, null: true, + description: 'Trace generated by the job.' field :triggered, GraphQL::Types::Boolean, null: true, description: 'Whether the job was triggered.' field :web_path, GraphQL::Types::String, null: true, @@ -144,6 +146,10 @@ module Types end end + def trace + object.trace if object.has_trace? + end + def previous_stage_jobs_or_needs if object.scheduling_type == 'stage' Gitlab::Graphql::Lazy.with_value(previous_stage_jobs) do |jobs| @@ -172,17 +178,16 @@ module Types ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Stage, object.stage_id).find end - def runner_machine - BatchLoader::GraphQL.for(object.id).batch(key: :runner_machines) do |build_ids, loader| - plucked_build_to_machine_ids = ::Ci::RunnerMachineBuild.for_build(build_ids).pluck_build_id_and_runner_machine_id - runner_machines = ::Ci::RunnerMachine.id_in(plucked_build_to_machine_ids.values.uniq) - Preloaders::RunnerMachinePolicyPreloader.new(runner_machines, current_user).execute - runner_machines_by_id = runner_machines.index_by(&:id) + def runner_manager + BatchLoader::GraphQL.for(object.id).batch(key: :runner_managers) do |build_ids, loader| + plucked_build_to_runner_manager_ids = + ::Ci::RunnerManagerBuild.for_build(build_ids).pluck_build_id_and_runner_manager_id + runner_managers = ::Ci::RunnerManager.id_in(plucked_build_to_runner_manager_ids.values.uniq) + Preloaders::RunnerManagerPolicyPreloader.new(runner_managers, current_user).execute + runner_managers_by_id = runner_managers.index_by(&:id) build_ids.each do |build_id| - runner_machine_id = plucked_build_to_machine_ids[build_id] - - loader.call(build_id, runner_machines_by_id[runner_machine_id]) + loader.call(build_id, runner_managers_by_id[plucked_build_to_runner_manager_ids[build_id]]) end end end diff --git a/app/graphql/types/ci/runner_machine_type.rb b/app/graphql/types/ci/runner_manager_type.rb index 8e6656288d9..2a5053f8f07 100644 --- a/app/graphql/types/ci/runner_machine_type.rb +++ b/app/graphql/types/ci/runner_manager_type.rb @@ -2,50 +2,48 @@ module Types module Ci - class RunnerMachineType < BaseObject - graphql_name 'CiRunnerMachine' + class RunnerManagerType < BaseObject + graphql_name 'CiRunnerManager' connection_type_class(::Types::CountableConnectionType) - authorize :read_runner_machine + authorize :read_runner_manager - alias_method :runner_machine, :object + alias_method :runner_manager, :object field :architecture_name, GraphQL::Types::String, null: true, - description: 'Architecture provided by the runner machine.', + description: 'Architecture provided by the runner manager.', method: :architecture field :contacted_at, Types::TimeType, null: true, - description: 'Timestamp of last contact from the runner machine.', + description: 'Timestamp of last contact from the runner manager.', method: :contacted_at field :created_at, Types::TimeType, null: true, - description: 'Timestamp of creation of the runner machine.' + description: 'Timestamp of creation of the runner manager.' field :executor_name, GraphQL::Types::String, null: true, description: 'Executor last advertised by the runner.', method: :executor_name - field :id, ::Types::GlobalIDType[::Ci::RunnerMachine], null: false, - description: 'ID of the runner machine.' + field :id, ::Types::GlobalIDType[::Ci::RunnerManager], null: false, + description: 'ID of the runner manager.' field :ip_address, GraphQL::Types::String, null: true, - description: 'IP address of the runner machine.' + description: 'IP address of the runner manager.' field :platform_name, GraphQL::Types::String, null: true, - description: 'Platform provided by the runner machine.', + description: 'Platform provided by the runner manager.', method: :platform field :revision, GraphQL::Types::String, null: true, description: 'Revision of the runner.' - field :runner, RunnerType, null: true, description: 'Runner configuration for the runner machine.' + field :runner, RunnerType, null: true, description: 'Runner configuration for the runner manager.' field :status, Types::Ci::RunnerStatusEnum, null: false, - description: 'Status of the runner machine.' + description: 'Status of the runner manager.' field :system_id, GraphQL::Types::String, null: false, - description: 'System ID associated with the runner machine.', + description: 'System ID associated with the runner manager.', method: :system_xid field :version, GraphQL::Types::String, null: true, description: 'Version of the runner.' def executor_name - ::Ci::Runner::EXECUTOR_TYPE_TO_NAMES[runner_machine.executor_type&.to_sym] + ::Ci::Runner::EXECUTOR_TYPE_TO_NAMES[runner_manager.executor_type&.to_sym] end end end end - -Types::Ci::RunnerType.prepend_mod_with('Types::Ci::RunnerType') diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 60ea78752ca..20e8b506a3f 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -39,9 +39,12 @@ module Types field :edit_admin_url, GraphQL::Types::String, null: true, description: 'Admin form URL of the runner. Only available for administrators.' field :ephemeral_authentication_token, GraphQL::Types::String, null: true, - description: 'Ephemeral authentication token used for runner machine registration. Only available for the creator of the runner for a limited time during registration.', + description: 'Ephemeral authentication token used for runner manager registration. Only available for the creator of the runner for a limited time during registration.', authorize: :read_ephemeral_token, alpha: { milestone: '15.9' } + field :ephemeral_register_url, GraphQL::Types::String, null: true, + description: 'URL of the registration page of the runner manager. Only available for the creator of the runner for a limited time during registration.', + alpha: { milestone: '15.11' } field :executor_name, GraphQL::Types::String, null: true, description: 'Executor last advertised by the runner.', method: :executor_name @@ -65,12 +68,12 @@ module Types resolver: ::Resolvers::Ci::RunnerJobsResolver field :locked, GraphQL::Types::Boolean, null: true, description: 'Indicates the runner is locked.' - field :machines, ::Types::Ci::RunnerMachineType.connection_type, null: true, - description: 'Machines associated with the runner configuration.', - method: :runner_machines, - alpha: { milestone: '15.10' } field :maintenance_note, GraphQL::Types::String, null: true, description: 'Runner\'s maintenance notes.' + field :managers, ::Types::Ci::RunnerManagerType.connection_type, null: true, + description: 'Machines associated with the runner configuration.', + method: :runner_managers, + alpha: { milestone: '15.10' } field :maximum_timeout, GraphQL::Types::Int, null: true, description: 'Maximum timeout (in seconds) for jobs processed by the runner.' field :owner_project, ::Types::ProjectType, null: true, @@ -147,6 +150,17 @@ module Types Gitlab::Routing.url_helpers.edit_admin_runner_url(runner) if can_admin_runners? end + def ephemeral_register_url + return unless ephemeral_register_url_access_allowed?(runner) + + case runner.runner_type + when 'instance_type' + Gitlab::Routing.url_helpers.register_admin_runner_url(runner) + when 'group_type' + Gitlab::Routing.url_helpers.register_group_runner_url(runner.groups[0], runner) + end + end + def register_admin_url return unless can_admin_runners? && runner.registration_available? @@ -187,6 +201,19 @@ module Types def can_admin_runners? context[:current_user]&.can_admin_all_resources? end + + def ephemeral_register_url_access_allowed?(runner) + return unless runner.registration_available? + + case runner.runner_type + when 'instance_type' + can_admin_runners? + when 'group_type' + group = runner.groups[0] + + group && context[:current_user]&.can?(:register_group_runners, group) + end + end end end end diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb index 3484acfe25e..1d0ec7c4959 100644 --- a/app/graphql/types/clusters/agent_activity_event_type.rb +++ b/app/graphql/types/clusters/agent_activity_event_type.rb @@ -5,7 +5,7 @@ module Types class AgentActivityEventType < BaseObject graphql_name 'ClusterAgentActivityEvent' - authorize :read_cluster + authorize :read_cluster_agent connection_type_class(Types::CountableConnectionType) diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb index 24489707698..720ee2f685b 100644 --- a/app/graphql/types/clusters/agent_token_type.rb +++ b/app/graphql/types/clusters/agent_token_type.rb @@ -5,7 +5,7 @@ module Types class AgentTokenType < BaseObject graphql_name 'ClusterAgentToken' - authorize :read_cluster + authorize :read_cluster_agent connection_type_class(Types::CountableConnectionType) diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb index 5d7b8815cde..317a1aab628 100644 --- a/app/graphql/types/clusters/agent_type.rb +++ b/app/graphql/types/clusters/agent_type.rb @@ -5,7 +5,7 @@ module Types class AgentType < BaseObject graphql_name 'ClusterAgent' - authorize :read_cluster + authorize :read_cluster_agent connection_type_class(Types::CountableConnectionType) diff --git a/app/graphql/types/data_transfer/base_type.rb b/app/graphql/types/data_transfer/base_type.rb index e077612bfd5..5031bd5c612 100644 --- a/app/graphql/types/data_transfer/base_type.rb +++ b/app/graphql/types/data_transfer/base_type.rb @@ -7,7 +7,7 @@ module Types field :egress_nodes, type: Types::DataTransfer::EgressNodeType.connection_type, description: 'Data nodes.', - null: true # disallow null once data_transfer_monitoring feature flag is rolled-out! + null: true # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/397693 end end end diff --git a/app/graphql/types/data_transfer/egress_node_type.rb b/app/graphql/types/data_transfer/egress_node_type.rb index a050540999f..f0ad3d15b53 100644 --- a/app/graphql/types/data_transfer/egress_node_type.rb +++ b/app/graphql/types/data_transfer/egress_node_type.rb @@ -26,12 +26,8 @@ module Types null: false field :registry_egress, GraphQL::Types::BigInt, - description: 'Registery egress for that project in that period of time.', + description: 'Registry egress for that project in that period of time.', null: false - - def total_egress - object.values.select { |x| x.is_a?(Integer) }.sum - end end end end diff --git a/app/graphql/types/data_transfer/project_data_transfer_type.rb b/app/graphql/types/data_transfer/project_data_transfer_type.rb index f385aa20a7e..36afa20194e 100644 --- a/app/graphql/types/data_transfer/project_data_transfer_type.rb +++ b/app/graphql/types/data_transfer/project_data_transfer_type.rb @@ -8,12 +8,14 @@ module Types field :total_egress, GraphQL::Types::BigInt, description: 'Total egress for that project in that period of time.', - null: true # disallow null once data_transfer_monitoring feature flag is rolled-out! + null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/397693 + extras: [:parent] - def total_egress(**_) - return unless Feature.enabled?(:data_transfer_monitoring) + def total_egress(parent:) + return unless Feature.enabled?(:data_transfer_monitoring, parent.group) + return 40_000_000 if Feature.enabled?(:data_transfer_monitoring_mock_data, parent.group) - 40_000_000 + object[:egress_nodes].sum('repository_egress + artifacts_egress + packages_egress + registry_egress') end end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 3543ac29c17..d352d82a52e 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -241,7 +241,7 @@ module Types field :data_transfer, Types::DataTransfer::GroupDataTransferType, null: true, - resolver: Resolvers::DataTransferResolver.group, + resolver: Resolvers::DataTransfer::GroupDataTransferResolver, description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' def label(title:) diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 3c288c1d496..d73eaed8a0a 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -219,6 +219,10 @@ module Types field :timelogs, Types::TimelogType.connection_type, null: false, description: 'Timelogs on the merge request.' + field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type, + null: true, + description: 'List of award emojis associated with the merge request.' + markdown_field :title_html, null: true markdown_field :description_html, null: true @@ -295,6 +299,13 @@ module Types def detailed_merge_status ::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute end + + # This is temporary to fix a bug where `committers` is already loaded and memoized + # and calling it again with a certain GraphQL query can cause the Rails to to throw + # a ActiveRecord::ImmutableRelation error + def committers + object.commits.committers + end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 9bdbdad4386..2714f4cf502 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -8,7 +8,9 @@ module Types mount_mutation Mutations::Achievements::Award, alpha: { milestone: '15.10' } mount_mutation Mutations::Achievements::Create, alpha: { milestone: '15.8' } + mount_mutation Mutations::Achievements::Delete, alpha: { milestone: '15.11' } mount_mutation Mutations::Achievements::Revoke, alpha: { milestone: '15.10' } + mount_mutation Mutations::Achievements::Update, alpha: { milestone: '15.11' } mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs mount_mutation Mutations::AlertManagement::CreateAlertIssue mount_mutation Mutations::AlertManagement::UpdateAlertStatus @@ -168,6 +170,7 @@ module Types mount_mutation Mutations::WorkItems::Update, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::Export, alpha: { milestone: '15.10' } + mount_mutation Mutations::WorkItems::Convert, alpha: { milestone: '15.11' } mount_mutation Mutations::SavedReplies::Create mount_mutation Mutations::SavedReplies::Update mount_mutation Mutations::Pages::MarkOnboardingComplete diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb index f35f42001e0..9f8f9e4f2b9 100644 --- a/app/graphql/types/permission_types/work_item.rb +++ b/app/graphql/types/permission_types/work_item.rb @@ -6,7 +6,8 @@ module Types graphql_name 'WorkItemPermissions' description 'Check permissions for the current user on a work item' - abilities :read_work_item, :update_work_item, :delete_work_item, :admin_work_item + abilities :read_work_item, :update_work_item, :delete_work_item, + :admin_work_item, :admin_parent_link end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 4ca2bc8b1b5..5ebc1cf7ddd 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -24,9 +24,9 @@ module Types authorize: :create_pipeline, alpha: { milestone: '15.3' }, description: 'CI/CD config variable.' do - argument :sha, GraphQL::Types::String, + argument :ref, GraphQL::Types::String, required: true, - description: 'Sha.' + description: 'Ref.' end field :full_path, GraphQL::Types::ID, @@ -136,6 +136,11 @@ module Types null: true, description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' + field :is_catalog_resource, GraphQL::Types::Boolean, + alpha: { milestone: '15.11' }, + null: true, + description: 'Indicates if a project is a catalog resource.' + field :public_jobs, GraphQL::Types::Boolean, null: true, description: 'Indicates if there is public access to pipelines and job details of the project, ' \ @@ -567,8 +572,8 @@ module Types description: "Find runners visible to the current user." field :data_transfer, Types::DataTransfer::ProjectDataTransferType, - null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! - resolver: Resolvers::DataTransferResolver.project, + null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682 + resolver: Resolvers::DataTransfer::ProjectDataTransferResolver, description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' field :visible_forks, Types::ProjectType.connection_type, @@ -589,6 +594,16 @@ module Types authorize: :read_cycle_analytics, alpha: { milestone: '15.10' } + field :tags_tipping_at_commit, ::Types::Projects::CommitParentNamesType, + null: true, + resolver: Resolvers::Projects::TagsTippingAtCommitResolver, + description: "Get tag names tipping at a given commit." + + field :branches_tipping_at_commit, ::Types::Projects::CommitParentNamesType, + null: true, + resolver: Resolvers::Projects::BranchesTippingAtCommitResolver, + description: "Get branch names tipping at a given commit." + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end @@ -635,6 +650,16 @@ module Types BatchLoader::GraphQL.wrap(object.forks_count) end + def is_catalog_resource # rubocop:disable Naming/PredicateName + lazy_catalog_resource = BatchLoader::GraphQL.for(object.id).batch do |project_ids, loader| + ::Ci::Catalog::Resource.for_projects(project_ids).each do |catalog_resource| + loader.call(catalog_resource.project_id, catalog_resource) + end + end + + Gitlab::Graphql::Lazy.with_value(lazy_catalog_resource, &:present?) + end + def statistics Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(object.id).find end @@ -643,10 +668,8 @@ module Types project.container_repositories.size end - # Even if the parameter name is `sha`, it is actually a ref name. We always send `ref` to the endpoint. - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/389065 - def ci_config_variables(sha:) - result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(sha) + def ci_config_variables(ref:) + result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(ref) return if result.nil? diff --git a/app/graphql/types/projects/commit_parent_names_type.rb b/app/graphql/types/projects/commit_parent_names_type.rb new file mode 100644 index 00000000000..0aa1ca768e9 --- /dev/null +++ b/app/graphql/types/projects/commit_parent_names_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Projects + # rubocop: disable Graphql/AuthorizeTypes + class CommitParentNamesType < BaseObject + graphql_name 'CommitParentNames' + + field :names, [GraphQL::Types::String], null: true, description: 'Names of the commit parent (branch or tag).' + field :total_count, GraphQL::Types::Int, null: true, description: 'Total of parent branches or tags.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/relative_position_type_enum.rb b/app/graphql/types/relative_position_type_enum.rb new file mode 100644 index 00000000000..e0d28bea648 --- /dev/null +++ b/app/graphql/types/relative_position_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class RelativePositionTypeEnum < BaseEnum + graphql_name 'RelativePositionType' + description 'The position to which the object should be moved' + + value 'BEFORE', 'Object is moved before an adjacent object.' + value 'AFTER', 'Object is moved after an adjacent object.' + end +end diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index ab5d1bd8c9e..40eade3a4d1 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -28,3 +28,5 @@ module Types description: 'Tree of the repository.' end end + +Types::RepositoryType.prepend_mod diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb index 3a060518cd9..88baca028ef 100644 --- a/app/graphql/types/timelog_type.rb +++ b/app/graphql/types/timelog_type.rb @@ -49,6 +49,10 @@ module Types null: true, description: 'Summary of how the time was spent.' + field :project, Types::ProjectType, + null: false, + description: 'Target project of the timelog merge request or issue.' + def user Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find end diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index b46362f66b8..888f22b4dd3 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -27,7 +27,10 @@ module Types GraphQL::Types::Int, null: false, description: 'Lock version of the work item. Incremented each time the work item is updated.' - field :project, Types::ProjectType, null: false, + field :namespace, Types::NamespaceType, null: true, + description: 'Namespace the work item belongs to.', + alpha: { milestone: '15.10' } + field :project, Types::ProjectType, null: true, description: 'Project the work item belongs to.', alpha: { milestone: '15.3' } field :state, WorkItemStateEnum, null: false, diff --git a/app/graphql/types/work_items/available_export_fields_enum.rb b/app/graphql/types/work_items/available_export_fields_enum.rb index 59dd7ba89b1..f5b26d9818d 100644 --- a/app/graphql/types/work_items/available_export_fields_enum.rb +++ b/app/graphql/types/work_items/available_export_fields_enum.rb @@ -8,6 +8,7 @@ module Types value 'ID', value: 'id', description: 'Unique identifier.' value 'TITLE', value: 'title', description: 'Title.' + value 'DESCRIPTION', value: 'description', description: 'Description.' value 'TYPE', value: 'type', description: 'Type of the work item.' value 'AUTHOR', value: 'author', description: 'Author name.' value 'AUTHOR_USERNAME', value: 'author username', description: 'Author username.' diff --git a/app/graphql/types/work_items/award_emoji_update_action_enum.rb b/app/graphql/types/work_items/award_emoji_update_action_enum.rb new file mode 100644 index 00000000000..5b2512a215f --- /dev/null +++ b/app/graphql/types/work_items/award_emoji_update_action_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class AwardEmojiUpdateActionEnum < BaseEnum + graphql_name 'WorkItemAwardEmojiUpdateAction' + description 'Values for work item award emoji update enum' + + value 'ADD', 'Adds the emoji.', value: :add + value 'REMOVE', 'Removes the emoji.', value: :remove + end + end +end diff --git a/app/graphql/types/work_items/todo_update_action_enum.rb b/app/graphql/types/work_items/todo_update_action_enum.rb new file mode 100644 index 00000000000..d9ce8f65396 --- /dev/null +++ b/app/graphql/types/work_items/todo_update_action_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class TodoUpdateActionEnum < BaseEnum + graphql_name 'WorkItemTodoUpdateAction' + description 'Values for work item to-do update enum' + + value 'MARK_AS_DONE', 'Marks the to-do as done.', value: 'mark_as_done' + value 'ADD', 'Adds the to-do.', value: 'add' + end + end +end diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index 50f8e4f7d8a..53ea901ea10 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -19,7 +19,9 @@ module Types ::Types::WorkItems::Widgets::StartAndDueDateType, ::Types::WorkItems::Widgets::MilestoneType, ::Types::WorkItems::Widgets::NotesType, - ::Types::WorkItems::Widgets::NotificationsType + ::Types::WorkItems::Widgets::NotificationsType, + ::Types::WorkItems::Widgets::CurrentUserTodosType, + ::Types::WorkItems::Widgets::AwardEmojiType ].freeze def self.ce_orphan_types @@ -47,6 +49,10 @@ module Types ::Types::WorkItems::Widgets::NotesType when ::WorkItems::Widgets::Notifications ::Types::WorkItems::Widgets::NotificationsType + when ::WorkItems::Widgets::CurrentUserTodos + ::Types::WorkItems::Widgets::CurrentUserTodosType + when ::WorkItems::Widgets::AwardEmoji + ::Types::WorkItems::Widgets::AwardEmojiType else raise "Unknown GraphQL type for widget #{object}" end diff --git a/app/graphql/types/work_items/widgets/award_emoji_type.rb b/app/graphql/types/work_items/widgets/award_emoji_type.rb new file mode 100644 index 00000000000..421bb8f0e98 --- /dev/null +++ b/app/graphql/types/work_items/widgets/award_emoji_type.rb @@ -0,0 +1,41 @@ +# 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 AwardEmojiType < BaseObject + graphql_name 'WorkItemWidgetAwardEmoji' + description 'Represents the award emoji widget' + + implements Types::WorkItems::WidgetInterface + + field :award_emoji, + ::Types::AwardEmojis::AwardEmojiType.connection_type, + null: true, + description: 'Award emoji on the work item.' + field :downvotes, + GraphQL::Types::Int, + null: false, + description: 'Number of downvotes the work item has received.' + field :upvotes, + GraphQL::Types::Int, + null: false, + description: 'Number of upvotes the work item has received.' + + def downvotes + BatchLoaders::AwardEmojiVotesBatchLoader + .load_downvotes(object.work_item, awardable_class: 'Issue') + end + + def upvotes + BatchLoaders::AwardEmojiVotesBatchLoader + .load_upvotes(object.work_item, awardable_class: 'Issue') + end + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb b/app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb new file mode 100644 index 00000000000..1d43d4913d2 --- /dev/null +++ b/app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class AwardEmojiUpdateInputType < BaseInputObject + graphql_name 'WorkItemWidgetAwardEmojiUpdateInput' + + argument :action, ::Types::WorkItems::AwardEmojiUpdateActionEnum, + required: true, + description: 'Action for the update.' + + argument :name, + GraphQL::Types::String, + required: true, + description: copy_field_description(Types::AwardEmojis::AwardEmojiType, :name) + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/current_user_todos_input_type.rb b/app/graphql/types/work_items/widgets/current_user_todos_input_type.rb new file mode 100644 index 00000000000..630958def53 --- /dev/null +++ b/app/graphql/types/work_items/widgets/current_user_todos_input_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class CurrentUserTodosInputType < BaseInputObject + graphql_name 'WorkItemWidgetCurrentUserTodosInput' + + argument :action, ::Types::WorkItems::TodoUpdateActionEnum, + required: true, + description: 'Action for the update.' + + argument :todo_id, + ::Types::GlobalIDType[::Todo], + required: false, + description: "Global ID of the to-do. If not present, all to-dos of the work item will be updated.", + prepare: ->(id, _) { id.model_id } + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/current_user_todos_type.rb b/app/graphql/types/work_items/widgets/current_user_todos_type.rb new file mode 100644 index 00000000000..1c7cdd631e2 --- /dev/null +++ b/app/graphql/types/work_items/widgets/current_user_todos_type.rb @@ -0,0 +1,26 @@ +# 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 CurrentUserTodosType < BaseObject + graphql_name 'WorkItemWidgetCurrentUserTodos' + description 'Represents a todos widget' + + implements Types::WorkItems::WidgetInterface + implements Types::CurrentUserTodos + + private + + # Overriden as `Types::CurrentUserTodos` relies on `unpresented` being the Issuable record. + def unpresented + object.work_item + end + end + # rubocop:enable Graphql/AuthorizeTypes + 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 index e1a9ebb76e9..297b06a8fab 100644 --- a/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb +++ b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb @@ -6,16 +6,27 @@ module Types class HierarchyUpdateInputType < BaseInputObject graphql_name 'WorkItemWidgetHierarchyUpdateInput' - argument :parent_id, ::Types::GlobalIDType[::WorkItem], + argument :adjacent_work_item_id, + ::Types::GlobalIDType[::WorkItem], required: false, loads: ::Types::WorkItemType, - description: 'Global ID of the parent work item. Use `null` to remove the association.' + description: 'ID of the work item to be switched with.' argument :children_ids, [::Types::GlobalIDType[::WorkItem]], required: false, description: 'Global IDs of children work items.', loads: ::Types::WorkItemType, as: :children + + 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 :relative_position, + Types::RelativePositionTypeEnum, + required: false, + description: 'Type of switch. Valid values are `BEFORE` or `AFTER`.' end end end diff --git a/app/helpers/abuse_reports_helper.rb b/app/helpers/abuse_reports_helper.rb new file mode 100644 index 00000000000..c18c78b26c7 --- /dev/null +++ b/app/helpers/abuse_reports_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module AbuseReportsHelper + def valid_image_mimetypes + Gitlab::FileTypeDetection::SAFE_IMAGE_EXT + .map { |extension| "image/#{extension}" } + .to_sentence(last_word_connector: ' or ') + end +end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index a4f19480539..0f6c81f5238 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -2,6 +2,6 @@ module AccountsHelper def incoming_email_token_enabled? - current_user.incoming_email_token && Gitlab::IncomingEmail.supports_issue_creation? + current_user.incoming_email_token && Gitlab::Email::IncomingEmail.supports_issue_creation? end end diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb index bd83ed19705..1741d6a953a 100644 --- a/app/helpers/admin/application_settings/settings_helper.rb +++ b/app/helpers/admin/application_settings/settings_helper.rb @@ -11,6 +11,10 @@ module Admin inactive_projects_send_warning_email_after_months: settings.inactive_projects_send_warning_email_after_months } end + + def project_missing_pipeline_yaml?(project) + project.repository&.gitlab_ci_yml.blank? + end end end end diff --git a/app/helpers/admin/background_migrations_helper.rb b/app/helpers/admin/background_migrations_helper.rb index 79bb13810bb..cea9cd704c3 100644 --- a/app/helpers/admin/background_migrations_helper.rb +++ b/app/helpers/admin/background_migrations_helper.rb @@ -5,6 +5,7 @@ module Admin def batched_migration_status_badge_variant(migration) variants = { active: :info, + finalizing: :info, paused: :warning, failed: :danger, finished: :success diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d0602952f9a..8c0a95b5fa8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -281,7 +281,11 @@ module ApplicationHelper end def startup_css_enabled? - !params.has_key?(:no_startup_css) + !Feature.enabled?(:remove_startup_css) && !params.has_key?(:no_startup_css) + end + + def sign_in_with_redirect? + current_page?(new_user_session_path) && session[:user_return_to].present? end def outdated_browser? @@ -316,6 +320,7 @@ module ApplicationHelper class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards) class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards) class_names << 'with-performance-bar' if performance_bar_enabled? + class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar class_names << system_message_class class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com? diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index fd684ee5ecb..42c9481828c 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -356,6 +356,7 @@ module ApplicationSettingsHelper :shared_runners_text, :sign_in_text, :signup_enabled, + :silent_mode_enabled, :sourcegraph_enabled, :sourcegraph_url, :sourcegraph_public_only, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index e2e89c9abca..58d647c41b1 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -45,9 +45,11 @@ module AuthHelper provider_has_builtin_icon?(name) || provider_has_custom_icon?(name) end - def qa_class_for_provider(provider) + def qa_selector_for_provider(provider) { - saml: 'qa-saml-login-button' + saml: 'saml_login_button', + openid_connect: 'oidc_login_button', + github: 'github_login_button' }[provider.to_sym] end diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb index 5117f7c6d9c..4eda89e2af2 100644 --- a/app/helpers/blame_helper.rb +++ b/app/helpers/blame_helper.rb @@ -39,4 +39,14 @@ module BlameHelper row_height_exp = line_count == 1 ? COMMIT_BLOCK_HEIGHT_EXP : total_line_height_exp "contain-intrinsic-size: 1px calc(#{row_height_exp})" end + + def blame_pages_streaming_url(id, project) + namespace_project_blame_page_url(namespace_id: project.namespace, project_id: project, id: id, streaming: true) + end + + def entire_blame_path(id, project, blame_mode) + params = blame_mode.streaming_supported? ? { streaming: true } : { no_pagination: true } + + namespace_project_blame_path(namespace_id: project.namespace, project_id: project, id: id, **params) + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index bb6fd6c3dad..02f69327dff 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -330,4 +330,17 @@ module BlobHelper @path.to_s.end_with?(Ci::Pipeline::CONFIG_EXTENSION) || @path.to_s == @project.ci_config_path_or_default end + + def vue_blob_app_data(project, blob, ref) + { + blob_path: blob.path, + project_path: project.full_path, + resource_id: project.to_global_id, + user_id: current_user.present? ? current_user.to_global_id : '', + target_branch: project.empty_repo? ? ref : @ref, + original_branch: @ref + } + end end + +BlobHelper.prepend_mod_with('BlobHelper') diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb index 38ed6e95a44..6996c7a1766 100644 --- a/app/helpers/breadcrumbs_helper.rb +++ b/app/helpers/breadcrumbs_helper.rb @@ -12,7 +12,7 @@ module BreadcrumbsHelper def breadcrumb_title_link return @breadcrumb_link if @breadcrumb_link - request.path + request.fullpath end def breadcrumb_title(title) diff --git a/app/helpers/ci/catalog/resources_helper.rb b/app/helpers/ci/catalog/resources_helper.rb index 46d78cd6b24..9f70410f17f 100644 --- a/app/helpers/ci/catalog/resources_helper.rb +++ b/app/helpers/ci/catalog/resources_helper.rb @@ -3,13 +3,15 @@ module Ci module Catalog module ResourcesHelper - def can_view_private_catalog?(_project) + def can_view_namespace_catalog?(_project) false end - def js_ci_catalog_data + def js_ci_catalog_data(_project) {} end end end end + +Ci::Catalog::ResourcesHelper.prepend_mod_with('Ci::Catalog::ResourcesHelper') diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 823332c3d1d..90c89f04dc7 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -101,13 +101,9 @@ module Ci has_gitlab_ci: has_gitlab_ci?(project).to_s, pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project), suggested_ci_templates: suggested_ci_templates.to_json, - ci_runner_settings_path: project_settings_ci_cd_path(project, anchor: 'js-runners-settings') + full_path: project.full_path } - experiment(:runners_availability_section, namespace: project.root_ancestor) do |e| - e.candidate { data[:any_runners_available] = project.active_runners.exists?.to_s } - end - experiment(:ios_specific_templates, actor: current_user, project: project, sticky_to: project) do |e| e.candidate do data[:registration_token] = project.runners_token if can?(current_user, :register_project_runners, project) diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 8449bccd285..5d554f57cc0 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -37,7 +37,6 @@ module ClustersHelper editable: can_edit.to_s, environment_scope: cluster.environment_scope, base_domain: cluster.base_domain, - application_ingress_external_ip: cluster.application_ingress_external_ip, auto_devops_help_path: help_page_path('topics/autodevops/index'), external_endpoint_help_path: help_page_path('user/project/clusters/gitlab_managed_clusters.md', anchor: 'base-domain') } diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 0352f5a1dfc..7c239f78088 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -41,7 +41,7 @@ module DashboardHelper if doc_href.present? link_to_doc = link_to( - sprite_icon('question'), + sprite_icon('question-o'), doc_href, class: 'gl-ml-2', title: _('Documentation'), diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index ce64ac1f21f..2ced1bec5e9 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -132,16 +132,6 @@ module GroupsHelper } end - def verification_for_group_creation_data - # overridden in EE - {} - end - - def require_verification_for_namespace_creation_enabled? - # overridden in EE - false - end - def group_name_and_path_app_data { base_path: root_url, @@ -161,6 +151,7 @@ module GroupsHelper new_project_path: new_project_path(namespace_id: group.id), new_subgroup_illustration: image_path('illustrations/subgroup-create-new-sm.svg'), new_project_illustration: image_path('illustrations/project-create-new-sm.svg'), + empty_projects_illustration: image_path('illustrations/empty-state/empty-projects-md.svg'), empty_subgroup_illustration: image_path('illustrations/empty-state/empty-subgroup-md.svg'), render_empty_state: 'true', can_create_subgroups: can?(current_user, :create_subgroup, group).to_s, @@ -168,6 +159,26 @@ module GroupsHelper } end + def group_readme_app_data(group_readme) + { + web_path: group_readme.present.web_path, + name: group_readme.present.name + } + end + + def show_group_readme?(group) + group.group_readme + end + + def group_settings_readme_app_data(group) + { + group_readme_path: group.group_readme&.present&.web_path, + readme_project_path: group.readme_project&.present&.path_with_namespace, + group_path: group.full_path, + group_id: group.id + } + end + def enabled_git_access_protocol_options_for_group case ::Gitlab::CurrentSettings.enabled_git_access_protocol when nil, "" diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 063eef41f77..a8dbaa4325f 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -7,6 +7,7 @@ module IdeHelper 'can-use-new-web-ide' => can_use_new_web_ide?.to_s, 'use-new-web-ide' => use_new_web_ide?.to_s, 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'), + 'sign-in-path' => new_session_path(current_user), 'user-preferences-path' => profile_preferences_path, 'editor-font-src-url' => font_url('jetbrains-mono/JetBrainsMono.woff2'), 'editor-font-family' => 'JetBrains Mono', @@ -82,5 +83,3 @@ module IdeHelper current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance') end end - -IdeHelper.prepend_mod_with('IdeHelper') diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 9c68f54f42e..179ce01ae44 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -173,9 +173,6 @@ module IssuablesHelper output << content_tag(:span, (sprite_icon('first-contribution', css_class: 'gl-icon gl-vertical-align-middle') if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!')) - output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-md-inline-block gl-ml-3") - output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none") - output.join.html_safe end @@ -252,7 +249,7 @@ module IssuablesHelper initialTitleText: issuable.title, initialDescriptionHtml: markdown_field(issuable, :description), initialDescriptionText: issuable.description, - initialTaskStatus: issuable.task_status + initialTaskCompletionStatus: issuable.task_completion_status } data.merge!(issue_only_initial_data(issuable)) data.merge!(path_data(parent)) @@ -389,6 +386,16 @@ module IssuablesHelper end end + def issuable_type_selector_data(issuable) + { + selected_type: issuable.issue_type, + is_issue_allowed: create_issue_type_allowed?(@project, :issue).to_s, + is_incident_allowed: create_issue_type_allowed?(@project, :incident).to_s, + issue_path: new_project_issue_path(@project), + incident_path: new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }) + } + end + private def sidebar_gutter_collapsed? @@ -438,7 +445,7 @@ module IssuablesHelper toggleSubscriptionEndpoint: issuable[:toggle_subscription_path], moveIssueEndpoint: issuable[:move_issue_path], projectsAutocompleteEndpoint: issuable[:projects_autocomplete_path], - editable: issuable.dig(:current_user, :can_edit), + editable: issuable.dig(:current_user, :can_edit).to_s, currentUser: issuable[:current_user], rootPath: root_path, fullPath: issuable[:project_full_path], diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 39399c2919b..e82f09a0a97 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -44,14 +44,6 @@ module IssuesHelper end end - def work_item_type_icon(issue_type) - if WorkItems::Type.base_types.include?(issue_type) - "issue-type-#{issue_type.to_s.dasherize}" - else - 'issue-type-issue' - end - end - def confidential_icon(issue) sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential? end diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 8c069bc828b..c4967a42a45 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -80,27 +80,20 @@ module LabelsHelper def suggested_colors { + '#cc338b' => s_('SuggestedColors|Magenta-pink'), + '#dc143c' => s_('SuggestedColors|Crimson'), + '#c21e56' => s_('SuggestedColors|Rose red'), + '#cd5b45' => s_('SuggestedColors|Dark coral'), + '#ed9121' => s_('SuggestedColors|Carrot orange'), + '#eee600' => s_('SuggestedColors|Titanium yellow'), '#009966' => s_('SuggestedColors|Green-cyan'), '#8fbc8f' => s_('SuggestedColors|Dark sea green'), - '#3cb371' => s_('SuggestedColors|Medium sea green'), - '#00b140' => s_('SuggestedColors|Green screen'), - '#013220' => s_('SuggestedColors|Dark green'), '#6699cc' => s_('SuggestedColors|Blue-gray'), - '#0000ff' => s_('SuggestedColors|Blue'), '#e6e6fa' => s_('SuggestedColors|Lavender'), '#9400d3' => s_('SuggestedColors|Dark violet'), '#330066' => s_('SuggestedColors|Deep violet'), - '#808080' => s_('SuggestedColors|Gray'), '#36454f' => s_('SuggestedColors|Charcoal grey'), - '#f7e7ce' => s_('SuggestedColors|Champagne'), - '#c21e56' => s_('SuggestedColors|Rose red'), - '#cc338b' => s_('SuggestedColors|Magenta-pink'), - '#dc143c' => s_('SuggestedColors|Crimson'), - '#ff0000' => s_('SuggestedColors|Red'), - '#cd5b45' => s_('SuggestedColors|Dark coral'), - '#eee600' => s_('SuggestedColors|Titanium yellow'), - '#ed9121' => s_('SuggestedColors|Carrot orange'), - '#c39953' => s_('SuggestedColors|Aztec Gold') + '#808080' => s_('SuggestedColors|Gray') } end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 7d9be2f93fd..833f2874a90 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -186,7 +186,7 @@ module MergeRequestsHelper endpoint_metadata: @endpoint_metadata_url, endpoint_batch: diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params), endpoint_coverage: @coverage_path, - endpoint_diff_for_path: diff_for_path_namespace_project_merge_request_path(format: 'json', id: merge_request.iid, namespace_id: project.namespace.path, project_id: project.path), + endpoint_diff_for_path: diff_for_path_namespace_project_merge_request_path(format: 'json', id: merge_request.iid, namespace_id: project.namespace.to_param, project_id: project.path), help_page_path: help_page_path('user/project/merge_requests/reviews/suggestions.md'), current_user_data: @current_user_data, update_current_user_path: @update_current_user_path, @@ -201,7 +201,7 @@ module MergeRequestsHelper source_project_default_url: @merge_request.source_project && default_url_to_repo(@merge_request.source_project), source_project_full_path: @merge_request.source_project&.full_path, is_forked: @project.forked?.to_s, - saved_replies_new_path: profile_saved_replies_path + new_comment_template_path: profile_comment_templates_path } end @@ -233,24 +233,40 @@ module MergeRequestsHelper end def merge_request_source_branch(merge_request) + fork_icon = if merge_request.for_fork? + title = _('The source project is a fork') + content_tag(:span, class: 'gl-vertical-align-middle gl-mr-n2 has-tooltip', title: title) do + sprite_icon('fork', size: 12, css_class: 'gl-ml-1 has-tooltip') + end + else + '' + end + branch = if merge_request.for_fork? - "#{merge_request.source_project_path}:#{merge_request.source_branch}" + _('%{fork_icon} %{source_project_path}:%{source_branch}').html_safe % { fork_icon: fork_icon.html_safe, source_project_path: merge_request.source_project_path.html_safe, source_branch: merge_request.source_branch.html_safe } else merge_request.source_branch end + branch_title = if merge_request.for_fork? + _('%{source_project_path}:%{source_branch}').html_safe % { source_project_path: merge_request.source_project_path.html_safe, source_branch: merge_request.source_branch.html_safe } + else + merge_request.source_branch + end + branch_path = if merge_request.source_project project_tree_path(merge_request.source_project, merge_request.source_branch) else '' end - link_to branch, branch_path, title: branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' + link_to branch, branch_path, title: branch_title, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' end def merge_request_header(project, merge_request) link_to_author = link_to_member(project, merge_request.author, size: 24, extra_class: 'gl-font-weight-bold gl-mr-2', avatar: false) copy_button = clipboard_button(text: merge_request.source_branch, title: _('Copy branch name'), class: 'btn btn-default btn-sm gl-button btn-default-tertiary btn-icon gl-display-none! gl-md-display-inline-block! js-source-branch-copy') + target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' _('%{author} requested to merge %{source_branch} %{copy_button} into %{target_branch} %{created_at}').html_safe % { author: link_to_author.html_safe, source_branch: merge_request_source_branch(merge_request).html_safe, copy_button: copy_button.html_safe, target_branch: target_branch.html_safe, created_at: time_ago_with_tooltip(merge_request.created_at, html_class: 'gl-display-inline-block').html_safe } @@ -260,6 +276,10 @@ module MergeRequestsHelper Feature.enabled?(:moved_mr_sidebar, @project) && defined?(@merge_request) end + def single_file_file_by_file? + Feature.enabled?(:single_file_file_by_file, @project) + end + def sticky_header_data data = { iid: @merge_request.iid, diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 59ffe6a183e..b101f184ca6 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -97,7 +97,7 @@ module NavHelper def super_sidebar_supported? return true if @nav.nil? - %w(your_work explore project group profile user_profile).include?(@nav) + %w(your_work explore project group profile user_profile search admin).include?(@nav) end def get_header_links diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index dec1943db54..8861f1ffe9a 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -69,6 +69,11 @@ module PackagesHelper Ability.allowed?(current_user, :admin_package, project) end + def show_group_package_registry_settings(group) + group.packages_feature_enabled? && + Ability.allowed?(current_user, :admin_group, group) + end + def cleanup_settings_data { project_id: @project.id, diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 2442856d7fe..f2fa82aebdb 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -132,7 +132,7 @@ module PreferencesHelper Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/' end - # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too + # Ensure that anyone adding new options updates `localized_dashboard_choices` too def validate_dashboard_choices!(user_dashboards) if user_dashboards.size != localized_dashboard_choices.size raise "`User` defines #{user_dashboards.size} dashboard choices," \ diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index 55216d412a5..1f044ebed3b 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -15,6 +15,7 @@ module Projects path_to_artifact: link_to_artifact(candidate), experiment_name: candidate.experiment.name, path_to_experiment: link_to_experiment(candidate.project, candidate.experiment), + path: link_to_details(candidate), status: candidate.status }, metadata: candidate.metadata @@ -24,6 +25,15 @@ module Projects Gitlab::Json.generate(data) end + def experiment_as_data(experiment) + data = { + name: experiment.name, + path: link_to_experiment(experiment.project, experiment) + } + + Gitlab::Json.generate(data) + end + def candidates_table_items(candidates) items = candidates.map do |candidate| { diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 5c62920cd89..c5cbe79caf7 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -8,7 +8,7 @@ module Projects { failed_jobs_count: pipeline.failed_builds.count, failed_jobs_summary: prepare_failed_jobs_summary_data(pipeline.failed_builds), - full_path: project.full_path, + project_path: project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(pipeline), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), pipeline_iid: pipeline.iid, diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index a854b9990d2..9452aa491e4 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -2,6 +2,7 @@ module ProjectsHelper include Gitlab::Utils::StrongMemoize + include CompareHelper def project_incident_management_setting @project_incident_management_setting ||= @project.incident_management_setting || @@ -131,15 +132,20 @@ module ProjectsHelper source_default_branch = source_project.default_branch { + project_path: project.full_path, + selected_branch: ref, source_name: source_project.full_name, source_path: project_path(source_project), source_default_branch: source_default_branch, + can_sync_branch: ::Gitlab::UserAccess.new(current_user, container: project).can_update_branch?(ref).to_s, ahead_compare_path: project_compare_path( project, from: source_default_branch, to: ref, from_project_id: source_project.id ), + create_mr_path: create_mr_path(from: ref, source_project: project, to: source_default_branch, target_project: source_project), behind_compare_path: project_compare_path( source_project, from: ref, to: source_default_branch, from_project_id: project.id - ) + ), + can_user_create_mr_in_fork: can_user_create_mr_in_fork(source_project) } end @@ -161,6 +167,10 @@ module ProjectsHelper project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source) end + def can_user_create_mr_in_fork(project) + can?(current_user, :create_merge_request_in, project) + end + def project_search_tabs?(tab) return false unless @project.present? @@ -837,4 +847,12 @@ def can_admin_group_clusters?(project) project.group && project.group.clusters.any? && can?(current_user, :admin_cluster, project.group) end +def can_view_branch_rules? + can?(current_user, :maintainer_access, @project) +end + +def branch_rules_path + project_settings_repository_path(@project, anchor: 'js-branch-rules') +end + ProjectsHelper.prepend_mod_with('ProjectsHelper') diff --git a/app/helpers/protected_branches_helper.rb b/app/helpers/protected_branches_helper.rb index 07b07bfd33c..bd2a4d1170d 100644 --- a/app/helpers/protected_branches_helper.rb +++ b/app/helpers/protected_branches_helper.rb @@ -17,3 +17,5 @@ module ProtectedBranchesHelper end end end + +ProtectedBranchesHelper.prepend_mod diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index 63e2b377fef..a304d14afb9 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -13,6 +13,11 @@ module Routing glm_source glm_content _gl + utm_medium + utm_source + utm_campaign + utm_content + utm_budget ].freeze def initialize(request_object, group, project) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index d62dc038388..9d14784f086 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -341,7 +341,7 @@ module SearchHelper # Autocomplete results for the current user's projects # rubocop: disable CodeReuse/ActiveRecord def projects_autocomplete(term, limit = 5) - current_user.authorized_projects.order_id_desc.search_by_title(term) + current_user.authorized_projects.order_id_desc.search(term, include_namespace: true) .sorted_by_stars_desc.non_archived.limit(limit).map do |p| { category: "Projects", diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 6c9688b0f9d..5bbc89a9d65 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -4,6 +4,8 @@ module SidebarsHelper include MergeRequestsHelper include Nav::NewDropdownHelper + USER_BAR_COUNT_LIMIT = 99 + def sidebar_tracking_attributes_by_object(object) sidebar_attributes_for_object(object).fetch(:tracking_attrs, {}) end @@ -40,7 +42,7 @@ module SidebarsHelper Sidebars::Context.new(**context_data, **args) end - def super_sidebar_context(user, group:, project:, panel:) + def super_sidebar_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize { current_menu_items: panel.super_sidebar_menu_items, current_context_header: panel.super_sidebar_context_header, @@ -50,15 +52,7 @@ module SidebarsHelper has_link_to_profile: current_user_menu?(:profile), link_to_profile: user_url(user), logo_url: current_appearance&.header_logo_path, - status: { - can_update: can?(current_user, :update_user_status, current_user), - busy: user.status&.busy?, - customized: user.status&.customized?, - availability: user.status&.availability.to_s, - emoji: user.status&.emoji, - message: user.status&.message_html&.html_safe, - clear_after: user_clear_status_at(user) - }, + status: user_status_menu_data(user), trial: { has_start_trial: current_user_menu?(:start_trial), url: trials_link_url @@ -70,14 +64,14 @@ module SidebarsHelper }, can_sign_out: current_user_menu?(:sign_out), sign_out_link: destroy_user_session_path, - assigned_open_issues_count: user.assigned_open_issues_count, + assigned_open_issues_count: format_user_bar_count(user.assigned_open_issues_count), todos_pending_count: user.todos_pending_count, issues_dashboard_path: issues_dashboard_path(assignee_username: user.username), - total_merge_requests_count: user_merge_requests_counts[:total], + total_merge_requests_count: format_user_bar_count(user_merge_requests_counts[:total]), create_new_menu_groups: create_new_menu_groups(group: group, project: project), merge_request_menu: create_merge_request_menu(user), - projects_path: projects_path, - groups_path: groups_path, + projects_path: dashboard_projects_path, + groups_path: dashboard_groups_path, support_path: support_url, display_whats_new: display_whats_new?, whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count, @@ -88,7 +82,15 @@ module SidebarsHelper gitlab_com_but_not_canary: Gitlab.com_but_not_canary?, gitlab_com_and_canary: Gitlab.com_and_canary?, canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url, - current_context: super_sidebar_current_context(project: project, group: group) + current_context: super_sidebar_current_context(project: project, group: group), + context_switcher_links: context_switcher_links, + search: search_data, + pinned_items: user.pinned_nav_items[panel_type] || [], + panel_type: panel_type, + update_pins_url: pins_url, + is_impersonating: impersonating?, + stop_impersonation_path: admin_impersonation_path, + shortcut_links: shortcut_links } end @@ -111,6 +113,11 @@ module SidebarsHelper Sidebars::UserProfile::Panel.new(context) when 'explore' Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) + when 'search' + context = Sidebars::Context.new(current_user: user, container: nil, **context_adds) + Sidebars::Search::Panel.new(context) + when 'admin' + Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) else context = your_work_sidebar_context(user, **context_adds) Sidebars::YourWork::Panel.new(context) @@ -119,6 +126,28 @@ module SidebarsHelper private + def search_data + { + search_path: search_path, + issues_path: issues_dashboard_path, + mr_path: merge_requests_dashboard_path, + autocomplete_path: search_autocomplete_path, + search_context: header_search_context + } + end + + def user_status_menu_data(user) + { + can_update: can?(user, :update_user_status, user), + busy: user.status&.busy?, + customized: user.status&.customized?, + availability: user.status&.availability.to_s, + emoji: user.status&.emoji, + message: user.status&.message_html&.html_safe, + clear_after: user_clear_status_at(user) + } + end + def create_new_menu_groups(group:, project:) new_dropdown_sections = new_dropdown_view_model(group: group, project: project)[:menu_sections] show_headers = new_dropdown_sections.length > 1 @@ -128,7 +157,14 @@ module SidebarsHelper items: section[:menu_items].map do |item| { text: item[:title], - href: item[:href] + href: item[:href], + extraAttrs: { + 'data-track-label': item[:id], + 'data-track-action': 'click_link', + 'data-track-property': 'nav_create_menu', + 'data-qa-selector': 'create_menu_item', + 'data-qa-create-menu-item': item[:id] + } } end } @@ -143,12 +179,24 @@ module SidebarsHelper { text: _('Assigned'), href: merge_requests_dashboard_path(assignee_username: user.username), - count: user_merge_requests_counts[:assigned] + count: user_merge_requests_counts[:assigned], + extraAttrs: { + 'data-track-action': 'click_link', + 'data-track-label': 'merge_requests_assigned', + 'data-track-property': 'nav_core_menu', + class: 'dashboard-shortcuts-merge_requests' + } }, { text: _('Review requests'), href: merge_requests_dashboard_path(reviewer_username: user.username), - count: user_merge_requests_counts[:review_requested] + count: user_merge_requests_counts[:review_requested], + extraAttrs: { + 'data-track-action': 'click_link', + 'data-track-label': 'merge_requests_to_review', + 'data-track-property': 'nav_core_menu', + class: 'dashboard-shortcuts-review_requests' + } } ] } @@ -260,6 +308,57 @@ module SidebarsHelper {} end + + def context_switcher_links + links = [ + # We should probably not return "You work" when used is not logged-in + { title: s_('Navigation|Your work'), link: root_path, icon: 'work' }, + { title: s_('Navigation|Explore'), link: explore_root_path, icon: 'compass' } + ] + + if current_user&.can_admin_all_resources? + links.append( + { title: s_('Navigation|Admin'), link: admin_root_path, icon: 'admin' } + ) + end + + links + end + + # Formats the counts to be shown in the super sidebar's top section (issues, MRs and todos). + # We want to avoid printing huge numbers there, so when the count exceeds USER_BAR_COUNT_LIMIT, + # we cap it to USER_BAR_COUNT_LIMIT and append a "+" to it. + def format_user_bar_count(count) + if count > USER_BAR_COUNT_LIMIT + "#{USER_BAR_COUNT_LIMIT}+" + else + count.to_s + end + end + + def impersonating? + !!session[:impersonator_id] + end + + def shortcut_links + [ + { + title: _('Milestones'), + href: dashboard_milestones_path, + css_class: 'dashboard-shortcuts-milestones' + }, + { + title: _('Snippets'), + href: dashboard_snippets_path, + css_class: 'dashboard-shortcuts-snippets' + }, + { + title: _('Activity'), + href: activity_dashboard_path, + css_class: 'dashboard-shortcuts-activity' + } + ] + end end SidebarsHelper.prepend_mod_with('SidebarsHelper') diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index a1b6e896475..3d31d697452 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -2,13 +2,13 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { - 'approved' => 'approval', + 'approved' => 'check', 'unapproved' => 'unapproval', 'cherry_pick' => 'cherry-pick-commit', 'commit' => 'commit', 'description' => 'pencil', - 'merge' => 'git-merge', - 'merged' => 'git-merge', + 'merge' => 'merge', + 'merged' => 'merge', 'opened' => 'issues', 'closed' => 'issue-close', 'time_tracking' => 'timer', @@ -51,7 +51,11 @@ module SystemNoteHelper }.freeze def system_note_icon_name(note) - ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + if note.system_note_metadata&.action == 'closed' && note.for_merge_request? + 'merge-request-close' + else + ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + end end def icon_for_system_note(note) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index e0cf7aa61ee..a137ff4d6f2 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -58,12 +58,21 @@ module UsersHelper end # Used to preload when you are rendering many projects and checking access - # - # rubocop: disable CodeReuse/ActiveRecord: `projects` can be array which also responds to pluck def load_max_project_member_accesses(projects) - current_user&.max_member_access_for_project_ids(projects.pluck(:id)) + # There are two different request store paradigms for max member access and + # we need to preload both of them. One is keyed User the other is keyed by + # Project. See https://gitlab.com/gitlab-org/gitlab/-/issues/396822 + + # rubocop: disable CodeReuse/ActiveRecord: `projects` can be array which also responds to pluck + project_ids = projects.pluck(:id) + # rubocop: enable CodeReuse/ActiveRecord + + Preloaders::UserMaxAccessLevelInProjectsPreloader + .new(project_ids, current_user) + .execute + + current_user&.max_member_access_for_project_ids(project_ids) end - # rubocop: enable CodeReuse/ActiveRecord def max_project_member_access(project) current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS @@ -182,7 +191,8 @@ module UsersHelper followees: user.followees.count, followers: user.followers.count, user_calendar_path: user_calendar_path(user, :json), - utc_offset: local_timezone_instance(user.timezone).now.utc_offset + utc_offset: local_timezone_instance(user.timezone).now.utc_offset, + user_id: user.id } end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index c577e2da1bb..68b15f7e042 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -22,7 +22,7 @@ module VisibilityLevelHelper when Project project_visibility_level_description(level) when Group - group_visibility_level_description(level) + group_visibility_level_description(level, form_model) end end @@ -125,22 +125,39 @@ 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 is granted to members of the group.") + s_("VisibilityLevel|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.") + s_("VisibilityLevel|The project can be accessed by any logged in user except external users.") when Gitlab::VisibilityLevel::PUBLIC - _("The project can be accessed without any authentication.") + s_("VisibilityLevel|The project can be accessed without any authentication.") end end - def group_visibility_level_description(level) + def show_updated_public_description_for_setting(group) + group && !group.new_record? && Gitlab::CurrentSettings.current_application_settings.try(:should_check_namespace_plan?) + end + + def group_visibility_level_description(level, group = nil) case level when Gitlab::VisibilityLevel::PRIVATE - _("The group and its projects can only be viewed by members.") + s_("VisibilityLevel|The group and its projects can only be viewed by members.") when Gitlab::VisibilityLevel::INTERNAL - _("The group and any internal projects can be viewed by any logged in user except external users.") + s_("VisibilityLevel|The group and any internal projects can be viewed by any logged in user except external users.") when Gitlab::VisibilityLevel::PUBLIC - _("The group and any public projects can be viewed without any authentication.") + unless show_updated_public_description_for_setting(group) + return s_('VisibilityLevel|The group and any public projects can be viewed without any authentication.') + end + + Kernel.format( + s_( + 'VisibilityLevel|The group, any public projects, and any of their members, issues, and merge requests can be viewed without authentication. ' \ + 'Public groups and projects will be indexed by search engines. ' \ + 'Read more about %{free_user_limit_doc_link_start}free user limits%{link_end}, ' \ + 'or %{group_billings_link_start}upgrade to a paid tier%{link_end}.'), + free_user_limit_doc_link_start: "<a href='#{help_page_path('user/free_user_limit')}' target='_blank' rel='noopener noreferrer'>".html_safe, + group_billings_link_start: "<a href='#{group_billings_path(group)}' target='_blank' rel='noopener noreferrer'>".html_safe, + link_end: "</a>".html_safe + ).html_safe end end diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index bc270380fca..6fa5c499ee2 100644 --- a/app/helpers/work_items_helper.rb +++ b/app/helpers/work_items_helper.rb @@ -7,7 +7,7 @@ module WorkItemsHelper issues_list_path: project_issues_path(project), register_path: new_user_registration_path(redirect_to_referer: 'yes'), sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'), - saved_replies_new_path: profile_saved_replies_path + new_comment_template_path: profile_comment_templates_path } end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index a191bd4a8f6..54a4c4be6a8 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -177,6 +177,19 @@ module Emails mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) end end + + def new_achievement_email(user, achievement) + return unless user&.active? + + @user = user + @achievement = achievement + + Gitlab::I18n.with_locale(@user.preferred_language) do + email_with_layout( + to: @user.notification_email_or_default, + subject: subject(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement") % { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name })) + end + end end end diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index 1295f978049..e75882073f2 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -43,6 +43,61 @@ module Emails inject_service_desk_custom_email(mail_answer_thread(@issue, options)) end + def service_desk_custom_email_verification_email(service_desk_setting) + @service_desk_setting = service_desk_setting + + email_sender = sender( + User.support_bot.id, + send_from_user_email: false, + sender_name: @service_desk_setting.outgoing_name, + sender_email: @service_desk_setting.custom_email + ) + + @verification_token = @service_desk_setting.custom_email_verification.token + + subject = format(s_("Notify|Verify custom email address %{email} for %{project_name}"), + email: @service_desk_setting.custom_email, + project_name: @service_desk_setting.project.name + ) + + options = { + from: email_sender, + to: @service_desk_setting.custom_email_address_for_verification, + subject: subject, + content_type: "text/plain" + } + # Outgoing emails from GitLab usually have this set to true. + # Service Desk email ingestion ignores auto generated emails. + headers["Auto-Submitted"] = "no" + + inject_service_desk_custom_email(mail_with_locale(options), force: true) + end + + def service_desk_verification_triggered_email(service_desk_setting, recipient) + @service_desk_setting = service_desk_setting + @triggerer = @service_desk_setting.custom_email_verification.triggerer + @smtp_address = @service_desk_setting.custom_email_credential.smtp_address + + subject = format(s_("Notify|Verification for custom email %{email} for %{project_name} triggered"), + email: @service_desk_setting.custom_email, + project_name: @service_desk_setting.project.name + ) + + email_with_layout(to: recipient, subject: subject) + end + + def service_desk_verification_result_email(service_desk_setting, recipient) + @service_desk_setting = service_desk_setting + @verification = @service_desk_setting.custom_email_verification + + subject = format(s_("Notify|Verification result for custom email %{email} for %{project_name}"), + email: @service_desk_setting.custom_email, + project_name: @service_desk_setting.project.name + ) + + email_with_layout(to: recipient, subject: subject) + end + private def setup_service_desk_mail(issue_id) @@ -67,10 +122,11 @@ module Emails end end - def inject_service_desk_custom_email(mail) - return mail unless service_desk_custom_email_enabled? + def inject_service_desk_custom_email(mail, force: false) + return mail if !service_desk_custom_email_enabled? && !force + return mail unless @service_desk_setting.custom_email_credential.present? - mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_delivery_options) + mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_credential.delivery_options) end def service_desk_custom_email_enabled? diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 2d6b2a3099c..65fdc233ea1 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -132,8 +132,8 @@ class Notify < ApplicationMailer @reason = headers['X-GitLab-NotificationReason'] - if Gitlab::IncomingEmail.enabled? && @sent_notification - headers['Reply-To'] = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)).tap do |address| + if Gitlab::Email::IncomingEmail.enabled? && @sent_notification + headers['Reply-To'] = Mail::Address.new(Gitlab::Email::IncomingEmail.reply_address(reply_key)).tap do |address| address.display_name = reply_display_name(model) end @@ -221,8 +221,8 @@ class Notify < ApplicationMailer return unless !@labels_url && @sent_notification && @sent_notification.unsubscribable? list_unsubscribe_methods = [unsubscribe_sent_notification_url(@sent_notification, force: true)] - if Gitlab::IncomingEmail.enabled? && Gitlab::IncomingEmail.supports_wildcard? - list_unsubscribe_methods << "mailto:#{Gitlab::IncomingEmail.unsubscribe_address(reply_key)}" + if Gitlab::Email::IncomingEmail.enabled? && Gitlab::Email::IncomingEmail.supports_wildcard? + list_unsubscribe_methods << "mailto:#{Gitlab::Email::IncomingEmail.unsubscribe_address(reply_key)}" end headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',') diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 17b225c5e9b..510f35ee0d2 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -213,6 +213,52 @@ class NotifyPreview < ActionMailer::Preview Notify.service_desk_thank_you_email(issue.id).message end + def service_desk_custom_email_verification_email + cleanup do + setup_service_desk_custom_email_objects + + Notify.service_desk_custom_email_verification_email(service_desk_setting).message + end + end + + def service_desk_verification_triggered_email + cleanup do + setup_service_desk_custom_email_objects + + Notify.service_desk_verification_triggered_email(service_desk_setting, 'owner@example.com').message + end + end + + def service_desk_verification_result_email_for_verified_state + cleanup do + setup_service_desk_custom_email_objects + + custom_email_verification.update!(state: 1) + + Notify.service_desk_verification_result_email(service_desk_setting, 'owner@example.com').message + end + end + + def service_desk_verification_result_email_for_incorrect_token_error + service_desk_verification_result_email_for_error_state(error: :incorrect_token) + end + + def service_desk_verification_result_email_for_incorrect_from_error + service_desk_verification_result_email_for_error_state(error: :incorrect_from) + end + + def service_desk_verification_result_email_for_mail_not_received_within_timeframe_error + service_desk_verification_result_email_for_error_state(error: :mail_not_received_within_timeframe) + end + + def service_desk_verification_result_email_for_invalid_credentials_error + service_desk_verification_result_email_for_error_state(error: :invalid_credentials) + end + + def service_desk_verification_result_email_for_smtp_host_issue_error + service_desk_verification_result_email_for_error_state(error: :smtp_host_issue) + end + def merge_when_pipeline_succeeds_email Notify.merge_when_pipeline_succeeds_email(user.id, merge_request.id, user.id).message end @@ -247,6 +293,53 @@ class NotifyPreview < ActionMailer::Preview @project ||= Project.first end + def service_desk_verification_result_email_for_error_state(error:) + cleanup do + setup_service_desk_custom_email_objects + + custom_email_verification.update!(state: 2, error: error) + + Notify.service_desk_verification_result_email(service_desk_setting, 'owner@example.com').message + end + end + + def setup_service_desk_custom_email_objects + # Call accessors to ensure objects have been created + custom_email_credential + custom_email_verification + + # Update associations in projects, because we access + # custom_email_credential and custom_email_verification via project + project.reset + end + + def custom_email_verification + @custom_email_verification ||= project.service_desk_custom_email_verification || ServiceDesk::CustomEmailVerification.create!( + project: project, + token: 'XXXXXXXXXXXX', + triggerer: user, + triggered_at: Time.current, + state: 0 + ) + end + + def custom_email_credential + @custom_email_credential ||= project.service_desk_custom_email_credential || ServiceDesk::CustomEmailCredential.create!( + project: project, + smtp_address: 'smtp.gmail.com', # Use gmail, because Gitlab::UrlBlocker resolves DNS + smtp_port: 587, + smtp_username: 'user@gmail.com', + smtp_password: 'supersecret' + ) + end + + def service_desk_setting + @service_desk_setting ||= project.service_desk_setting || ServiceDeskSetting.create!( + project: project, + custom_email: 'user@gmail.com' + ) + end + def issue @issue ||= project.issues.first end diff --git a/app/models/abuse/trust_score.rb b/app/models/abuse/trust_score.rb new file mode 100644 index 00000000000..9ad7c9b14b1 --- /dev/null +++ b/app/models/abuse/trust_score.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Abuse + class TrustScore < ApplicationRecord + MAX_EVENTS = 100 + + self.table_name = 'abuse_trust_scores' + + enum source: Enums::Abuse::Source.sources + + belongs_to :user + + validates :user, presence: true + validates :score, presence: true + validates :source, presence: true + + before_create :assign_correlation_id + after_commit :remove_old_scores + + private + + def assign_correlation_id + self.correlation_id_value ||= (Labkit::Correlation::CorrelationId.current_id || '') + end + + def remove_old_scores + count = user.trust_scores_for_source(source).count + return unless count > MAX_EVENTS + + TrustScore.delete( + user.trust_scores_for_source(source) + .order(created_at: :asc) + .limit(count - MAX_EVENTS) + ) + end + end +end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 5ae5367ca5a..716738e87c9 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -3,8 +3,11 @@ class AbuseReport < ApplicationRecord include CacheMarkdownField include Sortable + include Gitlab::FileTypeDetection + include WithUploads MAX_CHAR_LIMIT_URL = 512 + MAX_FILE_SIZE = 1.megabyte cache_markdown_field :message, pipeline: :single_line @@ -42,6 +45,10 @@ class AbuseReport < ApplicationRecord before_validation :filter_empty_strings_from_links_to_spam validate :links_to_spam_contains_valid_urls + mount_uploader :screenshot, AttachmentUploader + validates :screenshot, file_size: { maximum: MAX_FILE_SIZE } + validate :validate_screenshot_is_image + scope :by_user_id, ->(id) { where(user_id: id) } scope :by_reporter_id, ->(id) { where(reporter_id: id) } scope :by_category, ->(category) { where(category: category) } @@ -84,6 +91,20 @@ class AbuseReport < ApplicationRecord AbuseReportMailer.notify(id).deliver_later end + def screenshot_path + return unless screenshot + return screenshot.url unless screenshot.upload + + asset_host = ActionController::Base.asset_host || Gitlab.config.gitlab.base_url + local_path = Gitlab::Routing.url_helpers.abuse_report_upload_path( + filename: screenshot.filename, + id: screenshot.upload.model_id, + model: 'abuse_report', + mounted_as: 'screenshot') + + Gitlab::Utils.append_path(asset_host, local_path) + end + private def filter_empty_strings_from_links_to_spam @@ -113,4 +134,24 @@ class AbuseReport < ApplicationRecord rescue ::Gitlab::UrlBlocker::BlockedUrlError errors.add(:links_to_spam, _('only supports valid HTTP(S) URLs')) end + + def filename + screenshot&.filename + end + + def valid_image_extensions + Gitlab::FileTypeDetection::SAFE_IMAGE_EXT + end + + def validate_screenshot_is_image + return if screenshot.blank? + return if image? + + errors.add( + :screenshot, + format( + _('must match one of the following file types: %{extension_list}'), + extension_list: valid_image_extensions.to_sentence(last_word_connector: ' or ')) + ) + end end diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb index 95606e50ad4..a436e32b35b 100644 --- a/app/models/achievements/achievement.rb +++ b/app/models/achievements/achievement.rb @@ -4,9 +4,6 @@ module Achievements class Achievement < ApplicationRecord include Avatarable include StripAttribute - include IgnorableColumns - - ignore_column :revokable, remove_with: '15.11', remove_after: '2023-04-22' belongs_to :namespace, inverse_of: :achievements, optional: false diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 2d1dec1977d..133466e93e3 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -91,13 +91,6 @@ class ActiveSession active_user_session.dump ) - # Deprecated legacy format - temporary to support mixed deployments - pipeline.setex( - key_name_v1(user.id, session_private_id), - expiry, - Marshal.dump(active_user_session) - ) - pipeline.sadd?( lookup_key_name(user.id), session_private_id @@ -107,6 +100,19 @@ class ActiveSession end end + # set marketing cookie when user has active session + def self.set_active_user_cookie(auth) + auth.cookies[:about_gitlab_active_user] = + { + value: true, + domain: Gitlab.config.gitlab.host + } + end + + def self.unset_active_user_cookie(auth) + auth.cookies.delete :about_gitlab_active_user + end + def self.list(user) Gitlab::Redis::Sessions.with do |redis| cleaned_up_lookup_entries(redis, user).map do |raw_session| diff --git a/app/models/appearance.rb b/app/models/appearance.rb index b926c6abedc..8d6048d45d5 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Appearance < ApplicationRecord +class Appearance < MainClusterwide::ApplicationRecord include CacheableAttributes include CacheMarkdownField include WithUploads diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 71434931d8c..52abacfe3e8 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -26,6 +26,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord # rather than the persisted value. ADDRESSABLE_URL_VALIDATION_OPTIONS = { deny_all_requests_except_allowed: ->(settings) { settings.deny_all_requests_except_allowed } }.freeze + HUMANIZED_ATTRIBUTES = { + archive_builds_in_seconds: 'Archive job value' + }.freeze + enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true enum email_confirmation_setting: { off: 0, soft: 1, hard: 2 }, _prefix: true @@ -336,7 +340,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :archive_builds_in_seconds, allow_nil: true, - numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds } + numericality: { + only_integer: true, + greater_than_or_equal_to: 1.day.seconds, + message: N_('must be at least 1 day') + } validates :local_markdown_version, allow_nil: true, @@ -431,6 +439,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :silent_mode_enabled, + inclusion: { in: [true, false], message: N_('must be a boolean value') } + Gitlab::SSHPublicKey.supported_types.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -654,6 +665,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :inactive_projects_send_warning_email_after_months, numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months } + validates :database_apdex_settings, json_schema: { filename: 'application_setting_database_apdex_settings' }, allow_nil: true + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -696,6 +709,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord attr_encrypted :telesign_customer_xid, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :telesign_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :product_analytics_clickhouse_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :product_analytics_configurator_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :openai_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) validates :disable_feed_token, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -873,6 +888,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord private + def self.human_attribute_name(attribute, *options) + HUMANIZED_ATTRIBUTES[attribute.to_sym] || super + end + def parsed_grafana_url @parsed_grafana_url ||= Gitlab::Utils.parse_url(grafana_url) end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index b8d6434d9c9..010c88179df 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -97,7 +97,7 @@ module ApplicationSettingImplementation group_import_limit: 6, help_page_hide_commercial_content: false, help_page_text: nil, - help_page_documentation_base_url: nil, + help_page_documentation_base_url: 'https://docs.gitlab.com', hide_third_party_offers: false, housekeeping_enabled: true, housekeeping_full_repack_period: 50, diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index dbc5c7a584e..31bee8db1b4 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -7,6 +7,9 @@ class AwardEmoji < ApplicationRecord include Participable include GhostUser include Importable + include IgnorableColumns + + ignore_column :awardable_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb deleted file mode 100644 index 0b652984630..00000000000 --- a/app/models/awareness_session.rb +++ /dev/null @@ -1,245 +0,0 @@ -# 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| - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - 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 - end - - nil - end - - def leave(user) - user_key = user_sessions_key(user.id) - - with_redis do |redis| - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| - pipeline.srem?(user_key, id_i) - pipeline.zrem(users_key, user.id) - end - 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 = - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| - pipeline.scard(user_key) - pipeline.zcard(users_key) - end - end - - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - 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 - 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/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb index 9d1376de0cb..aac7271242e 100644 --- a/app/models/blob_viewer/composer_json.rb +++ b/app/models/blob_viewer/composer_json.rb @@ -15,7 +15,7 @@ module BlobViewer end def package_name - @package_name ||= package_name_from_json('name') + @package_name ||= fetch_from_json('name') end def package_url diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb index a3801025cd7..71bd90e7459 100644 --- a/app/models/blob_viewer/dependency_manager.rb +++ b/app/models/blob_viewer/dependency_manager.rb @@ -38,8 +38,10 @@ module BlobViewer end end - def package_name_from_json(key) - json_data[key] + def fetch_from_json(...) + json_data.dig(...) + rescue TypeError + nil end def package_name_from_method_call(name) diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb index 1d10cc82a85..5350b6b0626 100644 --- a/app/models/blob_viewer/package_json.rb +++ b/app/models/blob_viewer/package_json.rb @@ -11,7 +11,7 @@ module BlobViewer end def yarn? - json_data['engines'].present? && json_data['engines']['yarn'].present? + fetch_from_json('engines', 'yarn').present? end def manager_url @@ -19,7 +19,7 @@ module BlobViewer end def package_name - @package_name ||= package_name_from_json('name') + @package_name ||= fetch_from_json('name') end def package_type @@ -33,11 +33,11 @@ module BlobViewer private def private? - !!json_data['private'] + !!fetch_from_json('private') end def homepage - url = json_data['homepage'] + url = fetch_from_json('homepage') url if Gitlab::UrlSanitizer.valid?(url) end diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb index d3f6ae269da..d606f72376d 100644 --- a/app/models/blob_viewer/podspec_json.rb +++ b/app/models/blob_viewer/podspec_json.rb @@ -5,7 +5,7 @@ module BlobViewer self.file_types = %i(podspec_json) def package_name - @package_name ||= package_name_from_json('name') + @package_name ||= fetch_from_json('name') end end end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index c5a234ffa69..14aecbc9420 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -class BroadcastMessage < ApplicationRecord +class BroadcastMessage < MainClusterwide::ApplicationRecord include CacheMarkdownField include Sortable + include IgnorableColumns ALLOWED_TARGET_ACCESS_LEVELS = [ Gitlab::Access::GUEST, @@ -12,6 +13,8 @@ class BroadcastMessage < ApplicationRecord Gitlab::Access::OWNER ].freeze + ignore_column :namespace_id, remove_with: '16.0', remove_after: '2022-06-22' + cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true validates :message, presence: true diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index ae2d3758110..b3540917197 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -44,24 +44,19 @@ class BulkImports::Entity < ApplicationRecord validates :source_full_path, presence: true, format: { with: Gitlab::Regex.bulk_import_source_full_path_regex, - message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message } + message: Gitlab::Regex.bulk_import_source_full_path_regex_message } validates :destination_name, presence: true, - format: { with: Gitlab::Regex.group_path_regex, - message: Gitlab::Regex.group_path_regex_message } + if: -> { group || project } validates :destination_namespace, exclusion: [nil], - format: { with: Gitlab::Regex.bulk_import_destination_namespace_path_regex, - message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message }, if: :group validates :destination_namespace, presence: true, - format: { with: Gitlab::Regex.bulk_import_destination_namespace_path_regex, - message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message }, - if: :project + if: :project? validate :validate_parent_is_a_group, if: :parent validate :validate_imported_entity_type diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 697f06fbffd..b77e0f1d5c1 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -55,8 +55,6 @@ module Ci end def retryable? - return false unless Feature.enabled?(:ci_recreate_downstream_pipeline, project) - return false if failed? && (pipeline_loop_detected? || reached_max_descendant_pipelines_depth?) super diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 627604ec26c..d389c59f16b 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -55,9 +55,9 @@ module Ci has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', foreign_key: :job_id, inverse_of: :job end - has_one :runner_machine_build, class_name: 'Ci::RunnerMachineBuild', foreign_key: :build_id, inverse_of: :build, + has_one :runner_manager_build, class_name: 'Ci::RunnerManagerBuild', foreign_key: :build_id, inverse_of: :build, autosave: true - has_one :runner_machine, through: :runner_machine_build, class_name: 'Ci::RunnerMachine' + has_one :runner_manager, foreign_key: :runner_machine_id, through: :runner_manager_build, class_name: 'Ci::RunnerManager' has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, foreign_key: :build_id, inverse_of: :build has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', foreign_key: :build_id, inverse_of: :build @@ -597,8 +597,14 @@ module Ci .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self)) .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true) .append(key: 'CI_JOB_STARTED_AT', value: started_at&.iso8601) - .append(key: 'CI_BUILD_ID', value: id.to_s) - .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true) + + if Feature.disabled?(:ci_remove_legacy_predefined_variables, project) + variables + .append(key: 'CI_BUILD_ID', value: id.to_s) + .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true) + end + + variables .append(key: 'CI_REGISTRY_USER', value: ::Gitlab::Auth::CI_JOB_USER) .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true) .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false) diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 4b2be446fe3..b98fdba44ec 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -11,9 +11,11 @@ module Ci include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize include IgnorableColumns + include SafelyChangeColumnDefault self.table_name = 'p_ci_builds_metadata' self.primary_key = 'id' + columns_changing_default :partition_id partitionable scope: :build diff --git a/app/models/ci/build_trace.rb b/app/models/ci/build_trace.rb index f70e1ed69ea..b9a74102641 100644 --- a/app/models/ci/build_trace.rb +++ b/app/models/ci/build_trace.rb @@ -12,7 +12,11 @@ module Ci if stream.valid? stream.limit - @trace = Gitlab::Ci::Ansi2json.convert(stream.stream, state) + @trace = Gitlab::Ci::Ansi2json.convert( + stream.stream, + state, + verify_state: Feature.enabled?(:sign_and_verify_ansi2json_state, build.project) + ) end end diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index 00cf1531483..4c76089617f 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -42,9 +42,7 @@ module Ci end def track_archival!(trace_artifact_id, checksum) - update!(trace_artifact_id: trace_artifact_id, - checksum: checksum, - archived_at: Time.current) + update!(trace_artifact_id: trace_artifact_id, checksum: checksum, archived_at: Time.current) end def archival_attempts_message diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb index 92464cb645f..b9e777f27a0 100644 --- a/app/models/ci/catalog/listing.rb +++ b/app/models/ci/catalog/listing.rb @@ -27,7 +27,7 @@ module Ci def projects_in_namespace_visible_to_user Project .in_namespace(namespace.self_and_descendant_ids) - .public_or_visible_to_user(current_user) + .public_or_visible_to_user(current_user, ::Gitlab::Access::DEVELOPER) end end end diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index 1b3dec5f54d..bb4584aacae 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -11,6 +11,18 @@ module Ci self.table_name = 'catalog_resources' belongs_to :project + + scope :for_projects, ->(project_ids) { where(project_id: project_ids) } + + delegate :avatar_path, :description, :name, to: :project + + def versions + project.releases.order_released_desc + end + + def latest_version + versions.first + end end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 5a7860174ff..10f0dd865ff 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -14,6 +14,9 @@ module Ci include EachBatch include Gitlab::Utils::StrongMemoize + # NOTE: Temporarily ignore. This will will be used in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106740 + ignore_column :file_final_path, remove_with: '16.1', remove_after: '2023-05-23' + enum accessibility: { public: 0, private: 1 }, _suffix: true NON_ERASABLE_FILE_TYPES = %w[trace].freeze diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb index 5ea51fbe0a7..ff7e681217a 100644 --- a/app/models/ci/namespace_mirror.rb +++ b/app/models/ci/namespace_mirror.rb @@ -41,8 +41,7 @@ module Ci namespace = event.namespace traversal_ids = namespace.self_and_ancestor_ids(hierarchy_order: :desc) - upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids }, - unique_by: :namespace_id) + upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids }, unique_by: :namespace_id) end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 2b0c79aab87..d06051c7a15 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -52,26 +52,39 @@ module Ci belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines has_internal_id :iid, scope: :project, presence: false, - track_if: -> { !importing? }, - ensure_if: -> { !importing? }, - init: ->(pipeline, scope) do - if pipeline - pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count - elsif scope - ::Ci::Pipeline.where(**scope).maximum(:iid) - end - end + track_if: -> { !importing? }, + ensure_if: -> { !importing? }, + init: ->(pipeline, scope) do + if pipeline + pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count + elsif scope + ::Ci::Pipeline.where(**scope).maximum(:iid) + end + end has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline + + # + # In https://gitlab.com/groups/gitlab-org/-/epics/9991, we aim to convert all CommitStatus related models to + # Ci:Job models. With that epic, we aim to replace `statuses` with `jobs`. + # + # DEPRECATED: has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :statuses_order_id_desc, -> { order_id_desc }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :generic_commit_statuses, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'GenericCommitStatus' + # + # NEW: + has_many :all_jobs, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :current_jobs, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :all_processable_jobs, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline + has_many :current_processable_jobs, -> { latest }, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline + has_many :job_artifacts, through: :builds has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent @@ -386,6 +399,7 @@ module Ci 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) } + scope :preload_pipeline_metadata, -> { preload(:pipeline_metadata) } scope :outside_pipeline_family, ->(pipeline) do where.not(id: pipeline.same_family_pipeline_ids) @@ -407,11 +421,15 @@ module Ci # In general, please use `Ci::PipelinesForMergeRequestFinder` instead, # for checking permission of the actor. scope :triggered_by_merge_request, -> (merge_request) do - where(source: :merge_request_event, - merge_request: merge_request, - project: [merge_request.source_project, merge_request.target_project]) + where( + source: :merge_request_event, + merge_request: merge_request, + project: [merge_request.source_project, merge_request.target_project] + ) end + scope :order_id_desc, -> { order(id: :desc) } + # Returns the pipelines in descending order (= newest first), optionally # limited to a number of references. # @@ -682,7 +700,7 @@ module Ci # rubocop: enable CodeReuse/ServiceClass def lazy_ref_commit - BatchLoader.for(ref).batch do |refs, loader| + BatchLoader.for(ref).batch(key: project.id) do |refs, loader| next unless project.repository_exists? project.repository.list_commits_by_ref_name(refs).then do |commits| @@ -843,8 +861,7 @@ module Ci when 'manual' then block when 'scheduled' then delay else - raise Ci::HasStatus::UnknownStatusError, - "Unknown status `#{new_status}`" + raise Ci::HasStatus::UnknownStatusError, "Unknown status `#{new_status}`" end end end @@ -1319,7 +1336,7 @@ module Ci def cluster_agent_authorizations strong_memoize(:cluster_agent_authorizations) do - ::Clusters::AgentAuthorizationsFinder.new(project).execute + ::Clusters::Agents::Authorizations::CiAccess::Finder.new(project).execute end end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 83e6fa2f862..49d27053745 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -83,6 +83,8 @@ module Ci Settings.cron_jobs['pipeline_schedule_worker']['cron'] end + # Using destroy instead of before_destroy as we want nullify_dependent_associations_in_batches + # to run first and not in a transaction block. This prevents timeouts for schedules with numerous pipelines def destroy nullify_dependent_associations_in_batches diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 37c82c125aa..4c421f066f9 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module Ci + # This class is a collection of common features between Ci::Build and Ci::Bridge. + # In https://gitlab.com/groups/gitlab-org/-/epics/9991, we aim to clarify class naming conventions. class Processable < ::CommitStatus include Gitlab::Utils::StrongMemoize include FromUnion diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb index 15a161d5b7c..23cd5d92730 100644 --- a/app/models/ci/project_mirror.rb +++ b/app/models/ci/project_mirror.rb @@ -13,8 +13,7 @@ module Ci class << self def sync!(event) - upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id }, - unique_by: :project_id) + upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id }, unique_by: :project_id) end end end diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb index af5fdabff6e..199e1cd07e7 100644 --- a/app/models/ci/ref.rb +++ b/app/models/ci/ref.rb @@ -43,8 +43,7 @@ module Ci class << self def ensure_for(pipeline) - safe_find_or_create_by(project_id: pipeline.project_id, - ref_path: pipeline.source_ref_path) + safe_find_or_create_by(project_id: pipeline.project_id, ref_path: pipeline.source_ref_path) end def failing_state?(status_name) diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb index a220aa7bb18..48f321a236d 100644 --- a/app/models/ci/resource_group.rb +++ b/app/models/ci/resource_group.rb @@ -58,6 +58,10 @@ module Ci end end + def current_processable + Ci::Processable.find_by('(id, partition_id) IN (?)', resources.select('build_id, partition_id')) + end + private # In order to avoid deadlock, we do NOT specify the job execution order in the same pipeline. diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 6fefe95769b..80a3d8df632 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -18,9 +18,9 @@ module Ci extend ::Gitlab::Utils::Override add_authentication_token_field :token, - encrypted: :optional, - expires_at: :compute_token_expiration, - format_with_prefix: :prefix_for_new_and_legacy_runner + encrypted: :optional, + expires_at: :compute_token_expiration, + format_with_prefix: :prefix_for_new_and_legacy_runner enum access_level: { not_protected: 0, @@ -70,7 +70,7 @@ module Ci TAG_LIST_MAX_LENGTH = 50 - has_many :runner_machines, inverse_of: :runner + has_many :runner_managers, inverse_of: :runner has_many :builds has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects, disable_joins: true @@ -134,7 +134,7 @@ module Ci belonging_to_group(group_self_and_ancestors_ids) } - scope :belonging_to_parent_group_of_project, -> (project_id) { + scope :belonging_to_parent_groups_of_project, -> (project_id) { raise ArgumentError, "only 1 project_id allowed for performance reasons" unless project_id.is_a?(Integer) project_groups = ::Group.joins(:projects).where(projects: { id: project_id }) @@ -148,7 +148,7 @@ module Ci from_union( [ belonging_to_project(project_id), - project.group_runners_enabled? ? belonging_to_parent_group_of_project(project_id) : nil, + project.group_runners_enabled? ? belonging_to_parent_groups_of_project(project_id) : nil, project.shared_runners ].compact, remove_duplicates: false @@ -215,16 +215,14 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout, - error_message: 'Maximum job timeout has a value which could not be accepted' + error_message: 'Maximum job timeout has a value which could not be accepted' validates :maximum_timeout, allow_nil: true, - numericality: { greater_than_or_equal_to: 600, - message: 'needs to be at least 10 minutes' } + numericality: { greater_than_or_equal_to: 600, message: 'needs to be at least 10 minutes' } validates :public_projects_minutes_cost_factor, :private_projects_minutes_cost_factor, allow_nil: false, - numericality: { greater_than_or_equal_to: 0.0, - message: 'needs to be non-negative' } + numericality: { greater_than_or_equal_to: 0.0, message: 'needs to be non-negative' } validates :config, json_schema: { filename: 'ci_runner_config' } @@ -498,14 +496,14 @@ module Ci end end - def ensure_machine(system_xid, &blk) - RunnerMachine.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s, &blk) # rubocop: disable Performance/ActiveRecordSubtransactionMethods + def ensure_manager(system_xid, &blk) + RunnerManager.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s, &blk) # rubocop: disable Performance/ActiveRecordSubtransactionMethods end def registration_available? authenticated_user_registration_type? && created_at > REGISTRATION_AVAILABILITY_TIME.ago && - !runner_machines.any? + !runner_managers.any? end private @@ -595,7 +593,7 @@ module Ci end def exactly_one_group - unless runner_namespaces.one? + unless runner_namespaces.size == 1 errors.add(:runner, 'needs to be assigned to exactly one group') end end diff --git a/app/models/ci/runner_machine_build.rb b/app/models/ci/runner_machine_build.rb deleted file mode 100644 index d4f2c403337..00000000000 --- a/app/models/ci/runner_machine_build.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Ci - class RunnerMachineBuild < Ci::ApplicationRecord - include Ci::Partitionable - - self.table_name = :p_ci_runner_machine_builds - self.primary_key = :build_id - - partitionable scope: :build, partitioned: true - - belongs_to :build, inverse_of: :runner_machine_build, class_name: 'Ci::Build' - belongs_to :runner_machine, inverse_of: :runner_machine_builds, class_name: 'Ci::RunnerMachine' - - validates :build, presence: true - validates :runner_machine, presence: true - - scope :for_build, ->(build_id) { where(build_id: build_id) } - - def self.pluck_build_id_and_runner_machine_id - select(:build_id, :runner_machine_id) - .pluck(:build_id, :runner_machine_id) - .to_h - end - end -end diff --git a/app/models/ci/runner_machine.rb b/app/models/ci/runner_manager.rb index 8cf395aadb4..e36024d9f5b 100644 --- a/app/models/ci/runner_machine.rb +++ b/app/models/ci/runner_manager.rb @@ -1,20 +1,23 @@ # frozen_string_literal: true module Ci - class RunnerMachine < Ci::ApplicationRecord + class RunnerManager < Ci::ApplicationRecord include FromUnion include RedisCacheable include Ci::HasRunnerExecutor + # For legacy reasons, the table name is ci_runner_machines in the database + self.table_name = 'ci_runner_machines' + # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner Machine DB entry can be updated UPDATE_CONTACT_COLUMN_EVERY = (40.minutes)..(55.minutes) belongs_to :runner - has_many :runner_machine_builds, inverse_of: :runner_machine, class_name: 'Ci::RunnerMachineBuild' - has_many :builds, through: :runner_machine_builds, class_name: 'Ci::Build' - belongs_to :runner_version, inverse_of: :runner_machines, primary_key: :version, foreign_key: :version, - class_name: 'Ci::RunnerVersion' + has_many :runner_manager_builds, inverse_of: :runner_manager, class_name: 'Ci::RunnerManagerBuild' + has_many :builds, through: :runner_manager_builds, class_name: 'Ci::Build' + belongs_to :runner_version, inverse_of: :runner_managers, primary_key: :version, foreign_key: :version, + class_name: 'Ci::RunnerVersion' validates :runner, presence: true validates :system_xid, presence: true, length: { maximum: 64 } @@ -27,7 +30,7 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type - # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner machine + # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner manager # will be considered stale STALE_TIMEOUT = 7.days diff --git a/app/models/ci/runner_manager_build.rb b/app/models/ci/runner_manager_build.rb new file mode 100644 index 00000000000..322c5ae3a68 --- /dev/null +++ b/app/models/ci/runner_manager_build.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + class RunnerManagerBuild < Ci::ApplicationRecord + include Ci::Partitionable + + self.table_name = :p_ci_runner_machine_builds + self.primary_key = :build_id + + partitionable scope: :build, partitioned: true + + alias_attribute :runner_manager_id, :runner_machine_id + + belongs_to :build, inverse_of: :runner_manager_build, class_name: 'Ci::Build' + belongs_to :runner_manager, foreign_key: :runner_machine_id, inverse_of: :runner_manager_builds, + class_name: 'Ci::RunnerManager' + + validates :build, presence: true + validates :runner_manager, presence: true + + scope :for_build, ->(build_id) { where(build_id: build_id) } + + def self.pluck_build_id_and_runner_manager_id + select(:build_id, :runner_manager_id) + .pluck(:build_id, :runner_manager_id) + .to_h + end + end +end diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb index 41e7a2b8e8a..03b50f13989 100644 --- a/app/models/ci/runner_version.rb +++ b/app/models/ci/runner_version.rb @@ -19,7 +19,7 @@ module Ci recommended: 'Upgrade is available and recommended for the runner.' }.freeze - has_many :runner_machines, inverse_of: :runner_version, foreign_key: :version, class_name: 'Ci::RunnerMachine' + has_many :runner_managers, inverse_of: :runner_version, foreign_key: :version, class_name: 'Ci::RunnerManager' # 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 diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb index 43214b0c336..e6f80658f5d 100644 --- a/app/models/ci/running_build.rb +++ b/app/models/ci/running_build.rb @@ -24,10 +24,12 @@ module Ci raise ArgumentError, 'build has not been picked by a shared runner' end - entry = self.new(build: build, - project: build.project, - runner: build.runner, - runner_type: build.runner.runner_type) + entry = self.new( + build: build, + project: build.project, + runner: build.runner, + runner_type: build.runner.runner_type + ) entry.validate! diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 02093bdf153..d61760bd0fc 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -112,8 +112,7 @@ module Ci when 'scheduled' then delay when 'skipped', nil then skip else - raise Ci::HasStatus::UnknownStatusError, - "Unknown status `#{new_status}`" + raise Ci::HasStatus::UnknownStatusError, "Unknown status `#{new_status}`" end end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 1b2a7dc3fe4..0cfe2d50283 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -26,8 +26,7 @@ module Ci mode: :per_attribute_iv, algorithm: 'aes-256-gcm', key: Settings.attr_encrypted_db_key_base_32, - encode: false, - encode_vi: false + encode: false before_validation :set_default_values diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 3478bb69707..374deabfe33 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -12,11 +12,17 @@ module Clusters has_many :agent_tokens, -> { order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent - has_many :group_authorizations, class_name: 'Clusters::Agents::GroupAuthorization' - has_many :authorized_groups, class_name: '::Group', through: :group_authorizations, source: :group + has_many :ci_access_group_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::GroupAuthorization' + has_many :ci_access_authorized_groups, class_name: '::Group', through: :ci_access_group_authorizations, source: :group - has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization' - has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project + has_many :ci_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization' + has_many :ci_access_authorized_projects, class_name: '::Project', through: :ci_access_project_authorizations, source: :project + + has_many :user_access_group_authorizations, class_name: 'Clusters::Agents::Authorizations::UserAccess::GroupAuthorization' + has_many :user_access_authorized_groups, class_name: '::Group', through: :user_access_group_authorizations, source: :group + + has_many :user_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization' + has_many :user_access_authorized_projects, class_name: '::Project', through: :user_access_project_authorizations, source: :project has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent diff --git a/app/models/clusters/agents/authorizations/ci_access/group_authorization.rb b/app/models/clusters/agents/authorizations/ci_access/group_authorization.rb new file mode 100644 index 00000000000..4261fd6570f --- /dev/null +++ b/app/models/clusters/agents/authorizations/ci_access/group_authorization.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + class GroupAuthorization < ApplicationRecord + include ConfigScopes + + self.table_name = 'agent_group_authorizations' + + belongs_to :agent, class_name: 'Clusters::Agent', optional: false + belongs_to :group, class_name: '::Group', optional: false + + validates :config, json_schema: { filename: 'clusters_agents_authorizations_ci_access_config' } + + def config_project + agent.project + end + end + end + end + end +end diff --git a/app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb b/app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb new file mode 100644 index 00000000000..b996ae3f92b --- /dev/null +++ b/app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + class ImplicitAuthorization + attr_reader :agent + + delegate :id, to: :agent, prefix: true + + def initialize(agent:) + @agent = agent + end + + def config_project + agent.project + end + + def config + {} + end + end + end + end + end +end diff --git a/app/models/clusters/agents/authorizations/ci_access/project_authorization.rb b/app/models/clusters/agents/authorizations/ci_access/project_authorization.rb new file mode 100644 index 00000000000..7742d109cdb --- /dev/null +++ b/app/models/clusters/agents/authorizations/ci_access/project_authorization.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + class ProjectAuthorization < ApplicationRecord + include ConfigScopes + + self.table_name = 'agent_project_authorizations' + + belongs_to :agent, class_name: 'Clusters::Agent', optional: false + belongs_to :project, class_name: '::Project', optional: false + + validates :config, json_schema: { filename: 'clusters_agents_authorizations_ci_access_config' } + + def config_project + agent.project + end + end + end + end + end +end diff --git a/app/models/clusters/agents/authorizations/user_access/group_authorization.rb b/app/models/clusters/agents/authorizations/user_access/group_authorization.rb new file mode 100644 index 00000000000..e46a52e73a6 --- /dev/null +++ b/app/models/clusters/agents/authorizations/user_access/group_authorization.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module UserAccess + class GroupAuthorization < ApplicationRecord + self.table_name = 'agent_user_access_group_authorizations' + + belongs_to :agent, class_name: 'Clusters::Agent', optional: false + belongs_to :group, class_name: '::Group', optional: false + + validates :config, json_schema: { filename: 'clusters_agents_authorizations_user_access_config' } + + def config_project + agent.project + end + + class << self + def upsert_configs(configs) + upsert_all(configs, unique_by: [:agent_id, :group_id]) + end + + def delete_unlisted(group_ids) + where.not(group_id: group_ids).delete_all + end + end + end + end + end + end +end diff --git a/app/models/clusters/agents/authorizations/user_access/project_authorization.rb b/app/models/clusters/agents/authorizations/user_access/project_authorization.rb new file mode 100644 index 00000000000..2b0cbd3032a --- /dev/null +++ b/app/models/clusters/agents/authorizations/user_access/project_authorization.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module UserAccess + class ProjectAuthorization < ApplicationRecord + self.table_name = 'agent_user_access_project_authorizations' + + belongs_to :agent, class_name: 'Clusters::Agent', optional: false + belongs_to :project, class_name: '::Project', optional: false + + validates :config, json_schema: { filename: 'clusters_agents_authorizations_user_access_config' } + + def config_project + agent.project + end + + class << self + def upsert_configs(configs) + upsert_all(configs, unique_by: [:agent_id, :project_id]) + end + + def delete_unlisted(project_ids) + where.not(project_id: project_ids).delete_all + end + end + end + end + end + end +end diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb deleted file mode 100644 index 58ba874ab53..00000000000 --- a/app/models/clusters/agents/group_authorization.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Agents - class GroupAuthorization < ApplicationRecord - include ::Clusters::Agents::AuthorizationConfigScopes - - self.table_name = 'agent_group_authorizations' - - belongs_to :agent, class_name: 'Clusters::Agent', optional: false - belongs_to :group, class_name: '::Group', optional: false - - validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' } - - def config_project - agent.project - end - end - end -end diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb deleted file mode 100644 index a365ccdc568..00000000000 --- a/app/models/clusters/agents/implicit_authorization.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Agents - class ImplicitAuthorization - attr_reader :agent - - delegate :id, to: :agent, prefix: true - - def initialize(agent:) - @agent = agent - end - - def config_project - agent.project - end - - def config - {} - end - end - end -end diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb deleted file mode 100644 index b9b44741936..00000000000 --- a/app/models/clusters/agents/project_authorization.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Agents - class ProjectAuthorization < ApplicationRecord - include ::Clusters::Agents::AuthorizationConfigScopes - - self.table_name = 'agent_project_authorizations' - - belongs_to :agent, class_name: 'Clusters::Agent', optional: false - belongs_to :project, class_name: '::Project', optional: false - - validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' } - - def config_project - agent.project - end - end - end -end diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb deleted file mode 100644 index 9fac852ed5b..00000000000 --- a/app/models/clusters/applications/helm.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -require 'openssl' - -module Clusters - module Applications - # DEPRECATED: This model represents the Helm 2 Tiller server. - # It is being kept around to enable the cleanup of the unused Tiller server. - class Helm < ApplicationRecord - self.table_name = 'clusters_applications_helm' - - attr_encrypted :ca_key, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, - algorithm: 'aes-256-cbc' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Gitlab::Utils::StrongMemoize - - attribute :version, default: Gitlab::Kubernetes::Helm::V2::BaseCommand::HELM_VERSION - - before_create :create_keys_and_certs - - def issue_client_cert - ca_cert_obj.issue - end - - def set_initial_status - # The legacy Tiller server is not installable, which is the initial status of every app - end - - # DEPRECATED: This command is only for development and testing purposes, to simulate - # a Helm 2 cluster with an existing Tiller server. - def install_command - Gitlab::Kubernetes::Helm::V2::InitCommand.new( - name: name, - files: files, - rbac: cluster.platform_kubernetes_rbac? - ) - end - - def uninstall_command - Gitlab::Kubernetes::Helm::V2::ResetCommand.new( - name: name, - files: files, - rbac: cluster.platform_kubernetes_rbac? - ) - end - - def has_ssl? - ca_key.present? && ca_cert.present? - end - - private - - def files - { - 'ca.pem': ca_cert, - 'cert.pem': tiller_cert.cert_string, - 'key.pem': tiller_cert.key_string - } - end - - def create_keys_and_certs - ca_cert = Gitlab::Kubernetes::Helm::V2::Certificate.generate_root - self.ca_key = ca_cert.key_string - self.ca_cert = ca_cert.cert_string - end - - def tiller_cert - @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::V2::Certificate::INFINITE_EXPIRY) - end - - def ca_cert_obj - return unless has_ssl? - - Gitlab::Kubernetes::Helm::V2::Certificate - .from_strings(ca_key, ca_cert) - end - end - end -end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb deleted file mode 100644 index 034b178d67d..00000000000 --- a/app/models/clusters/applications/ingress.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - # DEPRECATED for removal in %14.0 - # See https://gitlab.com/groups/gitlab-org/-/epics/4280 - class Ingress < ApplicationRecord - VERSION = '1.40.2' - INGRESS_CONTAINER_NAME = 'nginx-ingress-controller' - - self.table_name = 'clusters_applications_ingress' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - include AfterCommitQueue - include UsageStatistics - - attribute :version, default: VERSION - - enum ingress_type: { - nginx: 1 - }, _default: :nginx - - FETCH_IP_ADDRESS_DELAY = 30.seconds - - state_machine :status do - after_transition any => [:installed] do |application| - application.run_after_commit do - ClusterWaitForIngressIpAddressWorker.perform_in( - FETCH_IP_ADDRESS_DELAY, application.name, application.id) - end - end - end - - def chart - "#{name}/nginx-ingress" - end - - def repository - 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' - end - - def values - content_values.to_yaml - end - - def allowed_to_uninstall? - external_ip_or_hostname? && !application_jupyter_installed? - end - - def install_command - helm_command_module::InstallCommand.new( - name: name, - repository: repository, - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - files: files - ) - end - - def external_ip_or_hostname? - external_ip.present? || external_hostname.present? - end - - def schedule_status_update - return unless installed? - return if external_ip - return if external_hostname - - ClusterWaitForIngressIpAddressWorker.perform_async(name, id) - end - - def ingress_service - cluster.kubeclient.get_service("ingress-#{INGRESS_CONTAINER_NAME}", Gitlab::Kubernetes::Helm::NAMESPACE) - end - - private - - def content_values - YAML.load_file(chart_values_file) - end - - def application_jupyter_installed? - cluster.application_jupyter&.installed? - end - end - end -end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb deleted file mode 100644 index 9c0e90d59ed..00000000000 --- a/app/models/clusters/applications/jupyter.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -require 'securerandom' - -module Clusters - module Applications - # DEPRECATED for removal in %14.0 - # See https://gitlab.com/groups/gitlab-org/-/epics/4280 - class Jupyter < ApplicationRecord - VERSION = '0.9.0' - - self.table_name = 'clusters_applications_jupyter' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - - belongs_to :oauth_application, class_name: 'Doorkeeper::Application' - - attribute :version, default: VERSION - - def set_initial_status - return unless not_installable? - return unless cluster&.application_ingress_available? - - ingress = cluster.application_ingress - self.status = status_states[:installable] if ingress.external_ip_or_hostname? - end - - def chart - "#{name}/jupyterhub" - end - - def repository - 'https://jupyterhub.github.io/helm-chart/' - end - - def values - content_values.to_yaml - end - - def install_command - helm_command_module::InstallCommand.new( - name: name, - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - files: files, - repository: repository - ) - end - - def callback_url - "http://#{hostname}/hub/oauth_callback" - end - - def oauth_scopes - 'api read_repository write_repository' - end - - private - - def specification - { - "ingress" => { - "hosts" => [hostname], - "tls" => [{ - "hosts" => [hostname], - "secretName" => "jupyter-cert" - }] - }, - "hub" => { - "extraEnv" => { - "GITLAB_HOST" => gitlab_url - }, - "cookieSecret" => cookie_secret - }, - "proxy" => { - "secretToken" => secret_token - }, - "auth" => { - "state" => { - "cryptoKey" => crypto_key - }, - "gitlab" => { - "clientId" => oauth_application.uid, - "clientSecret" => oauth_application.secret, - "callbackUrl" => callback_url, - "gitlabProjectIdWhitelist" => cluster.projects.ids, - "gitlabGroupWhitelist" => cluster.groups.map(&:to_param) - } - }, - "singleuser" => { - "extraEnv" => { - "GITLAB_CLUSTER_ID" => cluster.id.to_s, - "GITLAB_HOST" => gitlab_host - } - } - } - end - - def crypto_key - @crypto_key ||= SecureRandom.hex(32) - end - - def gitlab_url - Gitlab.config.gitlab.url - end - - def gitlab_host - Gitlab.config.gitlab.host - end - - def content_values - YAML.load_file(chart_values_file).deep_merge!(specification) - end - - def secret_token - @secret_token ||= SecureRandom.hex(32) - end - - def cookie_secret - @cookie_secret ||= SecureRandom.hex(32) - end - end - end -end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb deleted file mode 100644 index c8c043f3312..00000000000 --- a/app/models/clusters/applications/knative.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - # DEPRECATED for removal in %14.0 - # See https://gitlab.com/groups/gitlab-org/-/epics/4280 - class Knative < ApplicationRecord - VERSION = '0.10.0' - REPOSITORY = 'https://charts.gitlab.io' - METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/raw/v0.9.0/vendor/istio-metrics.yml' - FETCH_IP_ADDRESS_DELAY = 30.seconds - API_GROUPS_PATH = 'config/knative/api_groups.yml' - - self.table_name = 'clusters_applications_knative' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - include AfterCommitQueue - - alias_method :original_set_initial_status, :set_initial_status - def set_initial_status - return unless cluster&.platform_kubernetes_rbac? - - original_set_initial_status - end - - state_machine :status do - after_transition any => [:installed] do |application| - application.run_after_commit do - ClusterWaitForIngressIpAddressWorker.perform_in( - FETCH_IP_ADDRESS_DELAY, application.name, application.id) - end - end - - after_transition any => [:installed, :updated] do |application| - application.run_after_commit do - ClusterConfigureIstioWorker.perform_async(application.cluster_id) - end - end - end - - attribute :version, default: VERSION - - validates :hostname, presence: true, hostname: true - - scope :for_cluster, -> (cluster) { where(cluster: cluster) } - - def chart - 'knative/knative' - end - - def values - { "domain" => hostname }.to_yaml - end - - def available_domains - PagesDomain.instance_serverless - end - - def find_available_domain(pages_domain_id) - available_domains.find_by(id: pages_domain_id) - end - - def allowed_to_uninstall? - !pre_installed? - end - - def install_command - helm_command_module::InstallCommand.new( - name: name, - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - files: files, - repository: REPOSITORY, - postinstall: install_knative_metrics - ) - end - - def schedule_status_update - return unless installed? - return if external_ip - return if external_hostname - - ClusterWaitForIngressIpAddressWorker.perform_async(name, id) - end - - def ingress_service - cluster.kubeclient.get_service('istio-ingressgateway', Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE) - end - - def uninstall_command - helm_command_module::DeleteCommand.new( - name: name, - rbac: cluster.platform_kubernetes_rbac?, - files: files, - predelete: delete_knative_services_and_metrics, - postdelete: delete_knative_istio_leftovers - ) - end - - private - - def delete_knative_services_and_metrics - delete_knative_services + delete_knative_istio_metrics - end - - def delete_knative_services - cluster.kubernetes_namespaces.map do |kubernetes_namespace| - Gitlab::Kubernetes::KubectlCmd.delete("ksvc", "--all", "-n", kubernetes_namespace.namespace) - end - end - - def delete_knative_istio_leftovers - delete_knative_namespaces + delete_knative_and_istio_crds - end - - def delete_knative_namespaces - [ - Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "ns", "knative-serving"), - Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "ns", "knative-build") - ] - end - - def delete_knative_and_istio_crds - api_groups.map do |group| - Gitlab::Kubernetes::KubectlCmd.delete_crds_from_group(group) - end - end - - # returns an array of CRDs to be postdelete since helm does not - # manage the CRDs it creates. - def api_groups - @api_groups ||= YAML.safe_load(File.read(Rails.root.join(API_GROUPS_PATH))) - end - - # Relied on application_prometheus which is now removed - def install_knative_metrics - [] - end - - # Relied on application_prometheus which is now removed - def delete_knative_istio_metrics - [] - end - end - end -end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb deleted file mode 100644 index b8ed33828bc..00000000000 --- a/app/models/clusters/applications/runner.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class Runner < ApplicationRecord - VERSION = '0.42.1' - - self.table_name = 'clusters_applications_runners' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - - belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id - delegate :project, :group, to: :cluster - - attribute :version, default: VERSION - - def chart - "#{name}/gitlab-runner" - end - - def repository - 'https://charts.gitlab.io' - end - - def values - content_values.to_yaml - end - - def install_command - helm_command_module::InstallCommand.new( - name: name, - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - files: files, - repository: repository - ) - end - - def prepare_uninstall - # No op, see https://gitlab.com/gitlab-org/gitlab/-/issues/350180. - end - - def post_uninstall - runner.destroy! - end - - private - - def gitlab_url - Gitlab::Routing.url_helpers.root_url(only_path: false) - end - - def specification - { - "gitlabUrl" => gitlab_url, - "runners" => { "privileged" => privileged } - } - end - - def content_values - YAML.load_file(chart_values_file).deep_merge!(specification) - end - end - end -end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 5cd11265808..a2903bba6d2 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -11,16 +11,8 @@ module Clusters self.table_name = 'clusters' - APPLICATIONS = { - Clusters::Applications::Helm.application_name => Clusters::Applications::Helm, - Clusters::Applications::Ingress.application_name => Clusters::Applications::Ingress, - Clusters::Applications::Runner.application_name => Clusters::Applications::Runner, - Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, - Clusters::Applications::Knative.application_name => Clusters::Applications::Knative - }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' - APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze self.reactive_cache_work_type = :external_dependency @@ -52,12 +44,6 @@ module Clusters has_one application.association_name, class_name: application.to_s, inverse_of: :cluster # rubocop:disable Rails/ReflectionClassName end - has_one_cluster_application :helm - has_one_cluster_application :ingress - has_one_cluster_application :runner - has_one_cluster_application :jupyter - has_one_cluster_application :knative - has_many :kubernetes_namespaces has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster @@ -84,9 +70,6 @@ module Clusters delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, 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 - alias_attribute :base_domain, :domain alias_attribute :provided_by_user?, :user? @@ -119,7 +102,6 @@ module Clusters scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } scope :managed, -> { where(managed: true) } - scope :with_persisted_applications, -> { eager_load(*APPLICATIONS_ASSOCIATIONS) } scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } scope :with_management_project, -> { where.not(management_project: nil) } @@ -228,24 +210,6 @@ module Clusters connection_data.merge(Gitlab::Kubernetes::Node.new(self).all) end - def persisted_applications - APPLICATIONS_ASSOCIATIONS.filter_map { |association_name| public_send(association_name) } # rubocop:disable GitlabSecurity/PublicSend - end - - def applications - APPLICATIONS.each_value.map do |application_class| - find_or_build_application(application_class) - end - end - - def find_or_build_application(application_class) - raise ArgumentError, "#{application_class} is not in APPLICATIONS" unless APPLICATIONS.value?(application_class) - - association_name = application_class.association_name - - public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend - end - def find_or_build_integration_prometheus integration_prometheus || build_integration_prometheus end @@ -266,18 +230,6 @@ module Clusters !!platform_kubernetes&.rbac? end - def application_helm_available? - !!application_helm&.available? - end - - def application_ingress_available? - !!application_ingress&.available? - end - - def application_knative_available? - !!application_knative&.available? - end - def integration_prometheus_available? !!integration_prometheus&.available? end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 716be080851..4f6ca5a9617 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -8,10 +8,12 @@ class CommitStatus < Ci::ApplicationRecord include Presentable include BulkInsertableAssociations include TaggableQueries + include SafelyChangeColumnDefault self.table_name = 'ci_builds' self.primary_key = :id partitionable scope: :pipeline + columns_changing_default :partition_id belongs_to :user belongs_to :project diff --git a/app/models/compare.rb b/app/models/compare.rb index f03390334f4..58279cb58aa 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -30,7 +30,7 @@ class Compare # See `namespace_project_compare_url` def to_param { - from: @straight ? start_commit_sha : base_commit_sha, + from: @straight ? start_commit_sha : (base_commit_sha || start_commit_sha), to: head_commit_sha } end diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb index 1bdb89349aa..c01399184ad 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -74,7 +74,7 @@ module Analytics query = <<~SQL INSERT INTO #{quoted_table_name} ( - stage_event_hash_id, + stage_event_hash_id, #{connection.quote_column_name(issuable_id_column)}, group_id, project_id, diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb deleted file mode 100644 index da87d87e838..00000000000 --- a/app/models/concerns/awareness.rb +++ /dev/null @@ -1,41 +0,0 @@ -# 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/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb index c3aa3019abb..11e88ee3372 100644 --- a/app/models/concerns/bulk_member_access_load.rb +++ b/app/models/concerns/bulk_member_access_load.rb @@ -5,16 +5,20 @@ module BulkMemberAccessLoad included do def merge_value_to_request_store(resource_klass, resource_id, value) - Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass), - resource_ids: [resource_id], - default_value: Gitlab::Access::NO_ACCESS) do + Gitlab::SafeRequestLoader.execute( + resource_key: max_member_access_for_resource_key(resource_klass), + resource_ids: [resource_id], + default_value: Gitlab::Access::NO_ACCESS + ) do { resource_id => value } end end def purge_resource_id_from_request_store(resource_klass, resource_id) - Gitlab::SafeRequestPurger.execute(resource_key: max_member_access_for_resource_key(resource_klass), - resource_ids: [resource_id]) + Gitlab::SafeRequestPurger.execute( + resource_key: max_member_access_for_resource_key(resource_klass), + resource_ids: [resource_id] + ) end def max_member_access_for_resource_key(klass) diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index d91f33452a0..1c6b82d6ea7 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -9,10 +9,11 @@ module Ci extend ActiveSupport::Concern included do - has_one :metadata, class_name: 'Ci::BuildMetadata', - foreign_key: :build_id, - inverse_of: :build, - autosave: true + has_one :metadata, + class_name: 'Ci::BuildMetadata', + foreign_key: :build_id, + inverse_of: :build, + autosave: true accepts_nested_attributes_for :metadata diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index 28cc17432bc..d8417773dbd 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -2,7 +2,7 @@ module Ci ## - # This module implements a way to set the `partion_id` value on a dependent + # This module implements a way to set the `partition_id` value on a dependent # resource from a parent record. # Usage: # @@ -36,7 +36,7 @@ module Ci Ci::Pipeline Ci::PendingBuild Ci::RunningBuild - Ci::RunnerMachineBuild + Ci::RunnerManagerBuild Ci::PipelineVariable Ci::Sources::Pipeline Ci::Stage diff --git a/app/models/concerns/clusters/agents/authorization_config_scopes.rb b/app/models/concerns/clusters/agents/authorization_config_scopes.rb deleted file mode 100644 index 0a0406c3389..00000000000 --- a/app/models/concerns/clusters/agents/authorization_config_scopes.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Agents - module AuthorizationConfigScopes - extend ActiveSupport::Concern - - included do - scope :with_available_ci_access_fields, ->(project) { - where("config->'access_as' IS NULL") - .or(where("config->'access_as' = '{}'")) - .or(where("config->'access_as' ?| array[:fields]", fields: available_ci_access_fields(project))) - } - end - - class_methods do - def available_ci_access_fields(_project) - %w(agent) - end - end - end - end -end - -Clusters::Agents::AuthorizationConfigScopes.prepend_mod diff --git a/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb b/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb new file mode 100644 index 00000000000..eef68bfd349 --- /dev/null +++ b/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + module ConfigScopes + extend ActiveSupport::Concern + + included do + scope :with_available_ci_access_fields, ->(project) { + where("config->'access_as' IS NULL") + .or(where("config->'access_as' = '{}'")) + .or(where("config->'access_as' ?| array[:fields]", fields: available_ci_access_fields(project))) + } + end + + class_methods do + def available_ci_access_fields(_project) + %w(agent) + end + end + end + end + end + end +end + +Clusters::Agents::Authorizations::CiAccess::ConfigScopes.prepend_mod diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb index 9f75b3ed4d8..37b479d5237 100644 --- a/app/models/concerns/database_event_tracking.rb +++ b/app/models/concerns/database_event_tracking.rb @@ -30,7 +30,7 @@ module DatabaseEventTracking # that reports data asynchronously and does not impact performance nor carries a risk of # rollback in case of error - Gitlab::Tracking.event( + Gitlab::Tracking.database_event( self.class.to_s, "database_event_#{name}", label: self.class.table_name, diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 40891073738..d3ebda2702d 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -7,20 +7,20 @@ module DiscussionOnDiff NUMBER_OF_TRUNCATED_DIFF_LINES = 16 included do - delegate :line_code, - :original_line_code, - :note_diff_file, - :diff_line, - :active?, - :created_at_diff?, - to: :first_note - - delegate :file_path, - :blob, - :highlighted_diff_lines, - :diff_lines, - to: :diff_file, - allow_nil: true + delegate :line_code, + :original_line_code, + :note_diff_file, + :diff_line, + :active?, + :created_at_diff?, + to: :first_note + + delegate :file_path, + :blob, + :highlighted_diff_lines, + :diff_lines, + to: :diff_file, + allow_nil: true end def diff_discussion? diff --git a/app/models/concerns/enums/abuse/source.rb b/app/models/concerns/enums/abuse/source.rb new file mode 100644 index 00000000000..80703126aae --- /dev/null +++ b/app/models/concerns/enums/abuse/source.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Enums + module Abuse + module Source + def self.sources + { + spamcheck: 0, + virus_total: 1, + arkose_custom_score: 2, + arkose_global_score: 3, + telesign: 4, + pvs: 5 + } + end + end + end +end diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index a8227363a22..8e161c1513f 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -17,7 +17,8 @@ module Enums sprints: 9, # iterations design_management_designs: 10, incident_management_oncall_schedules: 11, - ml_experiments: 12 + ml_experiments: 12, + ml_candidates: 13 } end end diff --git a/app/models/concerns/enums/package_metadata.rb b/app/models/concerns/enums/package_metadata.rb index e15fe758e69..a866e2b995a 100644 --- a/app/models/concerns/enums/package_metadata.rb +++ b/app/models/concerns/enums/package_metadata.rb @@ -10,11 +10,19 @@ module Enums maven: 5, npm: 6, nuget: 7, - pypi: 8 + pypi: 8, + apk: 9, + rpm: 10, + deb: 11, + cbl_mariner: 12 }.with_indifferent_access.freeze def self.purl_types PURL_TYPES end + + def self.purl_types_numerical + purl_types.invert + end end end diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb index 8848c0c5555..3ba911dbcc5 100644 --- a/app/models/concerns/enums/sbom.rb +++ b/app/models/concerns/enums/sbom.rb @@ -14,7 +14,11 @@ module Enums maven: 5, npm: 6, nuget: 7, - pypi: 8 + pypi: 8, + apk: 9, + rpm: 10, + deb: 11, + cbl_mariner: 12 }.with_indifferent_access.freeze def self.component_types diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb index 5975ea23723..cc55315d6d7 100644 --- a/app/models/concerns/expirable.rb +++ b/app/models/concerns/expirable.rb @@ -8,7 +8,7 @@ module Expirable included do scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) } - scope :expired, -> { where('expires_at IS NOT NULL AND expires_at <= ?', Time.current) } + scope :expired, -> { where.not(expires_at: nil).where(arel_table[:expires_at].lteq(Time.current)) } scope :not_expired, -> { self.not(expired) } end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 224ac8930b5..de316446e14 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -60,10 +60,7 @@ module GroupDescendant end if parent && parent != hierarchy_top - expand_hierarchy_for_child(parent, - { parent => hierarchy }, - hierarchy_top, - preloaded) + expand_hierarchy_for_child(parent, { parent => hierarchy }, hierarchy_top, preloaded) else hierarchy end diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 0b1c6780db8..468ea26c51a 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -4,7 +4,8 @@ module HasUserType extend ActiveSupport::Concern USER_TYPES = { - human: nil, + human_deprecated: nil, + human: 0, support_bot: 1, alert_bot: 2, visual_review_bot: 3, @@ -17,7 +18,8 @@ module HasUserType security_policy_bot: 10, # Currently not in use. See https://gitlab.com/gitlab-org/gitlab/-/issues/384174 admin_bot: 11, suggested_reviewers_bot: 12, - service_account: 13 + service_account: 13, + llm_bot: 14 }.with_indifferent_access.freeze BOT_USER_TYPES = %w[ @@ -32,15 +34,20 @@ module HasUserType admin_bot suggested_reviewers_bot service_account + llm_bot ].freeze # `service_account` allows instance/namespaces to configure a user for external integrations/automations # `service_user` is an internal, `gitlab-com`-specific user type for integrations like suggested reviewers - NON_INTERNAL_USER_TYPES = %w[human project_bot service_user service_account].freeze + NON_INTERNAL_USER_TYPES = %w[human human_deprecated project_bot service_user service_account].freeze INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze included do - scope :humans, -> { where(user_type: :human) } + enum user_type: USER_TYPES + + scope :humans, -> { where(user_type: :human).or(where(user_type: :human_deprecated)) } + # Override default scope to include temporary human type. See https://gitlab.com/gitlab-org/gitlab/-/issues/386474 + scope :human, -> { humans } scope :bots, -> { where(user_type: BOT_USER_TYPES) } scope :without_bots, -> { humans.or(where(user_type: USER_TYPES.keys - BOT_USER_TYPES)) } scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) } @@ -48,10 +55,8 @@ module HasUserType scope :without_project_bot, -> { humans.or(where(user_type: USER_TYPES.keys - ['project_bot'])) } scope :human_or_service_user, -> { humans.or(where(user_type: :service_user)) } - enum user_type: USER_TYPES - def human? - super || user_type.nil? + super || human_deprecated? || user_type.nil? end end diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb index 57f8e21c5a6..223191fb963 100644 --- a/app/models/concerns/integrations/has_issue_tracker_fields.rb +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -8,29 +8,29 @@ module Integrations self.field_storage = :data_fields field :project_url, - required: true, - title: -> { _('Project URL') }, - help: -> do - s_('IssueTracker|The URL to the project in the external issue tracker.') - end + required: true, + title: -> { _('Project URL') }, + help: -> do + s_('IssueTracker|The URL to the project in the external issue tracker.') + end field :issues_url, - required: true, - title: -> { s_('IssueTracker|Issue URL') }, - help: -> do - 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 + required: true, + title: -> { s_('IssueTracker|Issue URL') }, + help: -> do + 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, - title: -> { s_('IssueTracker|New issue URL') }, - help: -> do - s_('IssueTracker|The URL to create an issue in the external issue tracker.') - end + required: true, + title: -> { s_('IssueTracker|New issue URL') }, + 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/issuable.rb b/app/models/concerns/issuable.rb index c1c1691e424..6594884ca0a 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -84,11 +84,11 @@ module Issuable has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true delegate :name, - :email, - :public_email, - to: :author, - allow_nil: true, - prefix: true + :email, + :public_email, + to: :author, + allow_nil: true, + prefix: true validates :author, presence: true validates :title, presence: true, length: { maximum: TITLE_LENGTH_MAX } @@ -345,8 +345,7 @@ module Issuable order_milestone_due_asc .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]) - .reorder(milestones_due_date_with_direction.nulls_last, - highest_priority_arel_with_direction.nulls_last) + .reorder(milestones_due_date_with_direction.nulls_last, highest_priority_arel_with_direction.nulls_last) end def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false) @@ -620,8 +619,10 @@ module Issuable end def updated_tasks - Taskable.get_updated_tasks(old_content: previous_changes['description'].first, - new_content: description) + Taskable.get_updated_tasks( + old_content: previous_changes['description'].first, + new_content: description + ) end ## diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index 209456f8b67..c5d194a93e7 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -27,7 +27,14 @@ module IssueAvailableFeatures raise ArgumentError, 'invalid feature' end - self.class.available_features_for_issue_types[feature].include?(issue_type) + type_for_issue = if Feature.enabled?(:issue_type_uses_work_item_types_table) + # The default will only be used in places where an issue is only build and not saved + work_item_type_with_default.base_type + else + issue_type + end + + self.class.available_features_for_issue_types[feature].include?(type_for_issue) end end diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb index 0cccb7b51a8..7ed7f65ca57 100644 --- a/app/models/concerns/limitable.rb +++ b/app/models/concerns/limitable.rb @@ -59,7 +59,10 @@ module Limitable def check_plan_limit_not_exceeded(limits, relation) return unless limits&.exceeded?(limit_name, relation) - errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") % - { name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend + errors.add( + :base, + _("Maximum number of %{name} (%{count}) exceeded") % + { name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) } # rubocop:disable GitlabSecurity/PublicSend + ) end end diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index b05beb6c764..0265d609e19 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -5,9 +5,7 @@ module Mentionable extend Gitlab::Utils::StrongMemoize def self.reference_pattern(link_patterns, issue_pattern) - Regexp.union(link_patterns, - issue_pattern, - *other_patterns) + Regexp.union(link_patterns, issue_pattern, *other_patterns) end def self.other_patterns @@ -29,7 +27,7 @@ module Mentionable def self.external_pattern strong_memoize(:external_pattern) do - issue_pattern = Integrations::BaseIssueTracker.reference_pattern + issue_pattern = Integrations::BaseIssueTracker.base_reference_pattern link_patterns = URI::DEFAULT_PARSER.make_regexp(%w(http https)) reference_pattern(link_patterns, issue_pattern) end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index facf0808e7a..6ed2cfb6f78 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -27,13 +27,11 @@ module ProtectedRefAccess scope :for_user, -> { where.not(user_id: nil) } scope :for_group, -> { where.not(group_id: nil) } - validates :access_level, presence: true, if: :role?, inclusion: { - in: self.allowed_access_levels - } + validates :access_level, presence: true, if: :role?, inclusion: { in: allowed_access_levels } end def humanize - HUMAN_ACCESS_LEVELS[self.access_level] + HUMAN_ACCESS_LEVELS[access_level] end def type diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index 141c480ea1f..45818942326 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -24,14 +24,14 @@ module ResolvableDiscussion ) delegate :potentially_resolvable?, - :noteable_id, - :noteable_type, - to: :first_note - - delegate :resolved_at, - :resolved_by, - to: :last_resolved_note, - allow_nil: true + :noteable_id, + :noteable_type, + to: :first_note + + delegate :resolved_at, + :resolved_by, + to: :last_resolved_note, + allow_nil: true end def resolved_by_push? diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index 1e8a290c050..a5b69997900 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -47,8 +47,9 @@ module VulnerabilityFindingHelpers 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) + finding_data = report_finding.to_hash.except( + :compare_key, :identifiers, :location, :scanner, :links, :signatures, :flags, :evidence + ) identifiers = report_finding.identifiers.uniq(&:fingerprint).map do |identifier| Vulnerabilities::Identifier.new(identifier.to_hash.merge({ project: project })) end diff --git a/app/models/concerns/web_hooks/auto_disabling.rb b/app/models/concerns/web_hooks/auto_disabling.rb index 05aaca32f35..2ad2e47ec4e 100644 --- a/app/models/concerns/web_hooks/auto_disabling.rb +++ b/app/models/concerns/web_hooks/auto_disabling.rb @@ -39,8 +39,11 @@ module WebHooks scope :disabled, -> do return none unless auto_disabling_enabled? - where('recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)', - FAILURE_THRESHOLD, Time.current) + where( + 'recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)', + FAILURE_THRESHOLD, + Time.current + ) end # A hook is executable if: @@ -52,8 +55,12 @@ module WebHooks scope :executable, -> do return all unless auto_disabling_enabled? - where('recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))', - FAILURE_THRESHOLD, FAILURE_THRESHOLD, Time.current) + where( + 'recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))', + FAILURE_THRESHOLD, + FAILURE_THRESHOLD, + Time.current + ) end end diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index d90f32d8b1c..caaf2b33ef0 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -25,6 +25,13 @@ module WithUploads FILE_UPLOADERS = %w(PersonalFileUploader NamespaceFileUploader FileUploader).freeze included do + around_destroy :ignore_uploads_table_in_transaction + + def ignore_uploads_table_in_transaction(&blk) + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[uploads], url: "https://gitlab.com/gitlab-org/gitlab/-/issues/398199", &blk) + end + has_many :uploads, as: :model has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model, diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index b3cbe498551..62b6effeb89 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -22,6 +22,12 @@ class ContainerRepository < ApplicationRecord MAX_TAGS_PAGES = 2000 + # The Registry client uses JWT token to authenticate to Registry. We cache the client using expiration + # time of JWT token. However it's possible that the token is valid but by the time the request is made to + # Regsitry, it's already expired. To prevent this case, we are subtracting a few seconds, defined by this constant + # from the cache expiration time. + AUTH_TOKEN_USAGE_RESERVED_TIME_IN_SECS = 5 + TooManyImportsError = Class.new(StandardError) belongs_to :project @@ -289,6 +295,10 @@ class ContainerRepository < ApplicationRecord all end + def self.registry_client_expiration_time + (Gitlab::CurrentSettings.container_registry_token_expire_delay * 60) - AUTH_TOKEN_USAGE_RESERVED_TIME_IN_SECS + end + class << self alias_method :pending_destruction, :delete_scheduled # needed by Packages::Destructible end @@ -410,7 +420,7 @@ class ContainerRepository < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def registry - @registry ||= begin + strong_memoize_with_expiration(:registry, self.class.registry_client_expiration_time) do token = Auth::ContainerRegistryAuthenticationService.full_access_token(path) url = Gitlab.config.registry.api_url diff --git a/app/models/design_management/git_repository.rb b/app/models/design_management/git_repository.rb new file mode 100644 index 00000000000..92db82f7bd1 --- /dev/null +++ b/app/models/design_management/git_repository.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module DesignManagement + class GitRepository < ::Repository + extend ::Gitlab::Utils::Override + + # We define static git attributes for the design repository as this + # repository is entirely GitLab-managed rather than user-facing. + # + # Enable all uploaded files to be stored in LFS. + MANAGED_GIT_ATTRIBUTES = <<~GA.freeze + /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text + GA + + # Passing the `project` explicitly saves on one query on the `project` table + # in Mutations::DesignManagement::Delete + + def initialize(project) + @project = project + + full_path = @project.full_path + Gitlab::GlRepository::DESIGN.path_suffix + disk_path = @project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix + + # Ideally a DesignManagement::Repository, not a project would be + # the container to this Git repository. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/394816. + + super( + full_path, + @project, + shard: @project.repository_storage, + disk_path: disk_path, + repo_type: Gitlab::GlRepository::DESIGN + ) + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def info_attributes + @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES) + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def attributes(path) + info_attributes.attributes(path) + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def gitattribute(path, name) + attributes(path)[name] + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def attributes_at(_ref = nil) + info_attributes + end + + override :copy_gitattributes + def copy_gitattributes(_ref = nil) + true + end + end +end diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb index 2b1e6070e6b..e6790bc3253 100644 --- a/app/models/design_management/repository.rb +++ b/app/models/design_management/repository.rb @@ -1,51 +1,24 @@ # frozen_string_literal: true module DesignManagement - class Repository < ::Repository - extend ::Gitlab::Utils::Override + class Repository < ApplicationRecord + include ::Gitlab::Utils::StrongMemoize - # We define static git attributes for the design repository as this - # repository is entirely GitLab-managed rather than user-facing. - # - # Enable all uploaded files to be stored in LFS. - MANAGED_GIT_ATTRIBUTES = <<~GA - /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text - GA + belongs_to :project, inverse_of: :design_management_repository + validates :project, presence: true, uniqueness: true - def initialize(project) - full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix - disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix + # This is so that git_repo is initialized once `project` has been + # set. If it is not set after intialization and saving the record + # fails for some reason, the first call to `git_repo`` (initiated by + # `delegate_missing_to`) will throw an error because project would + # be missing. + after_initialize :git_repo - super(full_path, project, shard: project.repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::DESIGN) - end - - # Override of a method called on Repository instances but sent via - # method_missing to Gitlab::Git::Repository where it is defined - def info_attributes - @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES) - end - - # Override of a method called on Repository instances but sent via - # method_missing to Gitlab::Git::Repository where it is defined - def attributes(path) - info_attributes.attributes(path) - end - - # Override of a method called on Repository instances but sent via - # method_missing to Gitlab::Git::Repository where it is defined - def gitattribute(path, name) - attributes(path)[name] - end - - # Override of a method called on Repository instances but sent via - # method_missing to Gitlab::Git::Repository where it is defined - def attributes_at(_ref = nil) - info_attributes - end + delegate_missing_to :git_repo - override :copy_gitattributes - def copy_gitattributes(_ref = nil) - true + def git_repo + GitRepository.new(project) end + strong_memoize_attr :git_repo end end diff --git a/app/models/event.rb b/app/models/event.rb index 333841b1f90..76a34bf7810 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -9,6 +9,9 @@ class Event < ApplicationRecord include Gitlab::Utils::StrongMemoize include UsageStatistics include ShaAttribute + include IgnorableColumns + + ignore_column :target_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' ACTIONS = HashWithIndifferentAccess.new( created: 1, diff --git a/app/models/group.rb b/app/models/group.rb index 01e2c220dbe..f13ce2ddca1 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -200,14 +200,27 @@ class Group < Namespace .where(project_authorizations: { user_id: user_ids }) end + scope :with_project_creation_levels, -> (project_creation_levels) do + where(project_creation_level: project_creation_levels) + end + scope :project_creation_allowed, -> do - permitted_levels = [ + project_creation_allowed_on_levels = [ ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS, ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS, nil ] - where(project_creation_level: permitted_levels) + # When the value of application_settings.default_project_creation is set to `NO_ONE_PROJECT_ACCESS`, + # it means that a `nil` value for `groups.project_creation_level` is telling us: + # do not allow project creation in such groups. + # ie, `nil` is a placeholder value for inheriting the value from the ApplicationSetting. + # So we remove `nil` from the list when the application_setting's value is `NO_ONE_PROJECT_ACCESS` + if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS + project_creation_allowed_on_levels.delete(nil) + end + + with_project_creation_levels(project_creation_allowed_on_levels) end scope :shared_into_ancestors, -> (group) do @@ -551,7 +564,7 @@ class Group < Namespace # rubocop: enable CodeReuse/ServiceClass def users_ids_of_direct_members - direct_members.pluck(:user_id) + direct_members.pluck_user_ids end def user_ids_for_project_authorizations @@ -894,6 +907,10 @@ class Group < Namespace ].compact.min end + def content_editor_on_issues_feature_flag_enabled? + feature_flag_enabled_for_self_or_ancestor?(:content_editor_on_issues) + end + def work_items_feature_flag_enabled? feature_flag_enabled_for_self_or_ancestor?(:work_items) end diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index 15949570f9c..fdb8fb9ed75 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -19,6 +19,14 @@ class GroupGroupLink < ApplicationRecord where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER]) end + scope :with_developer_maintainer_owner_access, -> do + where(group_access: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER]) + end + + scope :with_developer_access, -> do + where(group_access: [Gitlab::Access::DEVELOPER]) + end + scope :with_owner_access, -> do where(group_access: [Gitlab::Access::OWNER]) end diff --git a/app/models/group_label.rb b/app/models/group_label.rb index 0d2eb524929..46e56166951 100644 --- a/app/models/group_label.rb +++ b/app/models/group_label.rb @@ -11,4 +11,8 @@ class GroupLabel < Label def subject_foreign_key 'group_id' end + + def preloaded_parent_container + association(:group).loaded? ? group : parent_container + end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 25ccdc2b4f1..5ccbc926a71 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -133,7 +133,7 @@ class WebHook < ApplicationRecord def reset_url_variables interpolated_url_was = interpolated_url(decrypt_url_was, url_variables_were) - return if url_variables_were.empty? || interpolated_url_was == interpolated_url + return if url_variables_were.blank? || interpolated_url_was == interpolated_url self.url_variables = {} if url_changed? && url_variables_were.to_a.intersection(url_variables.to_a).any? end diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index 34da4c0f4b8..9efc85cbdb1 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -24,14 +24,12 @@ module Integrations field :app_store_key_id, section: SECTION_TYPE_CONNECTION, required: true, - title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') }, - is_secret: false + title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') } field :app_store_private_key_file_name, - section: SECTION_TYPE_CONNECTION, - is_secret: false + section: SECTION_TYPE_CONNECTION - field :app_store_private_key, api_only: true, is_secret: false + field :app_store_private_key, api_only: true def title 'Apple App Store Connect' @@ -53,7 +51,7 @@ module Integrations s_("Use the Apple App Store Connect integration to easily connect to the Apple App Store with Fastlane in CI/CD pipelines."), s_("After the Apple App Store Connect integration is activated, the following protected variables will be created for CI/CD use."), variable_list.join('<br>'), - s_(format("To get started, see the <a href='%{url}' target='_blank'>integration documentation</a> for instructions on how to generate App Store Connect credentials, and how to use this integration.", url: "https://docs.gitlab.com/ee/integration/apple_app_store.html")).html_safe + s_(format("To get started, see the <a href='%{url}' target='_blank'>integration documentation</a> for instructions on how to generate App Store Connect credentials, and how to use this integration.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/apple_app_store'))).html_safe ] # rubocop:enable Layout/LineLength diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index fc5e6a88c2d..4638ca0c5f1 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -17,7 +17,8 @@ module Integrations non_empty_password_title: -> { s_('BambooService|Enter new build key') }, non_empty_password_help: -> { s_('BambooService|Leave blank to use your current build key.') }, placeholder: -> { _('KEY') }, - required: true + required: true, + is_secret: true field :username, help: -> { s_('BambooService|The user with API access to the Bamboo server.') } diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index e0994305e9d..7a54d354007 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -14,7 +14,7 @@ module Integrations # This pattern does not support cross-project references # The other code assumes that this pattern is a superset of all # overridden patterns. See ReferenceRegexes.external_pattern - def self.reference_pattern(only_long: false) + def self.base_reference_pattern(only_long: false) if only_long /(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/ else @@ -22,6 +22,10 @@ module Integrations end end + def reference_pattern(only_long: false) + self.class.base_reference_pattern(only_long: only_long) + end + def handle_properties # this has been moved from initialize_properties and should be improved # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb index 1b86ef73c85..003c896704a 100644 --- a/app/models/integrations/ewm.rb +++ b/app/models/integrations/ewm.rb @@ -6,7 +6,7 @@ module Integrations validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def self.reference_pattern(only_long: true) + def reference_pattern(only_long: true) @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i end diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb index 329c046075f..9f2274216f6 100644 --- a/app/models/integrations/field.rb +++ b/app/models/integrations/field.rb @@ -2,8 +2,6 @@ module Integrations class Field - SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze - BOOLEAN_ATTRIBUTES = %i[required api_only is_secret exposes_secrets].freeze ATTRIBUTES = %i[ @@ -17,11 +15,11 @@ module Integrations attr_reader :name, :integration_class - def initialize(name:, integration_class:, type: 'text', is_secret: true, api_only: false, **attributes) + def initialize(name:, integration_class:, type: 'text', is_secret: false, api_only: false, **attributes) @name = name.to_s.freeze @integration_class = integration_class - attributes[:type] = SECRET_NAME.match?(@name) && is_secret ? 'password' : type + attributes[:type] = is_secret ? 'password' : type attributes[:api_only] = api_only attributes[:is_secret] = is_secret @attributes = attributes.freeze diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb index 8f1d2e7e1ec..9fa6dc19f11 100644 --- a/app/models/integrations/google_play.rb +++ b/app/models/integrations/google_play.rb @@ -2,6 +2,8 @@ module Integrations class GooglePlay < Integration + PACKAGE_NAME_REGEX = /\A[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*){1,20}\z/ + SECTION_TYPE_GOOGLE_PLAY = 'google_play' with_options if: :activated? do @@ -9,14 +11,19 @@ module Integrations filename: "google_service_account_key", parse_json: true } validates :service_account_key_file_name, presence: true + validates :package_name, presence: true, format: { with: PACKAGE_NAME_REGEX } end + field :package_name, + section: SECTION_TYPE_CONNECTION, + placeholder: 'com.example.myapp', + required: true + field :service_account_key_file_name, section: SECTION_TYPE_CONNECTION, - required: true, - is_secret: false + required: true - field :service_account_key, api_only: true, is_secret: false + field :service_account_key, api_only: true def title s_('GooglePlay|Google Play') @@ -28,6 +35,7 @@ module Integrations def help variable_list = [ + '<code>SUPPLY_PACKAGE_NAME</code>', '<code>SUPPLY_JSON_KEY_DATA</code>' ] @@ -36,7 +44,7 @@ module Integrations s_("Use the Google Play integration to connect to Google Play with fastlane in CI/CD pipelines."), s_("After you enable the integration, the following protected variable is created for CI/CD use:"), variable_list.join('<br>'), - s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: "#")).html_safe + s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe ] # rubocop:enable Layout/LineLength @@ -62,9 +70,9 @@ module Integrations end def test(*_args) - client.fetch_access_token! + client.list_reviews(package_name) { success: true } - rescue Signet::AuthorizationError => error + rescue Google::Apis::ClientError => error { success: false, message: error } end @@ -72,17 +80,22 @@ module Integrations return [] unless activated? [ - { key: 'SUPPLY_JSON_KEY_DATA', value: service_account_key, masked: true, public: false } + { key: 'SUPPLY_JSON_KEY_DATA', value: service_account_key, masked: true, public: false }, + { key: 'SUPPLY_PACKAGE_NAME', value: package_name, masked: false, public: false } ] end private def client - Google::Auth::ServiceAccountCredentials.make_creds( + service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new # rubocop: disable CodeReuse/ServiceClass + + service.authorization = Google::Auth::ServiceAccountCredentials.make_creds( json_key_io: StringIO.new(service_account_key), - scope: ['https://www.googleapis.com/auth/androidpublisher'] + scope: [Google::Apis::AndroidpublisherV3::AUTH_ANDROIDPUBLISHER] ) + + service end end end diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 01a04743d5d..079811e0df0 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -17,7 +17,8 @@ module Integrations field :project_name, title: -> { s_('HarborIntegration|Harbor project name') }, - help: -> { s_('HarborIntegration|The name of the project in Harbor.') } + help: -> { s_('HarborIntegration|The name of the project in Harbor.') }, + required: true field :username, title: -> { s_('HarborIntegration|Harbor username') }, @@ -62,7 +63,7 @@ module Integrations end def test(*_args) - client.ping + client.check_project_availability end def ci_variables diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index a1cdd55ceae..0f7e9b96370 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -23,6 +23,8 @@ module Integrations validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? validates :password, presence: true, if: :activated? + validates :jira_issue_prefix, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? + validates :jira_issue_regex, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? validates :jira_issue_transition_id, format: { @@ -70,7 +72,20 @@ module Integrations title: -> { s_('JiraService|Password or API token') }, non_empty_password_title: -> { s_('JiraService|Enter new password or API token') }, non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, - help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') } + help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') }, + is_secret: true + + field :jira_issue_regex, + section: SECTION_TYPE_CONFIGURATION, + required: false, + title: -> { s_('JiraService|Jira issue regex') }, + help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') } + + field :jira_issue_prefix, + section: SECTION_TYPE_CONFIGURATION, + required: false, + title: -> { s_('JiraService|Jira issue prefix') }, + help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') } field :jira_issue_transition_id, api_only: true @@ -90,8 +105,8 @@ module Integrations end # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 - def self.reference_pattern(only_long: true) - @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ + def reference_pattern(only_long: true) + @reference_pattern ||= jira_issue_match_regex end def self.valid_jira_cloud_url?(url) @@ -166,6 +181,11 @@ module Integrations type: SECTION_TYPE_JIRA_TRIGGER, title: _('Trigger'), description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.') + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: _('Jira issue matching'), + description: s_('Configure custom rules for Jira issue key matching') } ] @@ -325,6 +345,12 @@ module Integrations private + def jira_issue_match_regex + match_regex = (jira_issue_regex.presence || Gitlab::Regex.jira_issue_key_regex) + + /\b#{jira_issue_prefix}(?<issue>#{match_regex})/ + end + def parse_project_from_issue_key(issue_key) issue_key.gsub(Gitlab::Regex.jira_issue_key_project_key_extraction_regex, '') end diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb index f5079b9b907..e075400d9b5 100644 --- a/app/models/integrations/mattermost_slash_commands.rb +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -15,11 +15,11 @@ module Integrations end def title - 'Mattermost slash commands' + s_('Integrations|Mattermost slash commands') end def description - "Perform common tasks with slash commands." + s_('Integrations|Perform common tasks with slash commands.') end def self.to_param diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index fa719f925ed..15246a37aa7 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -7,12 +7,11 @@ module Integrations validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 - def self.reference_pattern(only_long: false) - if only_long - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ - else - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/ - end + def reference_pattern(only_long: false) + return @reference_pattern if defined?(@reference_pattern) + + regex_suffix = "|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})" + @reference_pattern = /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)#{regex_suffix if only_long}/ end def title diff --git a/app/models/issue.rb b/app/models/issue.rb index a19b5809ff8..8ace5dfff57 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -39,6 +39,8 @@ class Issue < ApplicationRecord DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze + IssueTypeOutOfSyncError = Class.new(StandardError) + SORTING_PREFERENCE_FIELD = :issues_sort MAX_BRANCH_TEMPLATE = 255 @@ -52,16 +54,18 @@ class Issue < ApplicationRecord # Types of issues that should be displayed on issue board lists TYPES_FOR_BOARD_LIST = %w(issue incident).freeze + # This default came from the enum `issue_type` column. Defined as default in the DB + DEFAULT_ISSUE_TYPE = :issue + belongs_to :project belongs_to :namespace, inverse_of: :issues belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' - belongs_to :iteration, foreign_key: 'sprint_id' belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :work_items - belongs_to :moved_to, class_name: 'Issue' - has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id + belongs_to :moved_to, class_name: 'Issue', inverse_of: :moved_from + has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id, inverse_of: :moved_to has_internal_id :iid, scope: :namespace, track_if: -> { !importing? }, init: ->(issue, scope) do # we need this init for the case where the IID allocation in internal_ids#last_value @@ -114,6 +118,7 @@ class Issue < ApplicationRecord has_many :issue_customer_relations_contacts, class_name: 'CustomerRelations::IssueContact', inverse_of: :issue has_many :customer_relations_contacts, through: :issue_customer_relations_contacts, source: :contact, class_name: 'CustomerRelations::Contact', inverse_of: :issues has_many :incident_management_timeline_events, class_name: 'IncidentManagement::TimelineEvent', foreign_key: :issue_id, inverse_of: :incident + has_many :assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', inverse_of: :issue alias_attribute :escalation_status, :incident_management_issuable_escalation_status @@ -231,6 +236,7 @@ class Issue < ApplicationRecord scope :with_projects_matching_search_data, -> { where('issue_search_data.project_id = issues.project_id') } before_validation :ensure_namespace_id, :ensure_work_item_type + before_save :check_issue_type_in_sync! after_save :ensure_metrics!, unless: :importing? after_commit :expire_etag_cache, unless: :importing? @@ -594,6 +600,10 @@ class Issue < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def update_project_counter_caches + # TODO: Fix counter cache for issues in group + # TODO: see https://gitlab.com/gitlab-org/gitlab/-/work_items/393125?iid_path=true + return unless project + Projects::OpenIssuesCountService.new(project).refresh_cache end # rubocop: enable CodeReuse/ServiceClass @@ -688,6 +698,10 @@ class Issue < ApplicationRecord end def expire_etag_cache + # TODO: Fix this for the case when issues is created at group level + # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/395814?iid_path=true + return unless project + key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self) Gitlab::EtagCaching::Store.new.touch(key) end @@ -702,8 +716,45 @@ class Issue < ApplicationRecord ::Gitlab::GlobalId.as_global_id(id, model_name: WorkItem.name) end + def resource_parent + project || namespace + end + + # Persisted records will always have a work_item_type. This method is useful + # in places where we use a non persisted issue to perform feature checks + def work_item_type_with_default + work_item_type || WorkItems::Type.default_by_type(DEFAULT_ISSUE_TYPE) + end + private + def check_issue_type_in_sync! + # We might have existing records out of sync, so we need to skip this check unless the value is changed + # so those records can still be updated until we fix them and remove the issue_type column + # https://gitlab.com/gitlab-org/gitlab/-/work_items/403158?iid_path=true + return unless (changes.keys & %w[issue_type work_item_type_id]).any? + + if issue_type != work_item_type.base_type + error = IssueTypeOutOfSyncError.new( + <<~ERROR + Issue `issue_type` out of sync with `work_item_type_id` column. + `issue_type` must be equal to `work_item.base_type`. + You can assign the correct work_item_type like this for example: + + Issue.new(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident)) + + More details in https://gitlab.com/gitlab-org/gitlab/-/issues/338005 + ERROR + ) + + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + error, + issue_type: issue_type, + work_item_type_id: work_item_type_id + ) + end + end + def due_date_after_start_date return unless start_date.present? && due_date.present? @@ -729,6 +780,10 @@ class Issue < ApplicationRecord override :persist_pg_full_text_search_vector def persist_pg_full_text_search_vector(search_vector) + # TODO: Fix search vector for issues at group level + # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/393126?iid_path=true + return unless project + Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id)) end @@ -745,12 +800,14 @@ class Issue < ApplicationRecord end def record_create_action - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author, project: project) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action( + author: author, namespace: namespace.reset + ) end # Returns `true` if this Issue is visible to everybody. def publicly_visible? - project.public? && project.feature_available?(:issues, nil) && + resource_parent.public? && resource_parent.feature_available?(:issues, nil) && !confidential? && !hidden? && !::Gitlab::ExternalAuthorization.enabled? end @@ -766,6 +823,8 @@ class Issue < ApplicationRecord def ensure_work_item_type return if work_item_type_id.present? || work_item_type_id_change&.last.present? + # TODO: We should switch to DEFAULT_ISSUE_TYPE here when the issue_type column is dropped + # https://gitlab.com/gitlab-org/gitlab/-/work_items/402700?iid_path=true self.work_item_type = WorkItems::Type.default_by_type(issue_type) end diff --git a/app/models/iteration.rb b/app/models/iteration.rb deleted file mode 100644 index ebec24731ed..00000000000 --- a/app/models/iteration.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Placeholder class for model that is implemented in EE -class Iteration < ApplicationRecord - include IgnorableColumns - - self.table_name = 'sprints' - - def self.reference_prefix - '*iteration:' - end - - def self.reference_pattern - nil - end -end - -Iteration.prepend_mod_with('Iteration') diff --git a/app/models/member.rb b/app/models/member.rb index 4329b61fc3d..529666a069c 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Member < ApplicationRecord + extend ::Gitlab::Utils::Override include EachBatch include AfterCommitQueue include Sortable @@ -359,6 +360,10 @@ class Member < ApplicationRecord def valid_email?(email) Devise.email_regexp.match?(email) end + + def pluck_user_ids + pluck(:user_id) + end end def real_source_type @@ -572,7 +577,7 @@ class Member < ApplicationRecord end def after_decline_invite - # override in subclass + notification_service.decline_invite(self) end def after_accept_request diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index f23d7208b6e..aabc902fe03 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class GroupMember < Member - extend ::Gitlab::Utils::Override include FromUnion include CreatedAtFilterable @@ -38,10 +37,6 @@ class GroupMember < Member Gitlab::Access.options_with_owner end - def self.pluck_user_ids - pluck(:user_id) - end - def group source end @@ -112,12 +107,6 @@ class GroupMember < Member super end - def after_decline_invite - notification_service.decline_group_invite(self) - - super - end - def send_welcome_email? true end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 733b7c4bc87..e0fecf702de 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ProjectMember < Member - extend ::Gitlab::Utils::Override SOURCE_TYPE = 'Project' SOURCE_TYPE_FORMAT = /\AProject\z/.freeze @@ -21,40 +20,6 @@ class ProjectMember < Member end class << self - # 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_members_to_projects( - # project_ids, - # user_ids, - # ProjectMember::MAINTAINER - # ) - # - # add_members_to_projects( - # project_ids, - # user_ids, - # :maintainer - # ) - # - 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_members( # rubocop:disable CodeReuse/ServiceClass - project, - users, - access_level, - current_user: current_user, - expires_at: expires_at - ) - end - end - end - def truncate_teams(project_ids) ProjectMember.transaction do members = ProjectMember.where(source_id: project_ids) @@ -180,12 +145,6 @@ class ProjectMember < Member super end - def after_decline_invite - notification_service.decline_project_invite(self) - - super - end - # rubocop: disable CodeReuse/ServiceClass def event_service EventCreateService.new diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb index f6617fa0888..1fef155e6ea 100644 --- a/app/models/members_preloader.rb +++ b/app/models/members_preloader.rb @@ -8,15 +8,12 @@ class MembersPreloader end def preload_all - user_associations = [:status] - user_associations << :webauthn_registrations if Feature.enabled?(:webauthn) - ActiveRecord::Associations::Preloader.new( records: members, associations: [ :source, :created_by, - { user: user_associations } + { user: [:status, :webauthn_registrations] } ] ).call end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 85e95a556a8..1e7ff6e8f0e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -44,7 +44,6 @@ class MergeRequest < ApplicationRecord belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" - belongs_to :iteration, foreign_key: 'sprint_id' has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(mr, scope) do @@ -123,6 +122,7 @@ class MergeRequest < ApplicationRecord has_many :reviews, inverse_of: :merge_request has_many :reviewed_by_users, -> { distinct }, through: :reviews, source: :author has_many :created_environments, class_name: 'Environment', foreign_key: :merge_request_id, inverse_of: :merge_request + has_many :assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', inverse_of: :merge_request KNOWN_MERGE_PARAMS = [ :auto_merge_strategy, @@ -254,7 +254,7 @@ class MergeRequest < ApplicationRecord Gitlab::Timeless.timeless(merge_request, &block) end - after_transition any => [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking, :can_be_merged, :cannot_be_merged] do |merge_request, transition| + after_transition any => [:unchecked, :cannot_be_merged_recheck, :can_be_merged, :cannot_be_merged] do |merge_request, transition| next if merge_request.skip_merge_status_trigger merge_request.run_after_commit do diff --git a/app/models/milestone_note.rb b/app/models/milestone_note.rb index 19171e682b7..14808158fd0 100644 --- a/app/models/milestone_note.rb +++ b/app/models/milestone_note.rb @@ -17,6 +17,7 @@ class MilestoneNote < SyntheticNote def note_text(html: false) format = milestone&.group_milestone? ? :name : :iid - event.remove? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" + reference = milestone&.to_reference(project, format: format) + event.remove? ? "removed milestone #{reference}" : "changed milestone to #{reference}" end end diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index f973b00c568..40758defafc 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -3,25 +3,34 @@ module Ml class Candidate < ApplicationRecord include Sortable + include AtomicInternalId + include IgnorableColumns - PACKAGE_PREFIX = 'ml_candidate_' + ignore_column :iid, remove_with: '16.0', remove_after: '2023-05-01' enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 } - validates :iid, :experiment, presence: true + validates :eid, :experiment, presence: true validates :status, inclusion: { in: statuses.keys } belongs_to :experiment, class_name: 'Ml::Experiment' belongs_to :user + belongs_to :package, class_name: 'Packages::Package' + belongs_to :project has_many :metrics, class_name: 'Ml::CandidateMetric' has_many :params, class_name: 'Ml::CandidateParam' has_many :metadata, class_name: 'Ml::CandidateMetadata' has_many :latest_metrics, -> { latest }, class_name: 'Ml::CandidateMetric', inverse_of: :candidate - attribute :iid, default: -> { SecureRandom.uuid } + attribute :eid, default: -> { SecureRandom.uuid } - scope :including_relationships, -> { includes(:latest_metrics, :params, :user) } + has_internal_id :internal_id, + scope: :project, + init: AtomicInternalId.project_init(self, :internal_id) + + scope :including_relationships, -> { includes(:latest_metrics, :params, :user, :package, :project) } scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection + scope :order_by_metric, ->(metric, direction) do subquery = Ml::CandidateMetric.latest.where(name: metric) column_expression = Arel::Table.new('latest')[:value] @@ -46,40 +55,30 @@ module Ml ) end - delegate :project_id, :project, to: :experiment + alias_attribute :artifact, :package + alias_attribute :iid, :internal_id + + delegate :package_name, to: :experiment def artifact_root "/#{package_name}/#{package_version}/" end - def artifact - artifact_lazy&.itself - end - - def artifact_lazy - BatchLoader.for(id).batch do |candidate_ids, loader| - Packages::Package - .joins("INNER JOIN ml_candidates ON packages_packages.name=(concat('#{PACKAGE_PREFIX}', ml_candidates.id))") - .where(ml_candidates: { id: candidate_ids }) - .find_each do |package| - loader.call(package.name.delete_prefix(PACKAGE_PREFIX).to_i, package) - end - end - end - - def package_name - "#{PACKAGE_PREFIX}#{id}" - end - def package_version - '-' + iid end class << self + def with_project_id_and_eid(project_id, eid) + return unless project_id.present? && eid.present? + + find_by(project_id: project_id, eid: eid) + end + def with_project_id_and_iid(project_id, iid) return unless project_id.present? && iid.present? - joins(:experiment).find_by(experiment: { project_id: project_id }, iid: iid) + find_by(project_id: project_id, internal_id: iid) end end end diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index 7bb80a170c5..d1277efac7b 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -4,6 +4,8 @@ module Ml class Experiment < ApplicationRecord include AtomicInternalId + PACKAGE_PREFIX = 'ml_experiment_' + validates :name, :project, presence: true validates :name, uniqueness: { scope: :project, message: "should be unique in the project" } @@ -20,6 +22,10 @@ module Ml has_internal_id :iid, scope: :project + def package_name + "#{PACKAGE_PREFIX}#{iid}" + end + class << self def by_project_id_and_iid(project_id, iid) find_by(project_id: project_id, iid: iid) @@ -32,6 +38,20 @@ module Ml def by_project_id(project_id) where(project_id: project_id).order(id: :desc) end + + def package_for_experiment?(package_name) + return false unless package_name&.starts_with?(PACKAGE_PREFIX) + + iid = package_name.delete_prefix(PACKAGE_PREFIX) + + numeric?(iid) + end + + private + + def numeric?(value) + value.match?(/\A\d+\z/) + end end end end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index aeb4d7a5694..3ac585a6957 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -13,6 +13,7 @@ class NamespaceSetting < ApplicationRecord enum enabled_git_access_protocol: { all: 0, ssh: 1, http: 2 }, _suffix: true validates :enabled_git_access_protocol, inclusion: { in: enabled_git_access_protocols.keys } + validates :code_suggestions, allow_nil: false, inclusion: { in: [true, false] } validate :allow_mfa_for_group validate :allow_resource_access_token_creation_for_group diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb index 2a2ea11ddc5..cf2612b7f33 100644 --- a/app/models/namespaces/project_namespace.rb +++ b/app/models/namespaces/project_namespace.rb @@ -11,6 +11,8 @@ module Namespaces alias_attribute :namespace_id, :parent_id has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace + delegate :execute_hooks, :execute_integrations, to: :project, allow_nil: true + def self.sti_name 'Project' end diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 0fae66b18ca..9006f104c64 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -117,15 +117,7 @@ module Namespaces traversal_ids.present? end - def use_traversal_ids_for_root_ancestor? - return false unless Feature.enabled?(:use_traversal_ids_for_root_ancestor) - - traversal_ids.present? - end - def root_ancestor - return super unless use_traversal_ids_for_root_ancestor? - strong_memoize(:root_ancestor) do if association(:parent).loaded? && parent.present? # This case is possible when parent has not been persisted or we're inside a transaction. @@ -133,7 +125,7 @@ module Namespaces elsif parent_id.nil? # There is no parent, so we are the root ancestor. self - elsif traversal_ids.present? + else Namespace.find_by(id: traversal_ids.first) end end diff --git a/app/models/note.rb b/app/models/note.rb index b9b884b88c5..13fff9520b7 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -173,6 +173,14 @@ class Note < ApplicationRecord end scope :with_metadata, -> { includes(:system_note_metadata) } + scope :without_hidden, -> { + if Feature.enabled?(:hidden_notes) + where_not_exists(Users::BannedUser.where('notes.author_id = banned_users.user_id')) + else + all + end + } + scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) } scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb index 0966a9f2912..afbd671f82e 100644 --- a/app/models/onboarding/completion.rb +++ b/app/models/onboarding/completion.rb @@ -13,7 +13,10 @@ module Onboarding :issue_created, :git_write, :merge_request_created, - :user_added + :user_added, + :license_scanning_run, + :secure_dependency_scanning_run, + :secure_dast_run ].freeze def initialize(project, current_user = nil) @@ -58,26 +61,10 @@ module Onboarding def action_columns [:code_added] + - tracked_actions.map { |action_key| ::Onboarding::Progress.column_name(action_key) } + ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) } end strong_memoize_attr :action_columns - def tracked_actions - ACTION_PATHS + deploy_section_tracked_actions - end - - def deploy_section_tracked_actions - experiment( - :security_actions_continuous_onboarding, - namespace: namespace, - user: current_user, - sticky_to: current_user - ) do |e| - e.control { [:security_scan_enabled] } - e.candidate { [:license_scanning_run, :secure_dependency_scanning_run, :secure_dast_run] } - end.run - end - attr_reader :project, :namespace, :current_user end end diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb index 887a5695530..2b8d0a4f51e 100644 --- a/app/models/packages/debian.rb +++ b/app/models/packages/debian.rb @@ -12,6 +12,8 @@ module Packages EMPTY_FILE_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'.freeze + INCOMING_PACKAGE_NAME = 'incoming' + def self.table_name_prefix 'packages_debian_' end diff --git a/app/models/packages/debian/file_metadatum.rb b/app/models/packages/debian/file_metadatum.rb index 77ce8e265ff..325ae0c468e 100644 --- a/app/models/packages/debian/file_metadatum.rb +++ b/app/models/packages/debian/file_metadatum.rb @@ -1,59 +1,69 @@ # frozen_string_literal: true -class Packages::Debian::FileMetadatum < ApplicationRecord - self.primary_key = :package_file_id +module Packages + module Debian + class FileMetadatum < ApplicationRecord + include UpdatedAtFilterable - belongs_to :package_file, inverse_of: :debian_file_metadatum + self.primary_key = :package_file_id - validates :package_file, presence: true - validate :valid_debian_package_type + belongs_to :package_file, inverse_of: :debian_file_metadatum - enum file_type: { - unknown: 1, source: 2, dsc: 3, deb: 4, udeb: 5, buildinfo: 6, changes: 7, ddeb: 8 - } + validates :package_file, presence: true + validate :valid_debian_package_type - validates :file_type, presence: true - validates :file_type, inclusion: { in: %w[unknown] }, - if: -> { package_file&.package&.debian_incoming? || package_file&.package&.processing? } - validates :file_type, - inclusion: { in: %w[source dsc deb udeb buildinfo changes ddeb] }, - if: -> { package_file&.package&.debian_package? && !package_file&.package&.processing? } + enum file_type: { + unknown: 1, source: 2, dsc: 3, deb: 4, udeb: 5, buildinfo: 6, changes: 7, ddeb: 8 + } - validates :component, - presence: true, - format: { with: Gitlab::Regex.debian_component_regex }, - if: :requires_component? - validates :component, absence: true, unless: :requires_component? + validates :file_type, presence: true + validates :file_type, inclusion: { in: %w[unknown] }, + if: -> { package_file&.package&.debian_incoming? || package_file&.package&.processing? } + validates :file_type, + inclusion: { in: %w[source dsc deb udeb buildinfo changes ddeb] }, + if: -> { package_file&.package&.debian_package? && !package_file&.package&.processing? } - validates :architecture, - presence: true, - format: { with: Gitlab::Regex.debian_architecture_regex }, - if: :requires_architecture? - validates :architecture, absence: true, unless: :requires_architecture? + validates :component, + presence: true, + format: { with: Gitlab::Regex.debian_component_regex }, + if: :requires_component? + validates :component, absence: true, unless: :requires_component? - validates :fields, - presence: true, - json_schema: { filename: "debian_fields" }, - if: :requires_fields? - validates :fields, absence: true, unless: :requires_fields? + validates :architecture, + presence: true, + format: { with: Gitlab::Regex.debian_architecture_regex }, + if: :requires_architecture? + validates :architecture, absence: true, unless: :requires_architecture? - private + validates :fields, + presence: true, + json_schema: { filename: "debian_fields" }, + if: :requires_fields? + validates :fields, absence: true, unless: :requires_fields? - def valid_debian_package_type - return if package_file&.package&.debian? + scope :with_file_type, ->(file_type) do + where(file_type: file_type) + end - errors.add(:package_file, _('Package type must be Debian')) - end + private - def requires_architecture? - deb? || udeb? || ddeb? - end + def valid_debian_package_type + return if package_file&.package&.debian? - def requires_component? - source? || dsc? || requires_architecture? || buildinfo? - end + errors.add(:package_file, _('Package type must be Debian')) + end + + def requires_architecture? + deb? || udeb? || ddeb? + end + + def requires_component? + source? || dsc? || requires_architecture? || buildinfo? + end - def requires_fields? - dsc? || requires_architecture? || buildinfo? || changes? + def requires_fields? + dsc? || requires_architecture? || buildinfo? || changes? + end + end end end diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb index bb2c33594e5..d93c22adcda 100644 --- a/app/models/packages/event.rb +++ b/app/models/packages/event.rb @@ -1,61 +1,60 @@ # frozen_string_literal: true -class Packages::Event < ApplicationRecord - belongs_to :package, optional: true - - UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package pull_symbol_package push_symbol_package].freeze - EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001, dependency_proxy: 1002).freeze - - EVENT_PREFIX = "i_package" - - enum event_scope: EVENT_SCOPES - - enum event_type: { - push_package: 0, - delete_package: 1, - pull_package: 2, - search_package: 3, - list_package: 4, - list_repositories: 5, - delete_repository: 6, - delete_tag: 7, - delete_tag_bulk: 8, - list_tags: 9, - cli_metadata: 10, - pull_symbol_package: 11, - push_symbol_package: 12, - pull_manifest: 13, - pull_manifest_from_cache: 14, - pull_blob: 15, - pull_blob_from_cache: 16 - } - - enum originator_type: { user: 0, deploy_token: 1, guest: 2 } - - # Remove some of the events, for now, so we don't hammer Redis too hard. - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770 - def self.event_allowed?(event_type) - return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym) - - false - end - - # counter names for unique user tracking (for MAU) - def self.unique_counters_for(event_scope, event_type, originator_type) - return [] unless event_allowed?(event_type) - return [] if originator_type.to_s == 'guest' - - ["#{EVENT_PREFIX}_#{event_scope}_#{originator_type}"] - end - - # total counter names for tracking number of events - def self.counters_for(event_scope, event_type, originator_type) - return [] unless event_allowed?(event_type) - - [ - "#{EVENT_PREFIX}_#{event_type}", - "#{EVENT_PREFIX}_#{event_type}_by_#{originator_type}", - "#{EVENT_PREFIX}_#{event_scope}_#{event_type}" - ] +module Packages + class Event + UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package pull_symbol_package push_symbol_package].freeze + EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001, dependency_proxy: 1002).freeze + + EVENT_PREFIX = "i_package" + + EVENT_TYPES = %i[ + push_package + delete_package + pull_package + search_package + list_package + list_repositories + delete_repository + delete_tag + delete_tag_bulk + list_tags + create_tag + cli_metadata + pull_symbol_package + push_symbol_package + pull_manifest + pull_manifest_from_cache + pull_blob + pull_blob_from_cache + ].freeze + + ORIGINATOR_TYPES = %i[user deploy_token guest].freeze + + # Remove some of the events, for now, so we don't hammer Redis too hard. + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770 + def self.event_allowed?(event_type) + return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym) + + false + end + + # counter names for unique user tracking (for MAU) + def self.unique_counters_for(event_scope, event_type, originator_type) + return [] unless event_allowed?(event_type) + return [] if originator_type.to_s == 'guest' + + ["#{EVENT_PREFIX}_#{event_scope}_#{originator_type}"] + end + + # total counter names for tracking number of events + def self.counters_for(event_scope, event_type, originator_type) + return [] unless event_allowed?(event_type) + + [ + "#{EVENT_PREFIX}_#{event_type}", + "#{EVENT_PREFIX}_#{event_type}_by_#{originator_type}", + "#{EVENT_PREFIX}_#{event_scope}_#{event_type}" + ] + end end end diff --git a/app/models/packages/npm/metadata_cache.rb b/app/models/packages/npm/metadata_cache.rb new file mode 100644 index 00000000000..2d116f2e9c0 --- /dev/null +++ b/app/models/packages/npm/metadata_cache.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Packages + module Npm + class MetadataCache < ApplicationRecord + belongs_to :project, inverse_of: :npm_metadata_caches + + validates :file, :package_name, :project, :size, presence: true + validates :package_name, uniqueness: { scope: :project_id } + validates :package_name, format: { with: Gitlab::Regex.package_name_regex } + validates :package_name, format: { with: Gitlab::Regex.npm_package_name_regex } + end + end +end diff --git a/app/models/packages/npm/metadatum.rb b/app/models/packages/npm/metadatum.rb index 7388c4bdbd2..a856cd7225f 100644 --- a/app/models/packages/npm/metadatum.rb +++ b/app/models/packages/npm/metadatum.rb @@ -9,6 +9,8 @@ class Packages::Npm::Metadatum < ApplicationRecord validate :ensure_npm_package_type validate :ensure_package_json_size + scope :package_id_in, ->(package_ids) { where(package_id: package_ids) } + private def ensure_npm_package_type diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 970538b45e7..a8eb990b914 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -31,6 +31,8 @@ class Packages::Package < ApplicationRecord belongs_to :project belongs_to :creator, class_name: 'User' + after_create_commit :publish_creation_event, if: :generic? + # package_files must be destroyed by ruby code in order to properly remove carrierwave uploads and update project statistics has_many :package_files, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent # TODO: put the installable default scope on the :package_files association once the dependent: :destroy is removed @@ -70,9 +72,8 @@ class Packages::Package < ApplicationRecord scope: %i[project_id version package_type], conditions: -> { not_pending_destruction } }, - unless: -> { pending_destruction? || conan? || debian_package? } + unless: -> { pending_destruction? || conan? } - validate :unique_debian_package_name, if: :debian_package? validate :valid_conan_package_recipe, if: :conan? validate :valid_composer_global_name, if: :composer? validate :npm_package_already_taken, if: :npm? @@ -84,7 +85,7 @@ class Packages::Package < ApplicationRecord validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget? validates :name, format: { with: Gitlab::Regex.terraform_module_package_name_regex }, if: :terraform_module? validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package? - validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming? + validates :name, inclusion: { in: [Packages::Debian::INCOMING_PACKAGE_NAME] }, if: :debian_incoming? validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget? validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } @@ -222,6 +223,12 @@ class Packages::Package < ApplicationRecord find_by!(name: name, version: version) end + def self.existing_debian_packages_with(name:, version:) + debian.with_name(name) + .with_version(version) + .not_pending_destruction + end + def self.pluck_names pluck(:name) end @@ -353,6 +360,18 @@ class Packages::Package < ApplicationRecord end end + def publish_creation_event + ::Gitlab::EventStore.publish( + ::Packages::PackageCreatedEvent.new(data: { + project_id: project_id, + id: id, + name: name, + version: version, + package_type: package_type + }) + ) + end + private def composer_tag_version? @@ -404,19 +423,6 @@ class Packages::Package < ApplicationRecord project.root_namespace.path == ::Packages::Npm.scope_of(name) end - def unique_debian_package_name - return unless debian_publication&.distribution - - package_exists = debian_publication.distribution.packages - .with_name(name) - .with_version(version) - .not_pending_destruction - .id_not_in(id) - .exists? - - errors.add(:base, _('Debian package already exists in Distribution')) if package_exists - end - def forbidden_debian_changes return unless persisted? diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index e1486c11298..c164d150bce 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -85,6 +85,13 @@ class Packages::PackageFile < ApplicationRecord .where(packages_debian_file_metadata: { architecture: architecture_name }) end + scope :with_debian_unknown_since, ->(updated_before) do + file_metadata = Packages::Debian::FileMetadatum.with_file_type(:unknown) + .updated_before(updated_before) + .where('packages_package_files.id = packages_debian_file_metadata.package_file_id') + where('EXISTS (?)', file_metadata.select(1)) + end + scope :with_conan_package_reference, ->(conan_package_reference) do joins(:conan_file_metadatum) .where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference }) diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 222cde19da7..864ea04c019 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -54,12 +54,19 @@ module Pages end strong_memoize_attr :prefix - def unique_domain + def unique_host return unless project.project_setting.pages_unique_domain_enabled? - project.project_setting.pages_unique_domain + project.pages_unique_host end - strong_memoize_attr :unique_domain + strong_memoize_attr :unique_host + + def root_directory + return unless deployment + + deployment.root_directory + end + strong_memoize_attr :root_directory private diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index da6ef035c54..fa29cbf8352 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -4,6 +4,7 @@ class PagesDeployment < ApplicationRecord include EachBatch include FileStoreMounter + include Gitlab::Utils::StrongMemoize MIGRATED_FILE_NAME = "_migrated.zip" @@ -28,15 +29,29 @@ class PagesDeployment < ApplicationRecord mount_file_store_uploader ::Pages::DeploymentUploader + skip_callback :save, :after, :store_file!, if: :store_after_commit? + after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit? + def migrated? file.filename == MIGRATED_FILE_NAME end + def store_after_commit? + Feature.enabled?(:pages_deploy_upload_file_outside_transaction, project) + end + strong_memoize_attr :store_after_commit? + private def set_size self.size = file.size end + + def store_file_after_commit! + return unless previous_changes.key?(:file) + + store_file_now! + end end PagesDeployment.prepend_mod diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 2e613768873..3ebb2126f4d 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -41,8 +41,8 @@ class PersonalAccessToken < ApplicationRecord scope :for_users, -> (users) { where(user: users) } scope :preload_users, -> { preload(:user) } scope :order_expires_at_asc_id_desc, -> { reorder(expires_at: :asc, id: :desc) } - scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) } - scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) } + scope :project_access_token, -> { includes(:user).references(:user).merge(User.project_bot) } + scope :owner_is_human, -> { includes(:user).references(:user).merge(User.human) } scope :last_used_before, -> (date) { where("last_used_at <= ?", date) } scope :last_used_after, -> (date) { where("last_used_at >= ?", date) } diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb index 2a3175be420..7ee0ec0ca43 100644 --- a/app/models/preloaders/labels_preloader.rb +++ b/app/models/preloaders/labels_preloader.rb @@ -20,25 +20,31 @@ module Preloaders def preload_all ActiveRecord::Associations::Preloader.new( - records: labels, - associations: { parent_container: :route } - ).call - - ActiveRecord::Associations::Preloader.new( - records: labels.select { |l| l.is_a? ProjectLabel }, + records: project_labels, associations: { project: [:project_feature, namespace: :route] } ).call ActiveRecord::Associations::Preloader.new( - records: labels.select { |l| l.is_a? GroupLabel }, + records: group_labels, associations: { group: :route } ).call + Preloaders::UserMaxAccessLevelInProjectsPreloader.new(project_labels.map(&:project), user).execute labels.each do |label| label.lazy_subscription(user) label.lazy_subscription(user, project) if project.present? end end + + private + + def group_labels + @group_labels ||= labels.select { |l| l.is_a? GroupLabel } + end + + def project_labels + @project_labels ||= labels.select { |l| l.is_a? ProjectLabel } + end end end diff --git a/app/models/preloaders/runner_machine_policy_preloader.rb b/app/models/preloaders/runner_machine_policy_preloader.rb deleted file mode 100644 index 52864eeba8d..00000000000 --- a/app/models/preloaders/runner_machine_policy_preloader.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Preloaders - class RunnerMachinePolicyPreloader - def initialize(runner_machines, current_user) - @runner_machines = runner_machines - @current_user = current_user - end - - def execute - return if runner_machines.is_a?(ActiveRecord::NullRelation) - - ActiveRecord::Associations::Preloader.new( - records: runner_machines, - associations: [:runner] - ).call - end - - private - - attr_reader :runner_machines, :current_user - end -end diff --git a/app/models/preloaders/runner_manager_policy_preloader.rb b/app/models/preloaders/runner_manager_policy_preloader.rb new file mode 100644 index 00000000000..788a3d25a87 --- /dev/null +++ b/app/models/preloaders/runner_manager_policy_preloader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Preloaders + class RunnerManagerPolicyPreloader + def initialize(runner_managers, current_user) + @runner_managers = runner_managers + @current_user = current_user + end + + def execute + return if runner_managers.is_a?(ActiveRecord::NullRelation) + + ActiveRecord::Associations::Preloader.new( + records: runner_managers, + associations: [:runner] + ).call + end + + private + + attr_reader :runner_managers, :current_user + end +end diff --git a/app/models/preloaders/users_max_access_level_by_project_preloader.rb b/app/models/preloaders/users_max_access_level_by_project_preloader.rb new file mode 100644 index 00000000000..37842665e7d --- /dev/null +++ b/app/models/preloaders/users_max_access_level_by_project_preloader.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Preloaders + # This class preloads the max access level (role) for the users within the given projects and + # stores the values in requests store via the ProjectTeam class. + class UsersMaxAccessLevelByProjectPreloader + include Gitlab::Utils::StrongMemoize + + def initialize(project_users:) + @project_users = project_users.transform_values { |users| Array.wrap(users) } + end + + def execute + return unless @project_users.present? + + all_users = @project_users.values.flatten.uniq + preload_users_namespace_bans(all_users) + + @project_users.each do |project, users| + users.each do |user| + access_level = access_levels.fetch([project.id, user.id], Gitlab::Access::NO_ACCESS) + project.team.write_member_access_for_user_id(user.id, access_level) + end + end + end + + private + + def access_levels + query = ProjectAuthorization.none + + @project_users.each do |project, users| + query = query.or( + ProjectAuthorization + .where(project_id: project.id, user_id: users.map(&:id)) + ) + end + + query + .group(:project_id, :user_id) + .maximum(:access_level) + end + strong_memoize_attr :access_levels + + def preload_users_namespace_bans(_users) + # overridden in EE + end + end +end + +Preloaders::UsersMaxAccessLevelByProjectPreloader.prepend_mod 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 deleted file mode 100644 index f32184f168d..00000000000 --- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module Preloaders - # This class preloads the max access level (role) for the users within the given projects and - # stores the values in requests store via the ProjectTeam class. - class UsersMaxAccessLevelInProjectsPreloader - def initialize(projects:, users:) - @projects = projects - @users = users - end - - 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] - - project.team.write_member_access_for_user_id(user_id, access_level) - end - end - - private - - def access_levels - ProjectAuthorization - .where(project_id: project_ids, user_id: user_ids) - .group(:project_id, :user_id) - .maximum(:access_level) - end - - # Use reselect to override the existing select to prevent - # the error `subquery has too many columns` - # NotificationsController passes in an Array so we need to check the type - def project_ids - @projects.is_a?(ActiveRecord::Relation) ? @projects.reselect(:id) : @projects - end - - def user_ids - @users.is_a?(ActiveRecord::Relation) ? @users.reselect(:id) : @users - end - - 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 cb218c0a49f..146747eb57a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -42,6 +42,7 @@ class Project < ApplicationRecord include BlocksUnsafeSerialization include Subquery include IssueParent + include UpdatedAtFilterable extend Gitlab::Cache::RequestCache extend Gitlab::Utils::Override @@ -168,6 +169,7 @@ class Project < ApplicationRecord alias_method :parent, :namespace alias_attribute :parent_id, :namespace_id + has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event' has_many :boards @@ -222,6 +224,7 @@ class Project < ApplicationRecord has_one :zentao_integration, class_name: 'Integrations::Zentao' has_one :wiki_repository, class_name: 'Projects::WikiRepository', inverse_of: :project + has_one :design_management_repository, class_name: 'DesignManagement::Repository', inverse_of: :project has_one :root_of_fork_network, foreign_key: 'root_project_id', inverse_of: :root_project, @@ -258,6 +261,8 @@ class Project < ApplicationRecord has_many :debian_distributions, class_name: 'Packages::Debian::ProjectDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :npm_metadata_caches, + class_name: 'Packages::Npm::MetadataCache' has_one :packages_cleanup_policy, class_name: 'Packages::Cleanup::Policy', inverse_of: :project @@ -275,6 +280,7 @@ class Project < ApplicationRecord has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting' has_one :service_desk_setting, class_name: 'ServiceDeskSetting' has_one :service_desk_custom_email_verification, class_name: 'ServiceDesk::CustomEmailVerification' + has_one :service_desk_custom_email_credential, class_name: 'ServiceDesk::CustomEmailCredential' # Merge requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -491,6 +497,7 @@ class Project < ApplicationRecord to: :project_setting, allow_nil: true delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?, + :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=, to: :project_setting delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting @@ -499,7 +506,7 @@ class Project < ApplicationRecord delegate :previous_default_branch, :previous_default_branch=, to: :project_setting delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true - delegate :add_member, :add_members, to: :team + delegate :add_member, :add_members, :member?, 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, to: :namespace, allow_nil: true @@ -1579,7 +1586,7 @@ class Project < ApplicationRecord end def new_issuable_address(author, address_type) - return unless Gitlab::IncomingEmail.supports_issue_creation? && author + return unless Gitlab::Email::IncomingEmail.supports_issue_creation? && author # check since this can come from a request parameter return unless %w(issue merge_request).include?(address_type) @@ -1590,7 +1597,7 @@ class Project < ApplicationRecord # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue@localhost.com # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-merge-request@localhost.com - Gitlab::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}") + Gitlab::Email::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}") end def build_commit_note(commit) @@ -1624,7 +1631,7 @@ class Project < ApplicationRecord end def external_issue_reference_pattern - external_issue_tracker.class.reference_pattern(only_long: issues_enabled?) + external_issue_tracker.reference_pattern(only_long: issues_enabled?) end def default_issues_tracker? @@ -1664,9 +1671,7 @@ class Project < ApplicationRecord end def disabled_integrations - disabled_integrations = [] - disabled_integrations << 'google_play' unless Feature.enabled?(:google_play_integration, self) - disabled_integrations + [] end def find_or_initialize_integration(name) @@ -2060,7 +2065,7 @@ class Project < ApplicationRecord end def group_runners - @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_group_of_project(self.id) : Ci::Runner.none + @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_groups_of_project(self.id) : Ci::Runner.none end def all_runners @@ -2160,6 +2165,10 @@ class Project < ApplicationRecord pages_url_for(project_setting.pages_unique_domain) end + def pages_unique_host + URI(pages_unique_url).host + end + def pages_namespace_url pages_url_for(pages_subdomain) end @@ -2237,7 +2246,7 @@ class Project < ApplicationRecord wiki.repository.expire_content_cache DetectRepositoryLanguagesWorker.perform_async(id) - ProjectCacheWorker.perform_async(self.id, [], [:repository_size]) + ProjectCacheWorker.perform_async(self.id, [], [:repository_size, :wiki_size]) AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(id) enqueue_record_project_target_platforms @@ -2399,6 +2408,8 @@ class Project < ApplicationRecord .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) .append(key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s) .append(key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol) + .append(key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s) + .append(key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s) .append(key: 'CI_SERVER_NAME', value: 'GitLab') .append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) .append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) @@ -2419,6 +2430,7 @@ class Project < ApplicationRecord def api_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_API_V4_URL', value: API::Helpers::Version.new('v4').root_url) + variables.append(key: 'CI_API_GRAPHQL_URL', value: Gitlab::Routing.url_helpers.api_graphql_url) end end @@ -2832,13 +2844,17 @@ class Project < ApplicationRecord end def all_protected_branches - if Feature.enabled?(:group_protected_branches, group) + if allow_protected_branches_for_group? @all_protected_branches ||= ProtectedBranch.from_union([protected_branches, group_protected_branches]) else protected_branches end end + def allow_protected_branches_for_group? + Feature.enabled?(:group_protected_branches, group) || Feature.enabled?(:allow_protected_branches_for_group, group) + end + def self_monitoring? Gitlab::CurrentSettings.self_monitoring_project_id == id end @@ -2891,11 +2907,11 @@ class Project < ApplicationRecord end def service_desk_custom_address - return unless Gitlab::ServiceDeskEmail.enabled? + return unless Gitlab::Email::ServiceDeskEmail.enabled? key = service_desk_setting&.project_key || default_service_desk_suffix - Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") + Gitlab::Email::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") end def default_service_desk_suffix @@ -3083,6 +3099,10 @@ class Project < ApplicationRecord pending_delete? || hidden? end + def content_editor_on_issues_feature_flag_enabled? + group&.content_editor_on_issues_feature_flag_enabled? || Feature.enabled?(:content_editor_on_issues, self) + end + def work_items_feature_flag_enabled? group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self) end @@ -3142,10 +3162,16 @@ class Project < ApplicationRecord false end + def crm_enabled? + return false unless group + + group.crm_enabled? + end + private def pages_unique_domain_enabled? - Feature.enabled?(:pages_unique_domain) && + Feature.enabled?(:pages_unique_domain, self) && project_setting.pages_unique_domain_enabled? end diff --git a/app/models/project_label.rb b/app/models/project_label.rb index dc647901b46..05d7b7429ff 100644 --- a/app/models/project_label.rb +++ b/app/models/project_label.rb @@ -23,6 +23,10 @@ class ProjectLabel < Label super(project, target_project: target_project, format: format, full: full) end + def preloaded_parent_container + association(:project).loaded? ? project : parent_container + end + private def title_must_not_exist_at_group_level diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 379b94b3af5..6a60015cc26 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -65,6 +65,10 @@ class ProjectSetting < ApplicationRecord end strong_memoize_attr :show_diff_preview_in_email? + def runner_registration_enabled + Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && read_attribute(:runner_registration_enabled) + end + private def validates_mr_default_target_self diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 5641fbfb867..dd200aec807 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -121,7 +121,7 @@ class ProjectTeam target_project = project source_members = source_project.project_members.to_a - target_user_ids = target_project.project_members.pluck(:user_id) + target_user_ids = target_project.project_members.pluck_user_ids source_members.reject! do |member| # Skip if user already present in team diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index ffffa803011..e64892dfa03 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -12,6 +12,13 @@ class ProjectWiki < Wiki container.disk_path + '.wiki' end + override :create_wiki_repository + def create_wiki_repository + super + + track_wiki_repository + end + override :after_wiki_activity def after_wiki_activity # Update activity columns, this is done synchronously to avoid @@ -28,6 +35,16 @@ class ProjectWiki < Wiki # the activity columns for Git pushes as well. after_wiki_activity end + + private + + def track_wiki_repository + return unless ::Gitlab::Database.read_write? + return if container.wiki_repository + + # This is the ActiveRecord auto-generated method for a Project's has_one :wiki_repository + container.create_wiki_repository! + end end # TODO: Remove this once we implement ES support for group wikis. diff --git a/app/models/projects/data_transfer.rb b/app/models/projects/data_transfer.rb index faab0bb6db2..c7f5132fbc7 100644 --- a/app/models/projects/data_transfer.rb +++ b/app/models/projects/data_transfer.rb @@ -13,6 +13,14 @@ module Projects belongs_to :namespace scope :current_month, -> { where(date: beginning_of_month) } + scope :with_project_between_dates, ->(project, from, to) { + where(project: project, date: from..to) + } + scope :with_namespace_between_dates, ->(namespace, from, to) { + where(namespace: namespace, date: from..to) + .group(:date, :namespace_id) + .order(date: :desc) + } counter_attribute :repository_egress, returns_current: true counter_attribute :artifacts_egress, returns_current: true diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 22eaac94897..01bdbba1955 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -8,6 +8,8 @@ class ProtectedBranch < ApplicationRecord belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches validate :validate_either_project_or_top_group + validates :name, presence: true + validates :name, uniqueness: { scope: [:project_id, :namespace_id] }, if: :name_changed? scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } scope :allowing_force_push, -> { where(allow_force_push: true) } @@ -43,7 +45,7 @@ class ProtectedBranch < ApplicationRecord end def self.allow_force_push?(project, ref_name) - if Feature.enabled?(:group_protected_branches, project.group) + if allow_protected_branches_for_group?(project.group) protected_branches = project.all_protected_branches.matching(ref_name) project_protected_branches, group_protected_branches = protected_branches.partition(&:project_id) @@ -58,6 +60,10 @@ class ProtectedBranch < ApplicationRecord end end + def self.allow_protected_branches_for_group?(group) + Feature.enabled?(:group_protected_branches, group) || Feature.enabled?(:allow_protected_branches_for_group, group) + end + def self.any_protected?(project, ref_names) protected_refs(project).any? do |protected_ref| ref_names.any? do |ref_name| diff --git a/app/models/repository.rb b/app/models/repository.rb index 587b71315c2..b4a0eaf0324 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -199,7 +199,7 @@ class Repository def list_commits_by(query, ref, author: nil, before: nil, after: nil, limit: 1000) return [] unless exists? return [] unless has_visible_content? - return [] unless query.present? && ref.present? + return [] unless ref.present? commits = raw_repository.list_commits_by( query, ref, author: author, before: before, after: after, limit: limit).map do |c| diff --git a/app/models/resource_events/issue_assignment_event.rb b/app/models/resource_events/issue_assignment_event.rb new file mode 100644 index 00000000000..b24f181bc48 --- /dev/null +++ b/app/models/resource_events/issue_assignment_event.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ResourceEvents + class IssueAssignmentEvent < ApplicationRecord + self.table_name = :issue_assignment_events + + belongs_to :user, optional: true + belongs_to :issue + + validates :issue, presence: true + + enum action: { add: 1, remove: 2 } + end +end diff --git a/app/models/resource_events/merge_request_assignment_event.rb b/app/models/resource_events/merge_request_assignment_event.rb new file mode 100644 index 00000000000..898594b7008 --- /dev/null +++ b/app/models/resource_events/merge_request_assignment_event.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ResourceEvents + class MergeRequestAssignmentEvent < ApplicationRecord + self.table_name = :merge_request_assignment_events + + belongs_to :user, optional: true + belongs_to :merge_request + + validates :merge_request, presence: true + + enum action: { add: 1, remove: 2 } + end +end diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index f3301ee2051..61129bbc9d8 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -4,6 +4,9 @@ class ResourceMilestoneEvent < ResourceTimeboxEvent belongs_to :milestone scope :include_relations, -> { includes(:user, milestone: [:project, :group]) } + scope :aliased_for_timebox_report, -> do + select("'timebox' AS event_type", "id", "created_at", "milestone_id AS value", "action", "issue_id") + end # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states) diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 134f71e35ad..e2ac762b1cd 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -13,6 +13,10 @@ class ResourceStateEvent < ResourceEvent after_create :issue_usage_metrics + scope :aliased_for_timebox_report, -> do + select("'state' AS event_type", "id", "created_at", "state AS value", "NULL AS action", "issue_id") + end + def self.issuable_attrs %i(issue merge_request).freeze end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 1a0a65df6a3..8a3449e8f7c 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -16,8 +16,6 @@ class SentNotification < ApplicationRecord validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid - ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' - after_save :keep_around_commit, if: :for_commit? class << self diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb new file mode 100644 index 00000000000..8ccdd6f2261 --- /dev/null +++ b/app/models/service_desk/custom_email_credential.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ServiceDesk + class CustomEmailCredential < ApplicationRecord + attr_encrypted :smtp_username, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32, + encode: false, + encode_iv: false + attr_encrypted :smtp_password, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32, + encode: false, + encode_iv: false + + belongs_to :project + + validates :project, presence: true + + validates :smtp_address, + presence: true, + length: { maximum: 255 }, + hostname: { allow_numeric_hostname: true } + validate :validate_smtp_address + + validates :smtp_port, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :smtp_username, + presence: true, + length: { maximum: 255 } + validates :smtp_password, + presence: true, + length: { minimum: 8, maximum: 128 } + + delegate :service_desk_setting, to: :project + + def delivery_options + { + user_name: smtp_username, + password: smtp_password, + address: smtp_address, + domain: Mail::Address.new(service_desk_setting.custom_email).domain, + port: smtp_port || 587 + } + end + + private + + def validate_smtp_address + # Addressable::URI always needs a scheme otherwise it interprets the host as the path + Gitlab::UrlBlocker.validate!("smtp://#{smtp_address}", + schemes: %w[smtp], + ascii_only: true, + enforce_sanitization: true, + allow_localhost: false, + allow_local_network: false + ) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + errors.add(:smtp_address, e) + end + end +end diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb index 69afb445734..4216ad7e70f 100644 --- a/app/models/service_desk_setting.rb +++ b/app/models/service_desk_setting.rb @@ -2,16 +2,19 @@ class ServiceDeskSetting < ApplicationRecord include Gitlab::Utils::StrongMemoize + include IgnorableColumns CUSTOM_EMAIL_VERIFICATION_SUBADDRESS = '+verify' + ignore_columns %i[ + custom_email_smtp_address + custom_email_smtp_port + custom_email_smtp_username + encrypted_custom_email_smtp_password + encrypted_custom_email_smtp_password_iv + ], remove_with: '16.1', remove_after: '2023-05-22' + attribute :custom_email_enabled, default: false - attr_encrypted :custom_email_smtp_password, - mode: :per_attribute_iv, - algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32, - encode: false, - encode_iv: false belongs_to :project @@ -20,48 +23,32 @@ class ServiceDeskSetting < ApplicationRecord validate :valid_project_key validates :outgoing_name, length: { maximum: 255 }, allow_blank: true validates :project_key, - length: { maximum: 255 }, - allow_blank: true, - format: { with: /\A[a-z0-9_]+\z/, message: -> (setting, data) { _("can contain only lowercase letters, digits, and '_'.") } } + length: { maximum: 255 }, + allow_blank: true, + format: { with: /\A[a-z0-9_]+\z/, message: -> (setting, data) { _("can contain only lowercase letters, digits, and '_'.") } } validates :custom_email, - length: { maximum: 255 }, - uniqueness: true, - allow_nil: true, - format: /\A[\w\-._]+@[\w\-.]+\.{1}[a-zA-Z]{2,}\z/ - validates :custom_email_smtp_address, length: { maximum: 255 } - validates :custom_email_smtp_username, length: { maximum: 255 } - + length: { maximum: 255 }, + uniqueness: true, + allow_nil: true, + format: /\A[\w\-._]+@[\w\-.]+\.{1}[a-zA-Z]{2,}\z/ + + validates :custom_email_credential, + presence: true, + if: :needs_custom_email_credentials? validates :custom_email, - presence: true, - devise_email: true, - if: :needs_custom_email_smtp_credentials? - validates :custom_email_smtp_address, - presence: true, - hostname: { allow_numeric_hostname: true, require_valid_tld: true }, - if: :needs_custom_email_smtp_credentials? - validates :custom_email_smtp_username, - presence: true, - if: :needs_custom_email_smtp_credentials? - validates :custom_email_smtp_port, - presence: true, - numericality: { only_integer: true, greater_than: 0 }, - if: :needs_custom_email_smtp_credentials? + presence: true, + devise_email: true, + if: :needs_custom_email_credentials? scope :with_project_key, ->(key) { where(project_key: key) } - def custom_email_verification - project&.service_desk_custom_email_verification + def custom_email_credential + project&.service_desk_custom_email_credential end - def custom_email_delivery_options - { - user_name: custom_email_smtp_username, - password: custom_email_smtp_password, - address: custom_email_smtp_address, - domain: Mail::Address.new(custom_email).domain, - port: custom_email_smtp_port || 587 - } + def custom_email_verification + project&.service_desk_custom_email_verification end def custom_email_address_for_verification @@ -116,7 +103,7 @@ class ServiceDeskSetting < ApplicationRecord end end - def needs_custom_email_smtp_credentials? + def needs_custom_email_credentials? custom_email_enabled? || custom_email_verification.present? end end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 8a207c891e2..93c128c989c 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -8,6 +8,8 @@ module Terraform HEX_REGEXP = %r{\A\h+\z}.freeze UUID_LENGTH = 32 + self.locking_column = :activerecord_lock_version + belongs_to :project belongs_to :locked_by_user, class_name: 'User' diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index d6a16ad5b99..6727c81f17b 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -5,7 +5,7 @@ module Terraform include EachBatch include FileStoreMounter - belongs_to :terraform_state, class_name: 'Terraform::State', optional: false + belongs_to :terraform_state, class_name: 'Terraform::State', optional: false, touch: true belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :build, class_name: 'Ci::Build', optional: true, foreign_key: :ci_build_id diff --git a/app/models/todo.rb b/app/models/todo.rb index 62252912c32..ac41b5d0b2c 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -76,7 +76,7 @@ class Todo < ApplicationRecord scope :for_target, -> (id) { where(target_id: id) } scope :for_commit, -> (id) { where(commit_id: id) } scope :with_entity_associations, -> do - preload(:target, :author, :note, group: :route, project: [:route, { namespace: [:route, :owner] }, :project_setting]) + preload(:target, :author, :note, group: :route, project: [:route, :group, { namespace: [:route, :owner] }, :project_setting]) end scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) } scope :for_internal_notes, -> { joins(:note).where(note: { confidential: true }) } diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index ba6c1ee6af1..81415eb383b 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -5,9 +5,6 @@ class U2fRegistration < ApplicationRecord belongs_to :user - after_create :create_webauthn_registration - after_update :update_webauthn_registration, if: :saved_change_to_counter? - def self.register(user, app_id, params, challenges) u2f = U2F::U2F.new(app_id) registration = self.new @@ -43,25 +40,4 @@ class U2fRegistration < ApplicationRecord rescue JSON::ParserError, NoMethodError, ArgumentError, U2F::Error false end - - private - - def create_webauthn_registration - converter = Gitlab::Auth::U2fWebauthnConverter.new(self) - WebauthnRegistration.create!(converter.convert) - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, u2f_registration_id: self.id) - end - - def update_webauthn_registration - # When we update the sign count of this registration - # we need to update the sign count of the corresponding webauthn registration - # as well if it exists already - WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid) - &.update_attribute(:counter, counter) - end - - def webauthn_credential_xid - Base64.strict_encode64(Base64.urlsafe_decode64(key_handle)) - end end diff --git a/app/models/user.rb b/app/models/user.rb index 3bd8a035357..96223ac5027 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,7 +9,6 @@ class User < ApplicationRecord include Gitlab::SQL::Pattern include AfterCommitQueue include Avatarable - include Awareness include Referable include Sortable include CaseSensitivity @@ -220,6 +219,7 @@ class User < ApplicationRecord has_many :abuse_reports, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :abuse_trust_scores, class_name: 'Abuse::TrustScore', foreign_key: :user_id has_many :builds, class_name: 'Ci::Build' has_many :pipelines, class_name: 'Ci::Pipeline' has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -266,6 +266,8 @@ class User < ApplicationRecord has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :issue_assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :merge_request_assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail' has_many :user_achievements, class_name: 'Achievements::UserAchievement', inverse_of: :user @@ -355,6 +357,7 @@ class User < ApplicationRecord :time_format_in_24h, :time_format_in_24h=, :show_whitespace_in_diffs, :show_whitespace_in_diffs=, :view_diffs_file_by_file, :view_diffs_file_by_file=, + :pass_user_identities_to_ci_jwt, :pass_user_identities_to_ci_jwt=, :tab_width, :tab_width=, :sourcegraph_enabled, :sourcegraph_enabled=, :gitpod_enabled, :gitpod_enabled=, @@ -366,6 +369,8 @@ class User < ApplicationRecord :diffs_addition_color, :diffs_addition_color=, :use_legacy_web_ide, :use_legacy_web_ide=, :use_new_navigation, :use_new_navigation=, + :pinned_nav_items, :pinned_nav_items=, + :achievements_enabled, :achievements_enabled=, to: :user_preference delegate :path, to: :namespace, allow_nil: true, prefix: true @@ -923,6 +928,17 @@ class User < ApplicationRecord end end + def llm_bot + email_pattern = "llm-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u| + u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content' + u.name = 'GitLab LLM Bot' + u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot + u.confirmed_at = Time.zone.now + end + end + def admin_bot email_pattern = "admin-bot%s@#{Settings.gitlab.host}" @@ -1074,8 +1090,6 @@ class User < ApplicationRecord end def two_factor_webauthn_enabled? - return false unless Feature.enabled?(:webauthn) - (webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?) end @@ -1703,10 +1717,11 @@ class User < ApplicationRecord def forkable_namespaces strong_memoize(:forkable_namespaces) do personal_namespace = Namespace.where(id: namespace_id) + groups_allowing_project_creation = Groups::AcceptingProjectCreationsFinder.new(self).execute Namespace.from_union( [ - manageable_groups(include_groups_with_developer_maintainer_access: true), + groups_allowing_project_creation, personal_namespace ]) end @@ -1972,7 +1987,7 @@ class User < ApplicationRecord end def enabled_incoming_email_token - incoming_email_token if Gitlab::IncomingEmail.supports_issue_creation? + incoming_email_token if Gitlab::Email::IncomingEmail.supports_issue_creation? end def sync_attribute?(attribute) @@ -2198,6 +2213,10 @@ class User < ApplicationRecord namespace_commit_emails.find_by(namespace: project.root_namespace) end + def trust_scores_for_source(source) + abuse_trust_scores.where(source: source) + end + protected # override, from Devise::Validatable diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index bc2c6b526b8..2519db825c0 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -24,6 +24,10 @@ class UserPreference < ApplicationRecord allow_blank: true validates :use_legacy_web_ide, allow_nil: false, inclusion: { in: [true, false] } + validates :pass_user_identities_to_ci_jwt, allow_nil: false, inclusion: { in: [true, false] } + + validates :pinned_nav_items, json_schema: { filename: 'pinned_nav_items' } + ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT } diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb index c73b3a4ee71..e7229abd147 100644 --- a/app/models/users/project_callout.rb +++ b/app/models/users/project_callout.rb @@ -15,7 +15,8 @@ module Users storage_enforcement_banner_first_enforcement_threshold: 4, # EE-only storage_enforcement_banner_second_enforcement_threshold: 5, # EE-only storage_enforcement_banner_third_enforcement_threshold: 6, # EE-only - storage_enforcement_banner_fourth_enforcement_threshold: 7 # EE-only + storage_enforcement_banner_fourth_enforcement_threshold: 7, # EE-only + license_check_deprecation_alert: 8 # EE-only } validates :project, presence: true diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 8bb598ee316..650e8942132 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -7,6 +7,8 @@ class Vulnerability < ApplicationRecord alias_attribute :vulnerability_id, :id + scope :with_projects, -> { includes(:project) } + def self.link_reference_pattern nil end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index a7cd522f023..10476339ca9 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -67,6 +67,16 @@ class WorkItem < Issue end end + # Returns widget object if available + # type parameter can be a symbol, for example, `:description`. + def get_widget(type) + widgets.find do |widget| + widget.instance_of?(WorkItems::Widgets.const_get(type.to_s.camelize, false)) + end + rescue NameError + nil + end + def ancestors hierarchy.ancestors(hierarchy_order: :asc) end @@ -130,6 +140,75 @@ class WorkItem < Issue ::Gitlab::WorkItems::WorkItemHierarchy.new(base, options: options) end + + override :allowed_work_item_type_change + def allowed_work_item_type_change + return unless work_item_type_id_changed? + + child_links = WorkItems::ParentLink.for_parents(id) + parent_link = ::WorkItems::ParentLink.find_by(work_item: self) + + validate_parent_restrictions(parent_link) + validate_child_restrictions(child_links) + validate_depth(parent_link, child_links) + end + + def validate_parent_restrictions(parent_link) + return unless parent_link + + parent_link.work_item.work_item_type_id = work_item_type_id + + unless parent_link.valid? + errors.add( + :work_item_type_id, + format( + _('cannot be changed to %{new_type} with %{parent_type} as parent type.'), + new_type: work_item_type.name, parent_type: parent_link.work_item_parent.work_item_type.name + ) + ) + end + end + + def validate_child_restrictions(child_links) + return if child_links.empty? + + child_type_ids = child_links.joins(:work_item).select(self.class.arel_table[:work_item_type_id]).distinct + restrictions = ::WorkItems::HierarchyRestriction.where( + parent_type_id: work_item_type_id, + child_type_id: child_type_ids + ) + + # We expect a restriction for every child type + if restrictions.size < child_type_ids.size + errors.add( + :work_item_type_id, + format(_('cannot be changed to %{new_type} with these child item types.'), new_type: work_item_type.name) + ) + end + end + + def validate_depth(parent_link, child_links) + restriction = ::WorkItems::HierarchyRestriction.find_by_parent_type_id_and_child_type_id( + work_item_type_id, + work_item_type_id + ) + return unless restriction&.maximum_depth + + children_with_new_type = self.class.where(id: child_links.select(:work_item_id)) + .where(work_item_type_id: work_item_type_id) + max_child_depth = ::Gitlab::WorkItems::WorkItemHierarchy.new(children_with_new_type).max_descendants_depth.to_i + + ancestor_depth = + if parent_link&.work_item_parent && parent_link.work_item_parent.work_item_type_id == work_item_type_id + parent_link.work_item_parent.same_type_base_and_ancestors.count + else + 0 + end + + if max_child_depth + ancestor_depth > restriction.maximum_depth - 1 + errors.add(:work_item_type_id, _('reached maximum depth')) + end + end end WorkItem.prepend_mod diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index 21e31980fda..5dff9e8e8d5 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -41,6 +41,10 @@ module WorkItems def relative_positioning_parent_column :work_item_parent_id end + + def for_work_item(work_item) + find_or_initialize_by(work_item: work_item) + end end private diff --git a/app/models/work_items/resource_link_event.rb b/app/models/work_items/resource_link_event.rb new file mode 100644 index 00000000000..64d51b2743c --- /dev/null +++ b/app/models/work_items/resource_link_event.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module WorkItems + class ResourceLinkEvent < ResourceEvent + belongs_to :child_work_item, class_name: 'WorkItem' + + validates :child_work_item, presence: true + + enum action: { + add: 1, + remove: 2 + } + end +end diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb index 9e8c421d740..763b1a79069 100644 --- a/app/models/work_items/widget_definition.rb +++ b/app/models/work_items/widget_definition.rb @@ -29,7 +29,9 @@ module WorkItems status: 11, # EE-only requirement_legacy: 12, # EE-only test_reports: 13, # EE-only - notifications: 14 + notifications: 14, + current_user_todos: 15, + award_emoji: 16 } def self.available_widgets diff --git a/app/models/work_items/widgets/award_emoji.rb b/app/models/work_items/widgets/award_emoji.rb new file mode 100644 index 00000000000..3c862d7c267 --- /dev/null +++ b/app/models/work_items/widgets/award_emoji.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class AwardEmoji < Base + delegate :award_emoji, :downvotes, :upvotes, to: :work_item + end + end +end diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb index 3a5b03bd514..b54b84f1e1b 100644 --- a/app/models/work_items/widgets/base.rb +++ b/app/models/work_items/widgets/base.rb @@ -15,6 +15,12 @@ module WorkItems [] end + def self.callback_class + Issuable::Callbacks.const_get(name.demodulize, false) + rescue NameError + nil + end + def type self.class.type end diff --git a/app/models/work_items/widgets/current_user_todos.rb b/app/models/work_items/widgets/current_user_todos.rb new file mode 100644 index 00000000000..61c4fcb453b --- /dev/null +++ b/app/models/work_items/widgets/current_user_todos.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class CurrentUserTodos < Base + end + end +end diff --git a/app/policies/achievements/user_achievement_policy.rb b/app/policies/achievements/user_achievement_policy.rb index b500d0a25c8..05650a05490 100644 --- a/app/policies/achievements/user_achievement_policy.rb +++ b/app/policies/achievements/user_achievement_policy.rb @@ -3,5 +3,10 @@ module Achievements class UserAchievementPolicy < ::BasePolicy delegate { @subject.achievement.namespace } + delegate { @subject.user } + + rule { can?(:read_user_profile) | can?(:admin_achievement) }.enable :read_user_achievement + + rule { ~can?(:read_achievement) }.prevent :read_user_achievement end end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 1ce866bd910..7c745c5731f 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -39,6 +39,10 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:automation_bot) { @user&.automation_bot? } + desc "User is llm bot" + with_options scope: :user, score: 0 + condition(:llm_bot) { @user&.llm_bot? } + desc "User email is unconfirmed or user account is locked" with_options scope: :user, score: 0 condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? } @@ -63,7 +67,7 @@ class BasePolicy < DeclarativePolicy::Base end rule { admin }.policy do - # Only for actual administrator accounts, behaviour affected by admin mode application setting + # Only for actual administrator accounts, behavior affected by admin mode application setting enable :admin_all_resources # Policy extended in EE to also enable auditors enable :read_all_resources diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index ca0b51e1385..fc154e6b465 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -71,6 +71,10 @@ module Ci can?(:developer_access, @subject.project) end + # Use admin_ci_minutes for detailed quota and usage reporting + # this is limited to total usage and total quota for a builds namespace + rule { can_read_project_build }.enable :read_ci_minutes_limited_summary + rule { can_read_project_build }.enable :read_build_trace rule { debug_mode & ~project_update_build }.prevent :read_build_trace diff --git a/app/policies/ci/runner_machine_policy.rb b/app/policies/ci/runner_manager_policy.rb index 9893d7dee14..43e81e373fc 100644 --- a/app/policies/ci/runner_machine_policy.rb +++ b/app/policies/ci/runner_manager_policy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class RunnerMachinePolicy < BasePolicy + class RunnerManagerPolicy < BasePolicy with_options scope: :subject, score: 0 condition(:can_read_runner, scope: :subject) do @@ -12,7 +12,7 @@ module Ci rule { can_read_runner }.policy do enable :read_builds - enable :read_runner_machine + enable :read_runner_manager end end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index b64e7e16433..09e41e0bfbf 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -121,11 +121,11 @@ class GlobalPolicy < BasePolicy enable :approve_user enable :reject_user enable :read_usage_trends_measurement - enable :create_instance_runners + enable :create_instance_runner end rule { ~create_runner_workflow_enabled }.policy do - prevent :create_instance_runners + prevent :create_instance_runner end # We can't use `read_statistics` because the user may have different permissions for different projects diff --git a/app/policies/group_label_policy.rb b/app/policies/group_label_policy.rb index 4a848e44fec..08d811d3dfa 100644 --- a/app/policies/group_label_policy.rb +++ b/app/policies/group_label_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class GroupLabelPolicy < BasePolicy - delegate { @subject.parent_container } + delegate { @subject.preloaded_parent_container } end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index ee1140b8405..1f8e003b09a 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -165,7 +165,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :developer_access enable :admin_crm_organization enable :admin_crm_contact - enable :read_cluster + enable :read_cluster # Deprecated as certificate-based cluster integration (`Clusters::Cluster`). + enable :read_cluster_agent enable :read_group_all_available_runners enable :use_k8s_proxies end @@ -190,6 +191,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :destroy_package enable :admin_package enable :create_projects + enable :import_projects enable :admin_pipeline enable :admin_build enable :add_cluster @@ -213,7 +215,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_group_runners enable :admin_group_runners enable :register_group_runners - enable :create_group_runners + enable :create_runner enable :set_note_created_at enable :set_emails_disabled @@ -260,14 +262,20 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy end.enable :change_share_with_group_lock rule { developer & developer_maintainer_access }.enable :create_projects - rule { create_projects_disabled }.prevent :create_projects + rule { create_projects_disabled }.policy do + prevent :create_projects + prevent :import_projects + end rule { owner | admin }.policy do enable :owner_access enable :read_statistics end - rule { maintainer & can?(:create_projects) }.enable :transfer_projects + rule { maintainer & can?(:create_projects) }.policy do + enable :transfer_projects + enable :import_projects + end rule { read_package_registry_deploy_token }.policy do enable :read_package @@ -324,7 +332,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy rule { ~admin & ~group_runner_registration_allowed }.policy do prevent :register_group_runners - prevent :create_group_runners + prevent :create_runner end rule { migration_bot }.policy do @@ -341,7 +349,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy end rule { ~create_runner_workflow_enabled }.policy do - prevent :create_group_runners + prevent :create_runner end # Should be matched with ProjectPolicy#read_internal_note diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 496708a9737..c9b936f9b06 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class IssuablePolicy < BasePolicy - delegate { @subject.project } + delegate { subject_container } condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? } - condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) } + condition(:is_project_member) { @user && subject_container.member?(@user) } condition(:can_read_issuable) { can?(:"read_#{@subject.to_ability_name}") } desc "User is the assignee or author" @@ -57,6 +57,10 @@ class IssuablePolicy < BasePolicy enable :read_issuable enable :read_issuable_participables end + + def subject_container + @subject.project || @subject.try(:namespace) + end end IssuablePolicy.prepend_mod_with('IssuablePolicy') diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 804709ed072..538959c92bd 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -14,8 +14,8 @@ class IssuePolicy < IssuablePolicy desc "Project belongs to a group, crm is enabled and user can read contacts in the root group" condition(:can_read_crm_contacts, scope: :subject) do - subject.project.group&.crm_enabled? && - (@user&.can?(:read_crm_contact, @subject.project.root_ancestor) || @user&.support_bot?) + subject_container&.crm_enabled? && + (@user&.can?(:read_crm_contact, subject_container.root_ancestor) || @user&.support_bot?) end desc "Issue is confidential" @@ -43,6 +43,7 @@ class IssuePolicy < IssuablePolicy rule { confidential & ~can_read_confidential }.policy do prevent(*create_read_update_admin_destroy(:issue)) + prevent(*create_read_update_admin_destroy(:work_item)) prevent :read_issue_iid end diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb index bfb1706bc5a..2214839fb62 100644 --- a/app/policies/namespaces/group_project_namespace_shared_policy.rb +++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb @@ -17,5 +17,16 @@ module Namespaces rule { can?(:reporter_access) }.policy do enable :read_timelog_category end + + rule { can?(:guest_access) }.policy do + enable :create_work_item + enable :read_work_item + enable :read_issue + enable :read_namespace + end + + rule { can?(:create_work_item) }.enable :create_task end end + +Namespaces::GroupProjectNamespaceSharedPolicy.prepend_mod diff --git a/app/policies/project_label_policy.rb b/app/policies/project_label_policy.rb index 6656d5990a5..3b125429510 100644 --- a/app/policies/project_label_policy.rb +++ b/app/policies/project_label_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class ProjectLabelPolicy < BasePolicy - delegate { @subject.parent_container } + delegate { @subject.preloaded_parent_container } end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e2daa8b88a7..30958757011 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -222,8 +222,8 @@ class ProjectPolicy < BasePolicy condition(:"#{f}_disabled", score: 32) { !access_allowed_to?(f.to_sym) } end - condition(:project_runner_registration_allowed) do - Gitlab::CurrentSettings.valid_runner_registrars.include?('project') + condition(:project_runner_registration_allowed, scope: :subject) do + Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && @subject.runner_registration_enabled end condition :registry_enabled do @@ -242,6 +242,8 @@ class ProjectPolicy < BasePolicy Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace) end + condition(:namespace_catalog_available) { namespace_catalog_available? } + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -261,7 +263,6 @@ class ProjectPolicy < BasePolicy enable :reporter_access enable :developer_access enable :maintainer_access - enable :add_catalog_resource enable :change_namespace enable :change_visibility_level @@ -279,9 +280,6 @@ class ProjectPolicy < BasePolicy enable :set_show_default_award_emojis enable :set_show_diff_preview_in_email enable :set_warn_about_potentially_unwanted_characters - - enable :register_project_runners - enable :create_project_runners enable :manage_owners end @@ -354,7 +352,6 @@ class ProjectPolicy < BasePolicy enable :metrics_dashboard enable :read_confidential_issues enable :read_package - enable :read_product_analytics enable :read_ci_cd_analytics enable :read_external_emails enable :read_grafana @@ -464,7 +461,8 @@ class ProjectPolicy < BasePolicy enable :destroy_environment enable :create_deployment enable :update_deployment - enable :read_cluster + enable :read_cluster # Deprecated as certificate-based cluster integration (`Clusters::Cluster`). + enable :read_cluster_agent enable :use_k8s_proxies enable :create_release enable :update_release @@ -537,7 +535,9 @@ class ProjectPolicy < BasePolicy enable :destroy_freeze_period enable :admin_feature_flags_client enable :register_project_runners - enable :create_project_runners + enable :create_runner + enable :admin_project_runners + enable :read_project_runners enable :update_runners_registration_token enable :admin_project_google_cloud enable :admin_project_aws @@ -844,7 +844,7 @@ class ProjectPolicy < BasePolicy rule { ~admin & ~project_runner_registration_allowed }.policy do prevent :register_project_runners - prevent :create_project_runners + prevent :create_runner end rule { can?(:admin_project_member) }.policy do @@ -870,12 +870,20 @@ class ProjectPolicy < BasePolicy end rule { ~create_runner_workflow_enabled }.policy do - prevent :create_project_runners + prevent :create_runner end # Should be matched with GroupPolicy#read_internal_note rule { admin | can?(:reporter_access) }.enable :read_internal_note + rule { can?(:developer_access) & namespace_catalog_available }.policy do + enable :read_namespace_catalog + end + + rule { can?(:owner_access) & namespace_catalog_available }.policy do + enable :add_catalog_resource + end + private def user_is_user? @@ -969,6 +977,10 @@ class ProjectPolicy < BasePolicy def project @subject end + + def namespace_catalog_available? + false + end end ProjectPolicy.prepend_mod_with('ProjectPolicy') diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index b8f0be9b4c5..e11c1a39757 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -25,10 +25,12 @@ class ProjectSnippetPolicy < BasePolicy # is used to hide/show various snippet-related controls, so we can't just # move all of the handling here. rule do - all?(private_snippet | (internal_snippet & external_user), - ~project.guest, - ~is_author, - ~can?(:read_all_resources)) + all?( + private_snippet | (internal_snippet & external_user), + ~project.guest, + ~is_author, + ~can?(:read_all_resources) + ) end.prevent :read_snippet rule { internal_snippet & ~is_author & ~admin & ~project.maintainer }.policy do diff --git a/app/presenters/ml/candidates_csv_presenter.rb b/app/presenters/ml/candidates_csv_presenter.rb new file mode 100644 index 00000000000..8e2baf6bd28 --- /dev/null +++ b/app/presenters/ml/candidates_csv_presenter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Ml + class CandidatesCsvPresenter + CANDIDATE_ASSOCIATIONS = [:latest_metrics, :params, :experiment].freeze + # This file size limit is mainly to avoid the generation to hog resources from the server. The value is arbitrary + # can be update once we have better insight into usage. + TARGET_FILESIZE = 2.megabytes + + def initialize(candidates) + @candidates = candidates + end + + def present + CsvBuilder.new(@candidates, headers, CANDIDATE_ASSOCIATIONS).render(TARGET_FILESIZE) + end + + private + + def headers + metric_names = columns_names(&:metrics) + param_names = columns_names(&:params) + + candidate_to_metrics = @candidates.to_h do |candidate| + [candidate.id, candidate.latest_metrics.to_h { |m| [m.name, m.value] }] + end + + candidate_to_params = @candidates.to_h do |candidate| + [candidate.id, candidate.params.to_h { |m| [m.name, m.value] }] + end + + { + project_id: 'project_id', + experiment_iid: ->(c) { c.experiment.iid }, + candidate_iid: 'internal_id', + name: 'name', + external_id: 'eid', + start_time: 'start_time', + end_time: 'end_time', + **param_names.index_with { |name| ->(c) { candidate_to_params.dig(c.id, name) } }, + **metric_names.index_with { |name| ->(c) { candidate_to_metrics.dig(c.id, name) } } + } + end + + def columns_names(&selector) + @candidates.flat_map(&selector).map(&:name).uniq + end + end +end diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb index 57bdd373309..42f61182ab8 100644 --- a/app/presenters/packages/npm/package_presenter.rb +++ b/app/presenters/packages/npm/package_presenter.rb @@ -3,94 +3,25 @@ module Packages module Npm class PackagePresenter - include API::Helpers::RelatedResourcesHelpers - - # Allowed fields are those defined in the abbreviated form - # defined here: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object - # except: name, version, dist, dependencies and xDependencies. Those are generated by this presenter. - PACKAGE_JSON_ALLOWED_FIELDS = %w[deprecated bin directories dist engines _hasShrinkwrap].freeze - - attr_reader :name, :packages + def initialize(metadata) + @metadata = metadata + end - def initialize(name, packages) - @name = name - @packages = packages + def name + metadata[:name] end def versions - package_versions = {} - - packages.each_batch do |relation| - batched_packages = relation.including_dependency_links - .preload_files - .preload_npm_metadatum - - batched_packages.each do |package| - package_file = package.installable_package_files.last - - next unless package_file - - package_versions[package.version] = build_package_version(package, package_file) - end - end - - package_versions + metadata[:versions] end def dist_tags - build_package_tags.tap { |t| t["latest"] ||= sorted_versions.last } + metadata[:dist_tags] end private - def build_package_tags - package_tags.to_h { |tag| [tag.name, tag.package.version] } - end - - def build_package_version(package, package_file) - abbreviated_package_json(package).merge( - name: package.name, - version: package.version, - dist: { - shasum: package_file.file_sha1, - tarball: tarball_url(package, package_file) - } - ).tap do |package_version| - package_version.merge!(build_package_dependencies(package)) - end - end - - def tarball_url(package, package_file) - expose_url "#{api_v4_projects_path(id: package.project_id)}" \ - "/packages/npm/#{package.name}" \ - "/-/#{package_file.file_name}" - end - - def build_package_dependencies(package) - dependencies = Hash.new { |h, key| h[key] = {} } - - package.dependency_links.each do |dependency_link| - dependency = dependency_link.dependency - dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern - end - - dependencies - end - - def sorted_versions - versions = packages.pluck_versions.compact - VersionSorter.sort(versions) - end - - def package_tags - Packages::Tag.for_package_ids(packages.last_of_each_version_ids) - .preload_package - end - - def abbreviated_package_json(package) - json = package.npm_metadatum&.package_json || {} - json.slice(*PACKAGE_JSON_ALLOWED_FIELDS) - end + attr_reader :metadata end end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index c02f3021069..856eba5aadc 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -182,7 +182,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated AnchorData.new( true, - statistic_icon('deployments') + + statistic_icon('rocket-launch') + n_('%{strong_start}%{release_count}%{strong_end} Release', '%{strong_start}%{release_count}%{strong_end} Releases', releases_count).html_safe % { release_count: number_with_delimiter(releases_count), strong_start: '<strong class="project-stat-value">'.html_safe, diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb index d7d959217b0..91e67c379c4 100644 --- a/app/presenters/search_service_presenter.rb +++ b/app/presenters/search_service_presenter.rb @@ -2,6 +2,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated include RendersCommits + include RendersProjectsList presents ::SearchService, as: :search_service @@ -28,6 +29,8 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated objects.respond_to?(:eager_load) ? objects.eager_load(:status) : objects # rubocop:disable CodeReuse/ActiveRecord when 'commits' prepare_commits_for_rendering(objects) + when 'projects' + prepare_projects_for_rendering(objects) else objects end diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb index a550763f0ff..54916d02ecb 100644 --- a/app/serializers/admin/abuse_report_entity.rb +++ b/app/serializers/admin/abuse_report_entity.rb @@ -2,15 +2,47 @@ module Admin class AbuseReportEntity < Grape::Entity + include RequestAwareEntity + include MarkupHelper + expose :category + expose :created_at expose :updated_at expose :reported_user do |report| - UserEntity.represent(report.user, only: [:name]) + UserEntity.represent(report.user, only: [:name, :created_at]) end expose :reporter do |report| UserEntity.represent(report.reporter, only: [:name]) end + + expose :reported_user_path do |report| + user_path(report.user) + end + + expose :reporter_path do |report| + user_path(report.reporter) + end + + expose :user_blocked do |report| + report.user.blocked? + end + + expose :block_user_path do |report| + block_admin_user_path(report.user) + end + + expose :remove_report_path do |report| + admin_abuse_report_path(report) + end + + expose :remove_user_and_report_path do |report| + admin_abuse_report_path(report, remove_user: true) + end + + expose :message do |report| + markdown_field(report, :message) + end end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 9b21fc57b9e..a34f329e9ec 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -74,8 +74,7 @@ class BuildDetailsEntity < Ci::JobEntity end expose :path do |build| - project_merge_request_path(build.merge_request.project, - build.merge_request) + project_merge_request_path(build.merge_request.project, build.merge_request) end end diff --git a/app/serializers/deploy_keys/basic_deploy_key_entity.rb b/app/serializers/deploy_keys/basic_deploy_key_entity.rb index 9184bc5f0ce..4a3dd3c8f08 100644 --- a/app/serializers/deploy_keys/basic_deploy_key_entity.rb +++ b/app/serializers/deploy_keys/basic_deploy_key_entity.rb @@ -10,6 +10,7 @@ module DeployKeys expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned expose :almost_orphaned?, as: :almost_orphaned expose :created_at + expose :expires_at expose :updated_at expose :can_edit expose :user, as: :owner, using: ::API::Entities::UserBasic, if: -> (_, opts) { can_read_owner?(opts) } diff --git a/app/serializers/detailed_status_entity.rb b/app/serializers/detailed_status_entity.rb index ed8ac9f40f7..1f1a805af67 100644 --- a/app/serializers/detailed_status_entity.rb +++ b/app/serializers/detailed_status_entity.rb @@ -35,7 +35,7 @@ class DetailedStatusEntity < Grape::Entity expose :favicon, documentation: { type: 'string', example: '/assets/ci_favicons/favicon_status_success.png' } do |status| - Gitlab::Favicon.status_overlay(status.favicon) + Gitlab::Favicon.ci_status_overlay(status.favicon) end expose :action, if: -> (status, _) { status.has_action? } do diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index aa43b9861d3..97ab9c83d71 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -55,7 +55,19 @@ class DiffFileEntity < DiffFileBaseEntity end # Used for inline diffs - expose :diff_lines_for_serializer, as: :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? } + expose :diff_lines_for_serializer, as: :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { display_highlighted_diffs?(diff_file, options) } + + expose :viewer do |diff_file, options| + whitespace_only = if !display_highlighted_diffs?(diff_file, options) + nil + elsif whitespace_only_change?(diff_file) + true + else + false + end + + DiffViewerEntity.represent diff_file.viewer, options.merge(whitespace_only: whitespace_only) + end expose :fully_expanded?, as: :is_fully_expanded @@ -68,6 +80,19 @@ class DiffFileEntity < DiffFileBaseEntity private + def whitespace_only_change?(diff_file) + !diff_file.collapsed? && + diff_file.diff_lines_for_serializer.nil? && + ( + diff_file.added_lines != 0 || + diff_file.removed_lines != 0 + ) + end + + def display_highlighted_diffs?(diff_file, options) + inline_diff_view?(options) && diff_file.text? + end + def parallel_diff_view?(options) diff_view(options) == :parallel end diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb index 45faca6cb2f..8ff9d9612c6 100644 --- a/app/serializers/diff_viewer_entity.rb +++ b/app/serializers/diff_viewer_entity.rb @@ -5,4 +5,7 @@ class DiffViewerEntity < Grape::Entity expose :render_error, as: :error expose :render_error_message, as: :error_message expose :collapsed?, as: :collapsed + expose :whitespace_only, if: ->(_, _) { Feature.enabled?(:add_ignore_all_white_spaces) } do |_, options| + options[:whitespace_only] + end end diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 46d5a488aea..21ffdce155f 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -35,8 +35,10 @@ class EnvironmentSerializer < BaseSerializer def itemize(resource) items = resource.order('folder ASC') .group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)') - .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS folder', - 'COUNT(*) AS size', 'MAX(id) AS last_id') + .select( + 'COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS folder', + 'COUNT(*) AS size', 'MAX(id) AS last_id' + ) # It makes a difference when you call `paginate` method, because # although `page` is effective at the end, it calls counting methods diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb index d3b38a24316..4f90eeaa92b 100644 --- a/app/serializers/error_tracking/detailed_error_entity.rb +++ b/app/serializers/error_tracking/detailed_error_entity.rb @@ -3,29 +3,29 @@ module ErrorTracking class DetailedErrorEntity < Grape::Entity expose :count, - :culprit, - :external_base_url, - :external_url, - :first_release_last_commit, - :first_release_short_version, - :gitlab_commit, - :gitlab_commit_path, - :first_seen, - :frequency, - :gitlab_issue, - :id, - :last_release_last_commit, - :last_release_short_version, - :last_seen, - :message, - :project_id, - :project_name, - :project_slug, - :short_id, - :status, - :tags, - :title, - :type, - :user_count + :culprit, + :external_base_url, + :external_url, + :first_release_last_commit, + :first_release_short_version, + :gitlab_commit, + :gitlab_commit_path, + :first_seen, + :frequency, + :gitlab_issue, + :id, + :last_release_last_commit, + :last_release_short_version, + :last_seen, + :message, + :project_id, + :project_name, + :project_slug, + :short_id, + :status, + :tags, + :title, + :type, + :user_count end end diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb index 997abb0f148..c305e53eacf 100644 --- a/app/serializers/fork_namespace_entity.rb +++ b/app/serializers/fork_namespace_entity.rb @@ -6,7 +6,7 @@ class ForkNamespaceEntity < Grape::Entity include MarkupHelper expose :id, :name, :description, :visibility, :full_name, - :created_at, :updated_at, :avatar_url + :created_at, :updated_at, :avatar_url expose :fork_path do |namespace, options| project_forks_path(options[:project], namespace_key: namespace.id) diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb index 08070c03bf8..669ade079e1 100644 --- a/app/serializers/group_child_entity.rb +++ b/app/serializers/group_child_entity.rb @@ -6,7 +6,7 @@ class GroupChildEntity < Grape::Entity include MarkupHelper expose :id, :name, :description, :visibility, :full_name, - :created_at, :updated_at, :avatar_url + :created_at, :updated_at, :avatar_url expose :type do |instance| type @@ -35,12 +35,10 @@ class GroupChildEntity < Grape::Entity # Project only attributes expose :last_activity_at, if: lambda { |instance| project? } - expose :star_count, :archived, - if: lambda { |_instance, _options| project? } + expose :star_count, :archived, if: lambda { |_instance, _options| project? } # Group only attributes - expose :children_count, :parent_id, - unless: lambda { |_instance, _options| project? } + expose :children_count, :parent_id, unless: lambda { |_instance, _options| project? } expose :subgroup_count, if: lambda { |group| access_group_counts?(group) } diff --git a/app/serializers/group_deploy_key_entity.rb b/app/serializers/group_deploy_key_entity.rb index c0bb0448a51..9e7be6de35d 100644 --- a/app/serializers/group_deploy_key_entity.rb +++ b/app/serializers/group_deploy_key_entity.rb @@ -7,6 +7,7 @@ class GroupDeployKeyEntity < Grape::Entity expose :fingerprint expose :fingerprint_sha256 expose :created_at + expose :expires_at expose :updated_at expose :group_deploy_keys_groups, using: GroupDeployKeysGroupEntity do |group_deploy_key| group_deploy_key.group_deploy_keys_groups_for_user(options[:user]) diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index ebd0f037160..c99a771bb11 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -57,9 +57,9 @@ class IssueBoardEntity < Grape::Entity end expose :issue_type, - as: :type, - format_with: :upcase, - documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" } + as: :type, + format_with: :upcase, + documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" } end IssueBoardEntity.prepend_mod_with('IssueBoardEntity') diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 340fd8803af..657af578c7f 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -99,9 +99,9 @@ class IssueEntity < IssuableEntity end expose :issue_type, - as: :type, - format_with: :upcase, - documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" } + as: :type, + format_with: :upcase, + documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" } end IssueEntity.prepend_mod_with('IssueEntity') diff --git a/app/serializers/linked_issue_entity.rb b/app/serializers/linked_issue_entity.rb index 4a28213fbac..8ed72472b6c 100644 --- a/app/serializers/linked_issue_entity.rb +++ b/app/serializers/linked_issue_entity.rb @@ -26,9 +26,9 @@ class LinkedIssueEntity < Grape::Entity end expose :issue_type, - as: :type, - format_with: :upcase, - documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" } + as: :type, + format_with: :upcase, + documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" } expose :relation_path diff --git a/app/serializers/merge_request_metrics_helper.rb b/app/serializers/merge_request_metrics_helper.rb index fb1769d0aa6..05333b1bef2 100644 --- a/app/serializers/merge_request_metrics_helper.rb +++ b/app/serializers/merge_request_metrics_helper.rb @@ -20,9 +20,11 @@ module MergeRequestMetricsHelper closed_event = merge_request.closed_event merge_event = merge_request.merge_event - MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at, - latest_closed_by: closed_event&.author, - merged_at: merge_event&.updated_at, - merged_by: merge_event&.author) + MergeRequest::Metrics.new( + latest_closed_at: closed_event&.updated_at, + latest_closed_by: closed_event&.author, + merged_at: merge_event&.updated_at, + merged_by: merge_event&.author + ) end end diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index 33079905ed2..a9c17402515 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -153,6 +153,19 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity end end + expose :favicon_overlay_path, + documentation: { type: 'string', + example: '/assets/ci_favicons/favicon_status_success.png' } do |merge_request| + if merge_request.state == 'merged' + status_name = "favicon_status_#{merge_request.state}" + Gitlab::Favicon.mr_status_overlay(status_name) + else + pipeline = merge_request.actual_head_pipeline + status = pipeline&.detailed_status(request.current_user) + Gitlab::Favicon.ci_status_overlay(status.favicon) if status + end + end + private delegate :current_user, to: :request diff --git a/app/serializers/rollout_status_entity.rb b/app/serializers/rollout_status_entity.rb index f432fe98289..467174ac6d3 100644 --- a/app/serializers/rollout_status_entity.rb +++ b/app/serializers/rollout_status_entity.rb @@ -14,5 +14,5 @@ class RolloutStatusEntity < Grape::Entity expose :completion, if: -> (rollout_status, _) { rollout_status.found? } expose :complete?, as: :is_completed, if: -> (rollout_status, _) { rollout_status.found? } expose :canary_ingress, using: RolloutStatuses::IngressEntity, expose_nil: false, - if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? } + if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? } end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index f278ccfce73..f8f5315d0d0 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -13,15 +13,11 @@ class StageEntity < Grape::Entity if: -> (_, opts) { opts[:grouped] }, with: JobGroupEntity - expose :latest_statuses, - if: -> (_, opts) { opts[:details] }, - with: Ci::JobEntity do |stage| + expose :latest_statuses, if: -> (_, opts) { opts[:details] }, with: Ci::JobEntity do |stage| latest_statuses end - expose :retried, - if: -> (_, opts) { opts[:retried] }, - with: Ci::JobEntity do |stage| + expose :retried, if: -> (_, opts) { opts[:retried] }, with: Ci::JobEntity do |stage| retried_statuses end diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb index 1a872274cbf..00b022b1c07 100644 --- a/app/serializers/test_case_entity.rb +++ b/app/serializers/test_case_entity.rb @@ -5,7 +5,7 @@ class TestCaseEntity < Grape::Entity expose :status, documentation: { type: 'string', example: 'success' } expose :name, default: "(No name)", - documentation: { type: 'string', example: 'Security Reports can create an auto-remediation MR' } + documentation: { type: 'string', example: 'Security Reports can create an auto-remediation MR' } expose :classname, documentation: { type: 'string', example: 'vulnerability_management_spec' } expose :file, documentation: { type: 'string', example: './spec/test_spec.rb' } expose :execution_time, documentation: { type: 'integer', example: 180 } diff --git a/app/services/achievements/award_service.rb b/app/services/achievements/award_service.rb index 674bb8837fb..3cefb0442d5 100644 --- a/app/services/achievements/award_service.rb +++ b/app/services/achievements/award_service.rb @@ -22,6 +22,7 @@ module Achievements awarded_by_user: current_user) return error_awarding(user_achievement) unless user_achievement.persisted? + NotificationService.new.new_achievement_email(recipient, achievement).deliver_later ServiceResponse.success(payload: user_achievement) rescue ActiveRecord::RecordNotFound => e error(e.message) diff --git a/app/services/achievements/destroy_service.rb b/app/services/achievements/destroy_service.rb new file mode 100644 index 00000000000..3204adb8e89 --- /dev/null +++ b/app/services/achievements/destroy_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Achievements + class DestroyService + attr_reader :current_user, :achievement + + def initialize(current_user, achievement) + @current_user = current_user + @achievement = achievement + end + + def execute + return error_no_permissions unless allowed? + + achievement.delete + ServiceResponse.success(payload: achievement) + end + + private + + def allowed? + current_user&.can?(:admin_achievement, achievement) + end + + def error_no_permissions + error('You have insufficient permissions to delete this achievement') + end + + def error(message) + ServiceResponse.error(message: Array(message)) + end + end +end diff --git a/app/services/achievements/update_service.rb b/app/services/achievements/update_service.rb new file mode 100644 index 00000000000..dcadae8dc3b --- /dev/null +++ b/app/services/achievements/update_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Achievements + class UpdateService + attr_reader :current_user, :achievement, :params + + def initialize(current_user, achievement, params) + @current_user = current_user + @achievement = achievement + @params = params + end + + def execute + return error_no_permissions unless allowed? + + if achievement.update(params) + ServiceResponse.success(payload: achievement) + else + error_updating + end + end + + private + + def allowed? + current_user&.can?(:admin_achievement, achievement) + end + + def error_no_permissions + error('You have insufficient permission to update this achievement') + end + + def error(message) + ServiceResponse.error(payload: achievement, message: Array(message)) + end + + def error_updating + error(achievement&.errors&.full_messages || 'Failed to update achievement') + end + end +end diff --git a/app/services/branches/validate_new_service.rb b/app/services/branches/validate_new_service.rb index e45183d160f..0bee7ffaa66 100644 --- a/app/services/branches/validate_new_service.rb +++ b/app/services/branches/validate_new_service.rb @@ -29,3 +29,5 @@ module Branches end end end + +Branches::ValidateNewService.prepend_mod diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index ac019d9ec5b..4c9c59ac504 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# Entry point of the BulkImport feature. +# Entry point of the BulkImport/Direct Transfer feature. # This service receives a Gitlab Instance connection params -# and a list of groups to be imported. +# and a list of groups or projects to be imported. # # Process topography: # @@ -15,18 +15,24 @@ # P1 (sync) # # - Create a BulkImport record -# - Create a BulkImport::Entity for each group to be imported -# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities) +# - Create a BulkImport::Entity for each group or project (entities) to be imported +# - Enqueue a BulkImportWorker job (P2) to import the given entity # # Pn (async) # # - For each group to be imported (BulkImport::Entity.with_status(:created)) # - Import the group data # - Create entities for each subgroup of the imported group -# - Enqueue a BulkImports::CreateService job (Pn) to import the new entities (subgroups) -# +# - Create entities for each project of the imported group +# - Enqueue a BulkImportWorker job (Pn) to import the new entities + module BulkImports class CreateService + ENTITY_TYPES_MAPPING = { + 'group_entity' => 'groups', + 'project_entity' => 'projects' + }.freeze + attr_reader :current_user, :params, :credentials def initialize(current_user, params, credentials) @@ -40,7 +46,12 @@ module BulkImports bulk_import = create_bulk_import - Gitlab::Tracking.event(self.class.name, 'create', label: 'bulk_import_group') + Gitlab::Tracking.event( + self.class.name, + 'create', + label: 'bulk_import_group', + extra: { source_equals_destination: source_equals_destination? } + ) BulkImportWorker.perform_async(bulk_import.id) @@ -57,6 +68,7 @@ module BulkImports def validate! client.validate_instance_version! + validate_setting_enabled! client.validate_import_scopes! end @@ -73,6 +85,8 @@ module BulkImports Array.wrap(params).each do |entity_params| track_access_level(entity_params) + validate_destination_namespace(entity_params) + validate_destination_slug(entity_params[:destination_slug] || entity_params[:destination_name]) validate_destination_full_path(entity_params) BulkImports::Entity.create!( @@ -88,6 +102,28 @@ module BulkImports end end + def validate_setting_enabled! + source_full_path, source_type = Array.wrap(params)[0].values_at(:source_full_path, :source_type) + entity_type = ENTITY_TYPES_MAPPING.fetch(source_type) + if source_full_path =~ /^[0-9]+$/ + query = query_type(entity_type) + response = graphql_client.execute( + graphql_client.parse(query.to_s), + { full_path: source_full_path } + ).original_hash + + source_entity_identifier = ::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id + else + source_entity_identifier = ERB::Util.url_encode(source_full_path) + end + + client.get("/#{entity_type}/#{source_entity_identifier}/export_relations/status") + # the source instance will return a 404 if the feature is disabled as the endpoint won't be available + rescue Gitlab::HTTP::BlockedUrlError + rescue BulkImports::NetworkError + raise ::BulkImports::Error.setting_not_enabled + end + def track_access_level(entity_params) Gitlab::Tracking.event( self.class.name, @@ -98,6 +134,30 @@ module BulkImports ) end + def source_equals_destination? + credentials[:url].starts_with?(Settings.gitlab.base_url) + end + + def validate_destination_namespace(entity_params) + destination_namespace = entity_params[:destination_namespace] + source_type = entity_params[:source_type] + + return if destination_namespace.blank? + + group = Group.find_by_full_path(destination_namespace) + if group.nil? || + (source_type == 'group_entity' && !current_user.can?(:create_subgroup, group)) || + (source_type == 'project_entity' && !current_user.can?(:import_projects, group)) + raise BulkImports::Error.destination_namespace_validation_failure(destination_namespace) + end + end + + def validate_destination_slug(destination_slug) + return if destination_slug =~ Gitlab::Regex.oci_repository_path_regex + + raise BulkImports::Error.destination_slug_validation_failure + end + def validate_destination_full_path(entity_params) source_type = entity_params[:source_type] @@ -140,5 +200,20 @@ module BulkImports token: @credentials[:access_token] ) end + + def graphql_client + @graphql_client ||= BulkImports::Clients::Graphql.new( + url: @credentials[:url], + token: @credentials[:access_token] + ) + end + + def query_type(entity_type) + if entity_type == 'groups' + BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil) + else + BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil) + end + end end end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 4b62580e670..f2ace1f1590 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -45,29 +45,12 @@ module Ci return end - # TODO: Remove this logging once we confirmed new live trace architecture is functional. - # See https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/4667. - unless job.has_live_trace? - Sidekiq.logger.warn(class: worker_name, - message: 'The job does not have live trace but going to be archived.', - job_id: job.id) - return - end - job.trace.archive! job.remove_pending_state! if job.job_artifacts_trace.present? job.project.execute_integrations(Gitlab::DataBuilder::ArchiveTrace.build(job), :archive_trace_hooks) end - - # TODO: Remove this logging once we confirmed new live trace architecture is functional. - # See https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/4667. - unless job.has_archived_trace? - Sidekiq.logger.warn(class: worker_name, - message: 'The job does not have archived trace after archiving.', - job_id: job.id) - end rescue ::Gitlab::Ci::Trace::AlreadyArchivedError # It's already archived, thus we can safely ignore this exception. rescue StandardError => e diff --git a/app/services/ci/catalog/add_resource_service.rb b/app/services/ci/catalog/add_resource_service.rb deleted file mode 100644 index 1f53513b7d1..00000000000 --- a/app/services/ci/catalog/add_resource_service.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Ci - module Catalog - class AddResourceService - include Gitlab::Allowable - - attr_reader :project, :current_user - - def initialize(project, user) - @current_user = user - @project = project - end - - def execute - raise Gitlab::Access::AccessDeniedError unless can?(current_user, :add_catalog_resource, project) - - validation_response = Ci::Catalog::ValidateResourceService.new(project, project.default_branch).execute - - if validation_response.success? - create_catalog_resource - else - ServiceResponse.error(message: validation_response.message) - end - end - - private - - def create_catalog_resource - catalog_resource = Ci::Catalog::Resource.new(project: project) - - if catalog_resource.valid? - catalog_resource.save! - ServiceResponse.success(payload: catalog_resource) - else - ServiceResponse.error(message: catalog_resource.errors.full_messages.join(', ')) - end - end - end - end -end diff --git a/app/services/ci/generate_kubeconfig_service.rb b/app/services/ci/generate_kubeconfig_service.rb index 1c6aaa9d1ff..56e22a64529 100644 --- a/app/services/ci/generate_kubeconfig_service.rb +++ b/app/services/ci/generate_kubeconfig_service.rb @@ -41,7 +41,7 @@ module Ci attr_reader :pipeline, :token, :environment, :template def agent_authorizations - ::Clusters::Agents::FilterAuthorizationsService.new( + ::Clusters::Agents::Authorizations::CiAccess::FilterService.new( pipeline.cluster_agent_authorizations, environment: environment ).execute diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 30d310dec7f..1de2424924a 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -39,14 +39,18 @@ module Ci return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file) - artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file) - result = parse_artifact(artifact) + build_result = build_artifact(artifacts_file, params, metadata_file) + return build_result unless build_result[:status] == :success + + artifact = build_result[:artifact] + artifact_metadata = build_result[:artifact_metadata] track_artifact_uploader(artifact) - return result unless result[:status] == :success + parse_result = parse_artifact(artifact) + return parse_result unless parse_result[:status] == :success - persist_artifact(artifact, artifact_metadata, params) + persist_artifact(artifact, artifact_metadata) end private @@ -76,40 +80,44 @@ module Ci end def build_artifact(artifacts_file, params, metadata_file) - expire_in = params['expire_in'] || - Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in - artifact_attributes = { job: job, project: project, - expire_in: expire_in + expire_in: expire_in(params), + accessibility: accessibility(params), + locked: pipeline.locked } - artifact_attributes[:locked] = pipeline.locked + file_attributes = { + file_type: params[:artifact_type], + file_format: params[:artifact_format], + file_sha256: artifacts_file.sha256, + file: artifacts_file + } - artifact = Ci::JobArtifact.new( - artifact_attributes.merge( - file: artifacts_file, - file_type: params[:artifact_type], - file_format: params[:artifact_format], - file_sha256: artifacts_file.sha256, - accessibility: accessibility(params) - ) - ) + artifact = Ci::JobArtifact.new(artifact_attributes.merge(file_attributes)) - artifact_metadata = if metadata_file - Ci::JobArtifact.new( - artifact_attributes.merge( - file: metadata_file, - file_type: :metadata, - file_format: :gzip, - file_sha256: metadata_file.sha256, - accessibility: accessibility(params) - ) - ) - end + artifact_metadata = build_metadata_artifact(artifact, metadata_file) if metadata_file + + success(artifact: artifact, artifact_metadata: artifact_metadata) + end + + def build_metadata_artifact(job_artifact, metadata_file) + Ci::JobArtifact.new( + job: job_artifact.job, + project: job_artifact.project, + expire_at: job_artifact.expire_at, + locked: job_artifact.locked, + file: metadata_file, + file_type: :metadata, + file_format: :gzip, + file_sha256: metadata_file.sha256, + accessibility: job_artifact.accessibility + ) + end - [artifact, artifact_metadata] + def expire_in(params) + params['expire_in'] || Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in end def accessibility(params) @@ -129,8 +137,8 @@ module Ci end end - def persist_artifact(artifact, artifact_metadata, params) - Ci::JobArtifact.transaction do + def persist_artifact(artifact, artifact_metadata) + job.transaction do # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future. # Running it first because in migrations we lock the `ci_builds` table # first and then the others. This reduces the chances of deadlocks. @@ -142,13 +150,13 @@ module Ci success(artifact: artifact) rescue ActiveRecord::RecordNotUnique => error - track_exception(error, params) + track_exception(error, artifact.file_type) error('another artifact of the same type already exists', :bad_request) rescue *OBJECT_STORAGE_ERRORS => error - track_exception(error, params) + track_exception(error, artifact.file_type) error(error.message, :service_unavailable) rescue StandardError => error - track_exception(error, params) + track_exception(error, artifact.file_type) error(error.message, :bad_request) end @@ -159,11 +167,12 @@ module Ci existing_artifact.file_sha256 == artifacts_file.sha256 end - def track_exception(error, params) - Gitlab::ErrorTracking.track_exception(error, + def track_exception(error, artifact_type) + Gitlab::ErrorTracking.track_exception( + error, job_id: job.id, project_id: job.project_id, - uploading_type: params[:artifact_type] + uploading_type: artifact_type ) end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 4f2230ea1fc..4c087d23a53 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -34,7 +34,7 @@ module Ci def process! update_stages! update_pipeline! - update_statuses_processed! + update_jobs_processed! Ci::ExpirePipelineCacheService.new.execute(pipeline) @@ -46,60 +46,61 @@ module Ci end def update_stage!(stage) - # Update processables for a given stage in bulk/slices + # Update jobs for a given stage in bulk/slices @collection - .created_processable_ids_in_stage(stage.position) - .in_groups_of(BATCH_SIZE, false) { |ids| update_processables!(ids) } + .created_job_ids_in_stage(stage.position) + .in_groups_of(BATCH_SIZE, false) { |ids| update_jobs!(ids) } status = @collection.status_of_stage(stage.position) stage.set_status(status) end - def update_processables!(ids) - created_processables = pipeline.processables.id_in(ids) + def update_jobs!(ids) + created_jobs = pipeline + .current_processable_jobs + .id_in(ids) .with_project_preload .created - .latest .ordered_by_stage .select_with_aggregated_needs(project) - created_processables.each { |processable| update_processable!(processable) } + created_jobs.each { |job| update_job!(job) } end def update_pipeline! pipeline.set_status(@collection.status_of_all) end - def update_statuses_processed! - processing = @collection.processing_processables + def update_jobs_processed! + processing = @collection.processing_jobs processing.each_slice(BATCH_SIZE) do |slice| - pipeline.statuses.match_id_and_lock_version(slice) + pipeline.all_jobs.match_id_and_lock_version(slice) .update_as_processed! end end - def update_processable!(processable) - previous_status = status_of_previous_processables(processable) - # We do not continue to process the processable if the previous status is not completed + def update_job!(job) + previous_status = status_of_previous_jobs(job) + # We do not continue to process the job if the previous status is not completed return unless Ci::HasStatus::COMPLETED_STATUSES.include?(previous_status) - Gitlab::OptimisticLocking.retry_lock(processable, name: 'atomic_processing_update_processable') do |subject| + Gitlab::OptimisticLocking.retry_lock(job, name: 'atomic_processing_update_job') do |subject| Ci::ProcessBuildService.new(project, subject.user) .execute(subject, previous_status) - # update internal representation of status - # to make the status change of processable to be taken into account during further processing - @collection.set_processable_status(processable.id, processable.status, processable.lock_version) + # update internal representation of job + # to make the status change of job to be taken into account during further processing + @collection.set_job_status(job.id, job.status, job.lock_version) end end - def status_of_previous_processables(processable) - if processable.scheduling_type_dag? - # Processable uses DAG, get status of all dependent needs - @collection.status_of_processables(processable.aggregated_needs_names.to_a, dag: true) + def status_of_previous_jobs(job) + if job.scheduling_type_dag? + # job uses DAG, get status of all dependent needs + @collection.status_of_jobs(job.aggregated_needs_names.to_a) else - # Processable uses Stages, get status of prior stage - @collection.status_of_processables_prior_to_stage(processable.stage_idx.to_i) + # job uses Stages, get status of prior stage + @collection.status_of_jobs_prior_to_stage(job.stage_idx.to_i) 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 9738e4e65b7..85646b79254 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 @@ -8,119 +8,113 @@ module Ci attr_reader :pipeline - # We use these columns to perform an efficient - # calculation of a status - STATUSES_COLUMNS = [ - :id, :name, :status, :allow_failure, - :stage_idx, :processed, :lock_version - ].freeze - def initialize(pipeline) @pipeline = pipeline - @stage_statuses = {} - @prior_stage_statuses = {} + @stage_jobs = {} + @prior_stage_jobs = {} end # This method updates internal status for given ID - def set_processable_status(id, status, lock_version) - processable = all_statuses_by_id[id] - return unless processable + def set_job_status(id, status, lock_version) + job = all_jobs_by_id[id] + return unless job - processable[:status] = status - processable[:lock_version] = lock_version + job[:status] = status + job[:lock_version] = lock_version end - # This methods gets composite status of all processables + # This methods gets composite status of all jobs def status_of_all - status_for_array(all_statuses, dag: false) + status_for_array(all_jobs) end - # This methods gets composite status for processables at a given stage + # This methods gets composite status for jobs at a given stage def status_of_stage(stage_position) strong_memoize("status_of_stage_#{stage_position}") do - stage_statuses = all_statuses_grouped_by_stage_position[stage_position].to_a + stage_jobs = all_jobs_grouped_by_stage_position[stage_position].to_a - status_for_array(stage_statuses.flatten, dag: false) + status_for_array(stage_jobs.flatten) end end - # This methods gets composite status for processables with given names - def status_of_processables(names, dag:) - name_statuses = all_statuses_by_name.slice(*names) + # This methods gets composite status for jobs with given names + def status_of_jobs(names) + jobs = all_jobs_by_name.slice(*names) - status_for_array(name_statuses.values, dag: dag) + status_for_array(jobs.values, dag: true) end - # This methods gets composite status for processables before given stage - def status_of_processables_prior_to_stage(stage_position) - strong_memoize("status_of_processables_prior_to_stage_#{stage_position}") do - stage_statuses = all_statuses_grouped_by_stage_position + # This methods gets composite status for jobs before given stage + def status_of_jobs_prior_to_stage(stage_position) + strong_memoize("status_of_jobs_prior_to_stage_#{stage_position}") do + stage_jobs = all_jobs_grouped_by_stage_position .select { |position, _| position < stage_position } - status_for_array(stage_statuses.values.flatten, dag: false) + status_for_array(stage_jobs.values.flatten) end end - # This methods gets a list of processables for a given stage - def created_processable_ids_in_stage(stage_position) - all_statuses_grouped_by_stage_position[stage_position] + # This methods gets a list of jobs for a given stage + def created_job_ids_in_stage(stage_position) + all_jobs_grouped_by_stage_position[stage_position] .to_a - .select { |processable| processable[:status] == 'created' } - .map { |processable| processable[:id] } + .select { |job| job[:status] == 'created' } + .map { |job| job[:id] } end - # This method returns a list of all processable, that are to be processed - def processing_processables - all_statuses.lazy.reject { |status| status[:processed] } + # This method returns a list of all job, that are to be processed + def processing_jobs + all_jobs.lazy.reject { |job| job[:processed] } end private - def status_for_array(statuses, dag:) + # We use these columns to perform an efficient calculation of a status + JOB_ATTRS = [ + :id, :name, :status, :allow_failure, + :stage_idx, :processed, :lock_version + ].freeze + + def status_for_array(jobs, dag: false) result = Gitlab::Ci::Status::Composite - .new(statuses, dag: dag) + .new(jobs, dag: dag, project: pipeline.project) .status result || 'success' end - def all_statuses_grouped_by_stage_position - strong_memoize(:all_statuses_by_order) do - all_statuses.group_by { |status| status[:stage_idx].to_i } + def all_jobs_grouped_by_stage_position + strong_memoize(:all_jobs_by_order) do + all_jobs.group_by { |job| job[:stage_idx].to_i } end end - def all_statuses_by_id - strong_memoize(:all_statuses_by_id) do - all_statuses.index_by { |row| row[:id] } + def all_jobs_by_id + strong_memoize(:all_jobs_by_id) do + all_jobs.index_by { |row| row[:id] } end end - def all_statuses_by_name - strong_memoize(:statuses_by_name) do - all_statuses.index_by { |row| row[:name] } + def all_jobs_by_name + strong_memoize(:jobs_by_name) do + all_jobs.index_by { |row| row[:name] } end end # rubocop: disable CodeReuse/ActiveRecord - def all_statuses + def all_jobs # We fetch all relevant data in one go. # - # This is more efficient than relying - # on PostgreSQL to calculate composite status - # for us + # This is more efficient than relying on PostgreSQL to calculate composite status for us # - # Since we need to reprocess everything - # we can fetch all of them and do processing - # ourselves. - strong_memoize(:all_statuses) do - raw_statuses = pipeline - .statuses - .latest + # Since we need to reprocess everything we can fetch all of them and do processing ourselves. + strong_memoize(:all_jobs) do + raw_jobs = pipeline + .current_jobs .ordered_by_stage - .pluck(*STATUSES_COLUMNS) + .pluck(*JOB_ATTRS) - raw_statuses.map do |row| - STATUSES_COLUMNS.zip(row).to_h + raw_jobs.map do |row| + JOB_ATTRS.zip(row).to_h end end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 228a246f480..4b55ce149e1 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -6,7 +6,7 @@ module Ci class RegisterJobService include ::Gitlab::Ci::Artifacts::Logger - attr_reader :runner, :runner_machine, :metrics + attr_reader :runner, :runner_manager, :metrics TEMPORARY_LOCK_TIMEOUT = 3.seconds @@ -18,9 +18,9 @@ module Ci # affect 5% of the worst case scenarios. MAX_QUEUE_DEPTH = 45 - def initialize(runner, runner_machine) + def initialize(runner, runner_manager) @runner = runner - @runner_machine = runner_machine + @runner_manager = runner_manager @metrics = ::Gitlab::Ci::Queue::Metrics.new(runner) end @@ -255,7 +255,7 @@ module Ci @metrics.increment_queue_operation(:runner_pre_assign_checks_success) build.run! - build.runner_machine = runner_machine if runner_machine + build.runner_manager = runner_manager if runner_manager end !failure_reason diff --git a/app/services/ci/runners/create_runner_service.rb b/app/services/ci/runners/create_runner_service.rb index 5906cdce99d..ff4a33e431b 100644 --- a/app/services/ci/runners/create_runner_service.rb +++ b/app/services/ci/runners/create_runner_service.rb @@ -5,39 +5,44 @@ module Ci class CreateRunnerService RUNNER_CLASS_MAPPING = { 'instance_type' => Ci::Runners::RunnerCreationStrategies::InstanceRunnerStrategy, - nil => Ci::Runners::RunnerCreationStrategies::InstanceRunnerStrategy + 'group_type' => Ci::Runners::RunnerCreationStrategies::GroupRunnerStrategy, + 'project_type' => Ci::Runners::RunnerCreationStrategies::ProjectRunnerStrategy }.freeze - attr_accessor :user, :type, :params, :strategy - - def initialize(user:, type:, params:) + def initialize(user:, params:) @user = user - @type = type @params = params - @strategy = RUNNER_CLASS_MAPPING[type].new(user: user, type: type, params: params) + @strategy = RUNNER_CLASS_MAPPING[params[:runner_type]].new(user: user, params: params) end def execute normalize_params - return ServiceResponse.error(message: 'Validation error') unless strategy.validate_params - return ServiceResponse.error(message: 'Insufficient permissions') unless strategy.authorized_user? + error = strategy.validate_params + return ServiceResponse.error(message: error, reason: :validation_error) if error + + unless strategy.authorized_user? + return ServiceResponse.error(message: _('Insufficient permissions'), reason: :forbidden) + end runner = ::Ci::Runner.new(params) return ServiceResponse.success(payload: { runner: runner }) if runner.save - ServiceResponse.error(message: runner.errors.full_messages) + ServiceResponse.error(message: runner.errors.full_messages, reason: :save_error) end def normalize_params params[:registration_type] = :authenticated_user - params[:runner_type] = type params[:active] = !params.delete(:paused) if params.key?(:paused) params[:creator] = user strategy.normalize_params end + + private + + attr_reader :user, :params, :strategy end end end diff --git a/app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb new file mode 100644 index 00000000000..2eae5069046 --- /dev/null +++ b/app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + module Runners + module RunnerCreationStrategies + class GroupRunnerStrategy + include Gitlab::Utils::StrongMemoize + + def initialize(user:, params:) + @user = user + @params = params + end + + def normalize_params + params[:runner_type] = 'group_type' + params[:groups] = [scope] + end + + def validate_params + _('Missing/invalid scope') unless scope.present? + end + + def authorized_user? + user.present? && user.can?(:create_runner, scope) + end + + private + + attr_reader :user, :params + + def scope + params.delete(:scope) + end + strong_memoize_attr :scope + end + end + end +end diff --git a/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb index f195c3e88f9..39719ad806f 100644 --- a/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb +++ b/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb @@ -4,25 +4,26 @@ module Ci module Runners module RunnerCreationStrategies class InstanceRunnerStrategy - attr_accessor :user, :type, :params - - def initialize(user:, type:, params:) + def initialize(user:, params:) @user = user - @type = type @params = params end def normalize_params - params[:runner_type] = :instance_type + params[:runner_type] = 'instance_type' end def validate_params - true + _('Unexpected scope') if params[:scope] end def authorized_user? - user.present? && user.can?(:create_instance_runners) + user.present? && user.can?(:create_instance_runner) end + + private + + attr_reader :user, :params end end end diff --git a/app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb new file mode 100644 index 00000000000..487da996513 --- /dev/null +++ b/app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + module Runners + module RunnerCreationStrategies + class ProjectRunnerStrategy + include Gitlab::Utils::StrongMemoize + + def initialize(user:, params:) + @user = user + @params = params + end + + def normalize_params + params[:runner_type] = 'project_type' + params[:projects] = [scope] + end + + def validate_params + _('Missing/invalid scope') unless scope.present? + end + + def authorized_user? + user.present? && user.can?(:create_runner, scope) + end + + private + + attr_reader :user, :params + + def scope + params.delete(:scope) + end + strong_memoize_attr :scope + end + end + end +end diff --git a/app/services/ci/runners/stale_machines_cleanup_service.rb b/app/services/ci/runners/stale_managers_cleanup_service.rb index 3e5706d24a6..b39f7315bc6 100644 --- a/app/services/ci/runners/stale_machines_cleanup_service.rb +++ b/app/services/ci/runners/stale_managers_cleanup_service.rb @@ -2,25 +2,25 @@ module Ci module Runners - class StaleMachinesCleanupService + class StaleManagersCleanupService MAX_DELETIONS = 1000 def execute ServiceResponse.success(payload: { # the `stale` relationship can return duplicates, so we don't try to return a precise count here - deleted_machines: delete_stale_runner_machines > 0 + deleted_managers: delete_stale_runner_managers > 0 }) end private - def delete_stale_runner_machines + def delete_stale_runner_managers total_deleted_count = 0 loop do sub_batch_limit = [100, MAX_DELETIONS].min # delete_all discards part of the `stale` scope query, so we expliclitly wrap it with a SELECT as a workaround - deleted_count = Ci::RunnerMachine.id_in(Ci::RunnerMachine.stale.limit(sub_batch_limit)).delete_all + deleted_count = Ci::RunnerManager.id_in(Ci::RunnerManager.stale.limit(sub_batch_limit)).delete_all total_deleted_count += deleted_count break if deleted_count == 0 || total_deleted_count >= MAX_DELETIONS diff --git a/app/services/ci/track_failed_build_service.rb b/app/services/ci/track_failed_build_service.rb index 973c43a9445..cd7d548e102 100644 --- a/app/services/ci/track_failed_build_service.rb +++ b/app/services/ci/track_failed_build_service.rb @@ -6,7 +6,7 @@ # @param exit_code [Int] the resulting exit code. module Ci class TrackFailedBuildService - SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-1' + SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-2' def initialize(build:, exit_code:, failure_reason:) @build = build diff --git a/app/services/clusters/agents/authorizations/ci_access/filter_service.rb b/app/services/clusters/agents/authorizations/ci_access/filter_service.rb new file mode 100644 index 00000000000..cd08aaa12d4 --- /dev/null +++ b/app/services/clusters/agents/authorizations/ci_access/filter_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + class FilterService + def initialize(authorizations, filter_params) + @authorizations = authorizations + @filter_params = filter_params + + @environments_matcher = {} + end + + def execute + filter_by_environment(authorizations) + end + + private + + attr_reader :authorizations, :filter_params + + def filter_by_environment(auths) + return auths unless filter_by_environment? + + auths.select do |auth| + next true if auth.config['environments'].blank? + + auth.config['environments'].any? { |environment_pattern| matches_environment?(environment_pattern) } + end + end + + def filter_by_environment? + filter_params.has_key?(:environment) + end + + def environment_filter + @environment_filter ||= filter_params[:environment] + end + + def matches_environment?(environment_pattern) + return false if environment_filter.nil? + + environments_matcher(environment_pattern).match?(environment_filter) + end + + def environments_matcher(environment_pattern) + @environments_matcher[environment_pattern] ||= ::Gitlab::Ci::EnvironmentMatcher.new(environment_pattern) + end + end + end + end + end +end diff --git a/app/services/clusters/agents/authorizations/ci_access/refresh_service.rb b/app/services/clusters/agents/authorizations/ci_access/refresh_service.rb new file mode 100644 index 00000000000..047a0725a2c --- /dev/null +++ b/app/services/clusters/agents/authorizations/ci_access/refresh_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module CiAccess + class RefreshService + include Gitlab::Utils::StrongMemoize + + AUTHORIZED_ENTITY_LIMIT = 100 + + delegate :project, to: :agent, private: true + delegate :root_ancestor, to: :project, private: true + + def initialize(agent, config:) + @agent = agent + @config = config + end + + def execute + refresh_projects! + refresh_groups! + + true + end + + private + + attr_reader :agent, :config + + def refresh_projects! + if allowed_project_configurations.present? + project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) } + + agent.with_lock do + agent.ci_access_project_authorizations.upsert_all(allowed_project_configurations, unique_by: [:agent_id, :project_id]) + agent.ci_access_project_authorizations.where.not(project_id: project_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord + end + else + agent.ci_access_project_authorizations.delete_all(:delete_all) + end + end + + def refresh_groups! + if allowed_group_configurations.present? + group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) } + + agent.with_lock do + agent.ci_access_group_authorizations.upsert_all(allowed_group_configurations, unique_by: [:agent_id, :group_id]) + agent.ci_access_group_authorizations.where.not(group_id: group_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord + end + else + agent.ci_access_group_authorizations.delete_all(:delete_all) + end + end + + def allowed_project_configurations + strong_memoize(:allowed_project_configurations) do + project_entries = extract_config_entries(entity: 'projects') + + if project_entries + allowed_projects.where_full_path_in(project_entries.keys).map do |project| + { project_id: project.id, config: project_entries[project.full_path.downcase] } + end + end + end + end + + def allowed_group_configurations + strong_memoize(:allowed_group_configurations) do + group_entries = extract_config_entries(entity: 'groups') + + if group_entries + allowed_groups.where_full_path_in(group_entries.keys).map do |group| + { group_id: group.id, config: group_entries[group.full_path.downcase] } + end + end + end + end + + def extract_config_entries(entity:) + config.dig('ci_access', entity) + &.first(AUTHORIZED_ENTITY_LIMIT) + &.index_by { |config| config.delete('id').downcase } + end + + def allowed_projects + root_ancestor.all_projects + end + + def allowed_groups + if group_root_ancestor? + root_ancestor.self_and_descendants + else + ::Group.none + end + end + + def group_root_ancestor? + root_ancestor.group_namespace? + end + end + end + end + end +end diff --git a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb new file mode 100644 index 00000000000..04d6e04c54d --- /dev/null +++ b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module Authorizations + module UserAccess + class RefreshService + include Gitlab::Utils::StrongMemoize + + AUTHORIZED_ENTITY_LIMIT = 100 + + delegate :project, to: :agent, private: true + delegate :root_ancestor, to: :project, private: true + + def initialize(agent, config:) + @agent = agent + @config = config + end + + def execute + refresh_projects! + refresh_groups! + + true + end + + private + + attr_reader :agent, :config + + def refresh_projects! + if allowed_project_configurations.present? + project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) } + + agent.with_lock do + agent.user_access_project_authorizations.upsert_configs(allowed_project_configurations) + agent.user_access_project_authorizations.delete_unlisted(project_ids) + end + else + agent.user_access_project_authorizations.delete_all(:delete_all) + end + end + + def refresh_groups! + if allowed_group_configurations.present? + group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) } + + agent.with_lock do + agent.user_access_group_authorizations.upsert_configs(allowed_group_configurations) + agent.user_access_group_authorizations.delete_unlisted(group_ids) + end + else + agent.user_access_group_authorizations.delete_all(:delete_all) + end + end + + def allowed_project_configurations + project_entries = extract_config_entries(entity: 'projects') + + return unless project_entries + + allowed_projects.where_full_path_in(project_entries.keys).map do |project| + { project_id: project.id, config: user_access_as } + end + end + strong_memoize_attr :allowed_project_configurations + + def allowed_group_configurations + group_entries = extract_config_entries(entity: 'groups') + + return unless group_entries + + allowed_groups.where_full_path_in(group_entries.keys).map do |group| + { group_id: group.id, config: user_access_as } + end + end + strong_memoize_attr :allowed_group_configurations + + def extract_config_entries(entity:) + config.dig('user_access', entity) + &.first(AUTHORIZED_ENTITY_LIMIT) + &.index_by { |config| config.delete('id').downcase } + end + + def allowed_projects + root_ancestor.all_projects + end + + def allowed_groups + if group_root_ancestor? + root_ancestor.self_and_descendants + else + ::Group.none + end + end + + def group_root_ancestor? + root_ancestor.group_namespace? + end + + def user_access_as + @user_access_as ||= config['user_access']&.slice('access_as') || {} + end + end + end + end + end +end diff --git a/app/services/clusters/agents/authorize_proxy_user_service.rb b/app/services/clusters/agents/authorize_proxy_user_service.rb index ec6645b2db4..ba90d61a7ef 100644 --- a/app/services/clusters/agents/authorize_proxy_user_service.rb +++ b/app/services/clusters/agents/authorize_proxy_user_service.rb @@ -57,7 +57,7 @@ module Clusters def authorized_projects(user_access) strong_memoize_with(:authorized_projects, user_access) do user_access.fetch(:projects, []) - .first(::Clusters::Agents::RefreshAuthorizationService::AUTHORIZED_ENTITY_LIMIT) + .first(::Clusters::Agents::Authorizations::CiAccess::RefreshService::AUTHORIZED_ENTITY_LIMIT) .map { |project| ::Project.find_by_full_path(project[:id]) } .select { |project| current_user.can?(:use_k8s_proxies, project) } end @@ -66,7 +66,7 @@ module Clusters def authorized_groups(user_access) strong_memoize_with(:authorized_groups, user_access) do user_access.fetch(:groups, []) - .first(::Clusters::Agents::RefreshAuthorizationService::AUTHORIZED_ENTITY_LIMIT) + .first(::Clusters::Agents::Authorizations::CiAccess::RefreshService::AUTHORIZED_ENTITY_LIMIT) .map { |group| ::Group.find_by_full_path(group[:id]) } .select { |group| current_user.can?(:use_k8s_proxies, group) } end diff --git a/app/services/clusters/agents/filter_authorizations_service.rb b/app/services/clusters/agents/filter_authorizations_service.rb deleted file mode 100644 index 68517ceec04..00000000000 --- a/app/services/clusters/agents/filter_authorizations_service.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Agents - class FilterAuthorizationsService - def initialize(authorizations, filter_params) - @authorizations = authorizations - @filter_params = filter_params - - @environments_matcher = {} - end - - def execute - filter_by_environment(authorizations) - end - - private - - attr_reader :authorizations, :filter_params - - def filter_by_environment(auths) - return auths unless filter_by_environment? - - auths.select do |auth| - next true if auth.config['environments'].blank? - - auth.config['environments'].any? { |environment_pattern| matches_environment?(environment_pattern) } - end - end - - def filter_by_environment? - filter_params.has_key?(:environment) - end - - def environment_filter - @environment_filter ||= filter_params[:environment] - end - - def matches_environment?(environment_pattern) - return false if environment_filter.nil? - - environments_matcher(environment_pattern).match?(environment_filter) - end - - def environments_matcher(environment_pattern) - @environments_matcher[environment_pattern] ||= ::Gitlab::Ci::EnvironmentMatcher.new(environment_pattern) - end - end - end -end diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb deleted file mode 100644 index 23ececef6a1..00000000000 --- a/app/services/clusters/agents/refresh_authorization_service.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Agents - class RefreshAuthorizationService - include Gitlab::Utils::StrongMemoize - - AUTHORIZED_ENTITY_LIMIT = 100 - - delegate :project, to: :agent, private: true - delegate :root_ancestor, to: :project, private: true - - def initialize(agent, config:) - @agent = agent - @config = config - end - - def execute - refresh_projects! - refresh_groups! - - true - end - - private - - attr_reader :agent, :config - - def refresh_projects! - if allowed_project_configurations.present? - project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) } - - agent.with_lock do - agent.project_authorizations.upsert_all(allowed_project_configurations, unique_by: [:agent_id, :project_id]) - agent.project_authorizations.where.not(project_id: project_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord - end - else - agent.project_authorizations.delete_all(:delete_all) - end - end - - def refresh_groups! - if allowed_group_configurations.present? - group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) } - - agent.with_lock do - agent.group_authorizations.upsert_all(allowed_group_configurations, unique_by: [:agent_id, :group_id]) - agent.group_authorizations.where.not(group_id: group_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord - end - else - agent.group_authorizations.delete_all(:delete_all) - end - end - - def allowed_project_configurations - strong_memoize(:allowed_project_configurations) do - project_entries = extract_config_entries(entity: 'projects') - - if project_entries - allowed_projects.where_full_path_in(project_entries.keys).map do |project| - { project_id: project.id, config: project_entries[project.full_path.downcase] } - end - end - end - end - - def allowed_group_configurations - strong_memoize(:allowed_group_configurations) do - group_entries = extract_config_entries(entity: 'groups') - - if group_entries - allowed_groups.where_full_path_in(group_entries.keys).map do |group| - { group_id: group.id, config: group_entries[group.full_path.downcase] } - end - end - end - end - - def extract_config_entries(entity:) - config.dig('ci_access', entity) - &.first(AUTHORIZED_ENTITY_LIMIT) - &.index_by { |config| config.delete('id').downcase } - end - - def allowed_projects - root_ancestor.all_projects - end - - def allowed_groups - if group_root_ancestor? - root_ancestor.self_and_descendants - else - ::Group.none - end - end - - def group_root_ancestor? - root_ancestor.group_namespace? - end - end - end -end diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb deleted file mode 100644 index 0c9b41be8d2..00000000000 --- a/app/services/clusters/applications/base_helm_service.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class BaseHelmService - attr_accessor :app - - def initialize(app) - @app = app - end - - protected - - def log_error(error) - meta = { - error_code: error.respond_to?(:error_code) ? error.error_code : nil, - service: self.class.name, - app_id: app.id, - app_name: app.name, - project_ids: app.cluster.project_ids, - group_ids: app.cluster.group_ids - } - - Gitlab::ErrorTracking.track_exception(error, meta) - end - - def log_event(event) - meta = { - service: self.class.name, - app_id: app.id, - app_name: app.name, - project_ids: app.cluster.project_ids, - group_ids: app.cluster.group_ids, - event: event - } - - logger.info(meta) - end - - def logger - @logger ||= Gitlab::Kubernetes::Logger.build - end - - def cluster - app.cluster - end - - def kubeclient - cluster.kubeclient - end - - def helm_api - @helm_api ||= Gitlab::Kubernetes::Helm::API.new(kubeclient) - end - - def install_command - @install_command ||= app.install_command - end - - def update_command - @update_command ||= app.update_command - end - - def patch_command(new_values = "") - app.patch_command(new_values) - end - end - end -end diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb index f0e9862ca30..5e87f610e4e 100644 --- a/app/services/concerns/issues/resolve_discussions.rb +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -16,7 +16,11 @@ module Issues # rubocop: disable CodeReuse/ActiveRecord def merge_request_to_resolve_discussions_of strong_memoize(:merge_request_to_resolve_discussions_of) do - MergeRequestsFinder.new(current_user, project_id: project.id) + # sometimes this will be a Group, when work item is created at group level. + # Not sure if we will need to handle resolving an MR with an issue at group level? + next unless container.is_a?(Project) + + MergeRequestsFinder.new(current_user, project_id: container.id) .find_by(iid: merge_request_to_resolve_discussions_of_iid) end end diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb index 24ade9336b2..9d1132b1aba 100644 --- a/app/services/concerns/work_items/widgetable_service.rb +++ b/app/services/concerns/work_items/widgetable_service.rb @@ -2,9 +2,29 @@ module WorkItems module WidgetableService + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def initialize_callbacks!(work_item) + @callbacks = work_item.widgets.filter_map do |widget| + callback_class = widget.class.try(:callback_class) + callback_params = @widget_params[widget.class.api_symbol] + + if new_type_excludes_widget?(widget) + callback_params = {} if callback_params.nil? + callback_params[:excluded_in_new_type] = true + end + + next if callback_class.nil? || callback_params.blank? + + callback_class.new(issuable: work_item, current_user: current_user, params: callback_params) + end + + @callbacks.each(&:after_initialize) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + def execute_widgets(work_item:, callback:, widget_params: {}, service_params: {}) work_item.widgets.each do |widget| - widget_service(widget, service_params).try(callback, params: widget_params[widget.class.api_symbol]) + widget_service(widget, service_params).try(callback, params: widget_params[widget.class.api_symbol] || {}) end end @@ -26,5 +46,13 @@ module WorkItems rescue NameError nil end + + private + + def new_type_excludes_widget?(widget) + return false unless params[:work_item_type] + + params[:work_item_type].widgets.exclude?(widget.class) + end end end diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 6087efce9fd..2ead2e2a113 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -156,7 +156,7 @@ module Git def enqueue_jira_connect_sync_messages return unless project.jira_subscription_exists? - branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractor.has_keys?(branch_name) + branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractors::Branch.has_keys?(project, branch_name) commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha) if branch_to_sync || commits_to_sync.any? diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb index 1d27a5811c7..70834b8a85a 100644 --- a/app/services/import_csv/base_service.rb +++ b/app/services/import_csv/base_service.rb @@ -42,6 +42,7 @@ module ImportCsv def validate_structure! header_line = csv_data.lines.first + raise CSV::MalformedCSVError.new('File is empty, no headers found', 1) if header_line.blank? validate_headers_presence!(header_line) detect_col_sep diff --git a/app/services/issuable/callbacks/base.rb b/app/services/issuable/callbacks/base.rb new file mode 100644 index 00000000000..3fabce2c949 --- /dev/null +++ b/app/services/issuable/callbacks/base.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Issuable + module Callbacks + class Base + include Gitlab::Allowable + + def initialize(issuable:, current_user:, params:) + @issuable = issuable + @current_user = current_user + @params = params + end + + def after_initialize; end + def after_update_commit; end + def after_save_commit; end + + private + + attr_reader :issuable, :current_user, :params + + def excluded_in_new_type? + params.key?(:excluded_in_new_type) && params[:excluded_in_new_type] + end + + def has_permission?(permission) + can?(current_user, permission, issuable) + end + end + end +end diff --git a/app/services/issuable/callbacks/milestone.rb b/app/services/issuable/callbacks/milestone.rb new file mode 100644 index 00000000000..7f922c26e07 --- /dev/null +++ b/app/services/issuable/callbacks/milestone.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Issuable + module Callbacks + class Milestone < Base + ALLOWED_PARAMS = %i[milestone milestone_id skip_milestone_email].freeze + + def after_initialize + params[:milestone_id] = nil if excluded_in_new_type? + return unless params.key?(:milestone_id) && has_permission?(:"set_#{issuable.to_ability_name}_metadata") + + @old_milestone = issuable.milestone + + if params[:milestone_id].blank? || params[:milestone_id].to_s == IssuableFinder::Params::NONE + issuable.milestone = nil + + return + end + + resource_group = issuable.project&.group || issuable.try(:namespace) + project_ids = [issuable.project&.id].compact + + milestone = MilestonesFinder.new({ + project_ids: project_ids, + group_ids: resource_group&.self_and_ancestors&.select(:id), + ids: [params[:milestone_id]] + }).execute.first + + issuable.milestone = milestone if milestone + end + + def after_update_commit + return unless issuable.previous_changes.include?('milestone_id') + + update_usage_data_counters + send_milestone_change_notification + + GraphqlTriggers.issuable_milestone_updated(issuable) + end + + def after_save_commit + return unless issuable.previous_changes.include?('milestone_id') + + invalidate_milestone_counters + end + + private + + def invalidate_milestone_counters + [@old_milestone, issuable.milestone].compact.each do |milestone| + case issuable + when Issue + ::Milestones::ClosedIssuesCountService.new(milestone).delete_cache + ::Milestones::IssuesCountService.new(milestone).delete_cache + when MergeRequest + ::Milestones::MergeRequestsCountService.new(milestone).delete_cache + end + end + end + + def update_usage_data_counters + return unless issuable.is_a?(MergeRequest) + + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + .track_milestone_changed_action(user: current_user) + end + + def send_milestone_change_notification + return if params[:skip_milestone_email] + + notification_service = NotificationService.new.async + + if issuable.milestone.nil? + notification_service.removed_milestone(issuable, current_user) + else + notification_service.changed_milestone(issuable, issuable.milestone, current_user) + end + end + end + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index c630d01cd84..e9312bd6b31 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -3,6 +3,31 @@ class IssuableBaseService < ::BaseContainerService private + def available_callbacks + [ + Issuable::Callbacks::Milestone + ].freeze + end + + def initialize_callbacks!(issuable) + @callbacks = available_callbacks.filter_map do |callback_class| + callback_params = params.slice(*callback_class::ALLOWED_PARAMS) + + next if callback_params.empty? + + callback_class.new(issuable: issuable, current_user: current_user, params: callback_params) + end + + remove_callback_params + @callbacks.each(&:after_initialize) + end + + def remove_callback_params + available_callbacks.each do |callback_class| + callback_class::ALLOWED_PARAMS.each { |p| params.delete(p) } + end + end + def self.constructor_container_arg(value) # TODO: Dynamically determining the type of a constructor arg based on the class is an antipattern, # but the root cause is that Epics::BaseService has some issues that inheritance may not be the @@ -13,14 +38,12 @@ class IssuableBaseService < ::BaseContainerService { container: value } end - attr_accessor :params, :skip_milestone_email + attr_accessor :params def initialize(container:, current_user: nil, params: {}) # we need to exclude project params since they may come from external requests. project should always # be passed as part of the service's initializer super(container: container, current_user: current_user, params: params.except(:project, :project_id)) - - @skip_milestone_email = @params.delete(:skip_milestone_email) end def can_admin_issuable?(issuable) @@ -36,10 +59,7 @@ class IssuableBaseService < ::BaseContainerService end def filter_params(issuable) - params.delete(:milestone) - unless can_set_issuable_metadata?(issuable) - params.delete(:milestone_id) params.delete(:labels) params.delete(:add_label_ids) params.delete(:add_labels) @@ -63,7 +83,6 @@ class IssuableBaseService < ::BaseContainerService params.delete(:remove_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable) filter_assignees(issuable) - filter_milestone filter_labels filter_severity(issuable) filter_escalation_status(issuable) @@ -104,19 +123,6 @@ class IssuableBaseService < ::BaseContainerService can?(user, ability_name, resource) end - def filter_milestone - milestone_id = params[:milestone_id] - return unless milestone_id - - params[:milestone_id] = '' if milestone_id == IssuableFinder::Params::NONE - groups = project.group&.self_and_ancestors&.select(:id) - - milestone = - Milestone.for_projects_and_groups([project.id], groups).find_by_id(milestone_id) - - params[:milestone_id] = '' unless milestone - end - def filter_labels label_ids_to_filter(:add_label_ids, :add_labels, false) label_ids_to_filter(:remove_label_ids, :remove_labels, true) @@ -208,6 +214,8 @@ class IssuableBaseService < ::BaseContainerService end def create(issuable, skip_system_notes: false) + initialize_callbacks!(issuable) + handle_quick_actions(issuable) filter_params(issuable) @@ -231,6 +239,8 @@ class IssuableBaseService < ::BaseContainerService end if issuable_saved + @callbacks.each(&:after_save_commit) + create_system_notes(issuable, is_update: false) unless skip_system_notes handle_changes(issuable, { params: params }) @@ -280,19 +290,22 @@ class IssuableBaseService < ::BaseContainerService end def update(issuable) + old_associations = associations_before_update(issuable) + + initialize_callbacks!(issuable) + prepare_update_params(issuable) handle_quick_actions(issuable) filter_params(issuable) change_additional_attributes(issuable) - old_associations = associations_before_update(issuable) assign_requested_labels(issuable) assign_requested_assignees(issuable) assign_requested_crm_contacts(issuable) widget_params = filter_widget_params - if issuable.changed? || params.present? || widget_params.present? + if issuable.changed? || params.present? || widget_params.present? || @callbacks.present? issuable.assign_attributes(allowed_update_params(params)) if issuable.description_changed? @@ -309,13 +322,15 @@ class IssuableBaseService < ::BaseContainerService # We have to perform this check before saving the issuable as Rails resets # the changed fields upon calling #save. update_project_counters = issuable.project && update_project_counter_caches?(issuable) - ensure_milestone_available(issuable) issuable_saved = issuable.with_transaction_returning_status do transaction_update(issuable, { save_with_touch: should_touch }) end if issuable_saved + @callbacks.each(&:after_update_commit) + @callbacks.each(&:after_save_commit) + create_system_notes( issuable, old_labels: old_associations[:labels], old_milestone: old_associations[:milestone] ) @@ -586,14 +601,6 @@ class IssuableBaseService < ::BaseContainerService project end - # we need to check this because milestone from milestone_id param is displayed on "new" page - # where private project milestone could leak without this check - def ensure_milestone_available(issuable) - return unless issuable.supports_milestone? && issuable.milestone_id.present? - - issuable.milestone_id = nil unless issuable.milestone_available? - end - def update_timestamp?(issuable) issuable.changes.keys != ["relative_position"] end diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb index 5d10eca2979..e996724ebd6 100644 --- a/app/services/issues/after_create_service.rb +++ b/app/services/issues/after_create_service.rb @@ -4,7 +4,6 @@ module Issues class AfterCreateService < Issues::BaseService def execute(issue) todo_service.new_issue(issue, current_user) - delete_milestone_total_issue_counter_cache(issue.milestone) track_incident_action(current_user, issue, :incident_created) end end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 75ef9f735ab..05090efe260 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -53,6 +53,10 @@ module Issues params.delete(:issue_type) unless create_issue_type_allowed?(issue, params[:issue_type]) + if params[:work_item_type].present? && !create_issue_type_allowed?(project, params[:work_item_type].base_type) + params.delete(:work_item_type) + end + moved_issue = params.delete(:moved_issue) # Setting created_at, updated_at and iid is allowed only for admins and owners or @@ -103,8 +107,8 @@ module Issues def execute_hooks(issue, action = 'open', old_associations: {}) issue_data = Gitlab::Lazy.new { hook_data(issue, action, old_associations: old_associations) } hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks - issue.project.execute_hooks(issue_data, hooks_scope) - issue.project.execute_integrations(issue_data, hooks_scope) + issue.namespace.execute_hooks(issue_data, hooks_scope) + issue.namespace.execute_integrations(issue_data, hooks_scope) execute_incident_hooks(issue, issue_data) if issue.incident? end @@ -114,29 +118,12 @@ module Issues def execute_incident_hooks(issue, issue_data) issue_data[:object_kind] = 'incident' issue_data[:event_type] = 'incident' - issue.project.execute_integrations(issue_data, :incident_hooks) + issue.namespace.execute_integrations(issue_data, :incident_hooks) end def update_project_counter_caches?(issue) super || issue.confidential_changed? end - - def delete_milestone_closed_issue_counter_cache(milestone) - return unless milestone - - Milestones::ClosedIssuesCountService.new(milestone).delete_cache - end - - def delete_milestone_total_issue_counter_cache(milestone) - return unless milestone - - Milestones::IssuesCountService.new(milestone).delete_cache - end - - override :allowed_create_params - def allowed_create_params(params) - super(params).except(:work_item_type_id, :work_item_type) - end end end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 75bd2b88e86..cb90aca5800 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -4,11 +4,20 @@ module Issues class BuildService < Issues::BaseService include ResolveDiscussions - def execute + def execute(initialize_callbacks: true) filter_resolve_discussion_params - @issue = model_klass.new(issue_params.merge(project: project)).tap do |issue| - ensure_milestone_available(issue) + container_param = case container + when Project + { project: project } + when Namespaces::ProjectNamespace + { project: container.project } + else + { namespace: container } + end + + @issue = model_klass.new(issue_params.merge(container_param)).tap do |issue| + initialize_callbacks!(issue) if initialize_callbacks end end @@ -61,18 +70,6 @@ module Issues def issue_params @issue_params ||= build_issue_params - - if @issue_params[:work_item_type].present? - @issue_params[:issue_type] = @issue_params[:work_item_type].base_type - else - # If :issue_type is nil then params[:issue_type] was either nil - # or not permitted. Either way, the :issue_type will default - # to the column default of `issue`. And that means we need to - # ensure the work_item_type_id is set - @issue_params[:work_item_type_id] = get_work_item_type_id(@issue_params[:issue_type]) - end - - @issue_params end private @@ -89,11 +86,7 @@ module Issues :confidential ] - 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 - - 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) + public_issue_params << :issue_type if create_issue_type_allowed?(container, params[:issue_type]) params.slice(*public_issue_params) end @@ -104,10 +97,6 @@ module Issues .merge(public_params) .with_indifferent_access end - - def get_work_item_type_id(issue_type = :issue) - find_work_item_type_id(issue_type) - end end end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 4f6a859e20e..87e27ef2763 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -43,7 +43,7 @@ module Issues Onboarding::ProgressService.new(project.namespace).execute(action: :issue_auto_closed) end - delete_milestone_closed_issue_counter_cache(issue.milestone) + Milestones::ClosedIssuesCountService.new(issue.milestone).delete_cache if issue.milestone end issue diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index ec5f9ea8167..2a3f0abf4cb 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -22,9 +22,13 @@ module Issues end def execute(skip_system_notes: false) - return error(_('Operation not allowed'), 403) unless @current_user.can?(authorization_action, @project) + return error(_('Operation not allowed'), 403) unless @current_user.can?(authorization_action, container) + + # We should not initialize the callback classes during the build service execution because these will be + # initialized when we call #create below + @issue = @build_service.execute(initialize_callbacks: false) + set_work_item_type(@issue) - @issue = @build_service.execute # issue_type is set in BuildService, so we can delete it from params, in later phase # it can be set also from quick actions - in that case work_item_id is synced later again params.delete(:issue_type) @@ -60,7 +64,8 @@ module Issues issue.run_after_commit do NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s) Issues::PlacementWorker.perform_async(nil, issue.project_id) - Onboarding::IssueCreatedWorker.perform_async(issue.project.namespace_id) + # issue.namespace_id can point to either a project through project namespace or a group. + Onboarding::IssueCreatedWorker.perform_async(issue.namespace_id) end end @@ -72,7 +77,6 @@ module Issues handle_escalation_status_change(issue) create_timeline_event(issue) try_to_associate_contacts(issue) - change_additional_attributes(issue) super end @@ -89,6 +93,7 @@ module Issues return if issue.assignees == old_assignees create_assignee_note(issue, old_assignees) + Gitlab::ResourceEvents::AssignmentEventRecorder.new(parent: issue, old_assignees: old_assignees).record end def resolve_discussions_with_issue(issue) @@ -101,12 +106,24 @@ module Issues private - def handle_quick_actions(issue) - # Do not handle quick actions unless the work item is the default Issue. - # The available quick actions for a work item depend on its type and widgets. - return if @params[:work_item_type].present? && @params[:work_item_type] != WorkItems::Type.default_by_type(:issue) + def set_work_item_type(issue) + work_item_type = if params[:work_item_type_id].present? + params.delete(:work_item_type) + WorkItems::Type.find_by(id: params.delete(:work_item_type_id)) # rubocop: disable CodeReuse/ActiveRecord + else + params.delete(:work_item_type) + end + + base_type = work_item_type&.base_type + if create_issue_type_allowed?(container, base_type) + issue.work_item_type = work_item_type + # Up to this point issue_type might be set to the default, so we need to sync if a work item type is provided + issue.issue_type = work_item_type.base_type + end - super + # If no work item type was provided, we need to set it to whatever issue_type was up to this point, + # and that includes the column default + issue.work_item_type = WorkItems::Type.default_by_type(issue.issue_type) end def authorization_action @@ -140,15 +157,6 @@ module Issues set_crm_contacts(issue, contacts) end - - override :change_additional_attributes - def change_additional_attributes(issue) - super - - # issue_type can be still set through quick actions, in that case - # we have to make sure to re-sync work_item_type with it - issue.work_item_type_id = find_work_item_type_id(params[:issue_type]) if params[:issue_type] - end end end diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index f4f81e9455a..3330c462947 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -13,7 +13,7 @@ module Issues execute_hooks(issue, 'reopen') invalidate_cache_counts(issue, users: issue.assignees) issue.update_project_counter_caches - delete_milestone_closed_issue_counter_cache(issue.milestone) + Milestones::ClosedIssuesCountService.new(issue.milestone).delete_cache if issue.milestone track_incident_action(current_user, issue, :incident_reopened) end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 322065c5b7c..2cf3f36eef1 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -64,7 +64,6 @@ module Issues handle_assignee_changes(issue, old_assignees) handle_confidential_change(issue) handle_added_labels(issue, old_labels) - handle_milestone_change(issue) handle_added_mentions(issue, old_mentioned_users) handle_severity_change(issue, old_severity) handle_escalation_status_change(issue) @@ -76,6 +75,7 @@ module Issues return if issue.assignees == old_assignees create_assignee_note(issue, old_assignees) + Gitlab::ResourceEvents::AssignmentEventRecorder.new(parent: issue, old_assignees: old_assignees).record notification_service.async.reassigned_issue(issue, current_user, old_assignees) todo_service.reassigned_assignable(issue, current_user, old_assignees) track_incident_action(current_user, issue, :incident_assigned) @@ -116,14 +116,6 @@ module Issues attr_reader :spam_params - def handle_quick_actions(issue) - # Do not handle quick actions unless the work item is the default Issue. - # The available quick actions for a work item depend on its type and widgets. - return unless issue.work_item_type.default_issue? - - super - end - def handle_date_changes(issue) return unless issue.previous_changes.slice('due_date', 'start_date').any? @@ -166,35 +158,6 @@ module Issues end end - def handle_milestone_change(issue) - return unless issue.previous_changes.include?('milestone_id') - - invalidate_milestone_issue_counters(issue) - send_milestone_change_notification(issue) - GraphqlTriggers.issuable_milestone_updated(issue) - end - - def invalidate_milestone_issue_counters(issue) - issue.previous_changes['milestone_id'].each do |milestone_id| - next unless milestone_id - - milestone = Milestone.find_by_id(milestone_id) - - delete_milestone_closed_issue_counter_cache(milestone) - delete_milestone_total_issue_counter_cache(milestone) - end - end - - def send_milestone_change_notification(issue) - return if skip_milestone_email - - if issue.milestone.nil? - notification_service.async.removed_milestone(issue, current_user) - else - notification_service.async.changed_milestone(issue, issue.milestone, current_user) - end - end - def handle_added_mentions(issue, old_mentioned_users) added_mentions = issue.mentioned_users(current_user) - old_mentioned_users @@ -220,7 +183,7 @@ module Issues end def do_handle_issue_type_change(issue) - SystemNoteService.change_issue_type(issue, current_user) + SystemNoteService.change_issue_type(issue, current_user, issue.issue_type_before_last_save) ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation? end diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index 3ce8390d07d..699c5b94c53 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -13,8 +13,29 @@ module Members Gitlab::Access.sym_options_with_owner end - def add_members( # rubocop:disable Metrics/ParameterLists - source, + # Add members to sources with passed access option + # + # access can be an integer representing a access code + # or symbol like :maintainer representing role + # + # Ex. + # add_members( + # sources, + # user_ids, + # Member::MAINTAINER + # ) + # + # add_members( + # sources, + # user_ids, + # :maintainer + # ) + # + # @param sources [Group, Project, Array<Group>, Array<Project>, Group::ActiveRecord_Relation, + # Project::ActiveRecord_Relation] - Can't be an array of source ids because we don't know the type of source. + # @return Array<Member> + def add_members( + sources, invitees, access_level, current_user: nil, @@ -22,52 +43,58 @@ module Members tasks_to_be_done: [], tasks_project_id: nil, ldap: nil - ) + ) # rubocop:disable Metrics/ParameterLists 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, invitees) + sources = Array.wrap(sources) if sources.is_a?(ApplicationRecord) # For single source Member.transaction do - 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 - } - - members = emails.map do |email| - new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute - end + sources.flat_map do |source| + # If this user is attempting to manage Owner members and doesn't have permission, do not allow + next [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user) + + emails, users, existing_members = parse_users_list(source, invitees) + + 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 + } + + 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 += users.map do |user| + new(invitee: user, **common_arguments).execute + end - members + members + end end end - def add_member( # rubocop:disable Metrics/ParameterLists + def add_member( source, invitee, access_level, current_user: nil, expires_at: nil, ldap: nil - ) - add_members(source, - [invitee], - access_level, - current_user: current_user, - expires_at: expires_at, - ldap: ldap).first + ) # rubocop:disable Metrics/ParameterLists + add_members( + source, + [invitee], + access_level, + current_user: current_user, + expires_at: expires_at, + ldap: ldap + ).first end private @@ -217,8 +244,7 @@ module Members end def approve_request - ::Members::ApproveAccessRequestService.new(current_user, - access_level: access_level) + ::Members::ApproveAccessRequestService.new(current_user, access_level: access_level) .execute( member, skip_authorization: ldap || skip_authorization?, diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index 11251e56ee3..f3b1c663fa2 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -39,8 +39,6 @@ module MergeRequests Gitlab::UsageDataCounters::MergeRequestCounter.count(:create) link_lfs_objects(merge_request) - - delete_milestone_total_merge_requests_counter_cache(merge_request.milestone) end def link_lfs_objects(merge_request) diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 97ca96043fb..0d59e442dce 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -263,12 +263,6 @@ module MergeRequests merge_request.update(merge_error: message) if save_message_on_model end - def delete_milestone_total_merge_requests_counter_cache(milestone) - return unless milestone - - Milestones::MergeRequestsCountService.new(milestone).delete_cache - end - def trigger_merge_request_reviewers_updated(merge_request) GraphqlTriggers.merge_request_reviewers_updated(merge_request) end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index b9a681f29db..d5b109a764d 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -16,6 +16,8 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project + initialize_callbacks!(merge_request) + process_params merge_request.compare_commits = [] diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 51be4690af4..835d56a7070 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -15,6 +15,7 @@ module MergeRequests def execute(merge_request, old_assignees, options = {}) create_assignee_note(merge_request, old_assignees) notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees.to_a) + Gitlab::ResourceEvents::AssignmentEventRecorder.new(parent: merge_request, old_assignees: old_assignees).record todo_service.reassigned_assignable(merge_request, current_user, old_assignees) new_assignees = merge_request.assignees - old_assignees diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 792f1728b88..6248baea4ea 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -63,3 +63,5 @@ module MergeRequests end end end + +::MergeRequests::RebaseService.prepend_mod diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 255d96f4969..642cffa6c0d 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -36,7 +36,6 @@ module MergeRequests end handle_target_branch_change(merge_request) - handle_milestone_change(merge_request) handle_draft_status_change(merge_request, changed_fields) track_title_and_desc_edits(changed_fields) @@ -204,25 +203,6 @@ module MergeRequests ) end - def handle_milestone_change(merge_request) - return if skip_milestone_email - - return unless merge_request.previous_changes.include?('milestone_id') - - merge_request_activity_counter.track_milestone_changed_action(user: current_user) - - previous_milestone = Milestone.find_by_id(merge_request.previous_changes['milestone_id'].first) - delete_milestone_total_merge_requests_counter_cache(previous_milestone) - - if merge_request.milestone.nil? - notification_service.async.removed_milestone(merge_request, current_user) - else - notification_service.async.changed_milestone(merge_request, merge_request.milestone, current_user) - - delete_milestone_total_merge_requests_counter_cache(merge_request.milestone) - end - end - def create_branch_change_note(issuable, branch_type, event_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, event_type, diff --git a/app/services/metrics/global_metrics_update_service.rb b/app/services/metrics/global_metrics_update_service.rb new file mode 100644 index 00000000000..356de58ba2e --- /dev/null +++ b/app/services/metrics/global_metrics_update_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Metrics + # Update metrics regarding GitLab instance wide + # + # Anything that is not specific to a machine, process, request or any other context + # can be updated from this services. + # + # Examples of metrics that qualify: + # * Global counters (instance users, instance projects...) + # * State of settings stored in the database (whether a feature is active or not, tuning values...) + # + class GlobalMetricsUpdateService + def execute + return unless ::Gitlab::Metrics.prometheus_metrics_enabled? + + maintenance_mode_metric.set({}, (::Gitlab.maintenance_mode? ? 1 : 0)) + end + + def maintenance_mode_metric + ::Gitlab::Metrics.gauge(:gitlab_maintenance_mode, 'Is GitLab Maintenance Mode enabled?') + end + end +end diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb index f1fd93d7816..e2978c16b2f 100644 --- a/app/services/ml/experiment_tracking/candidate_repository.rb +++ b/app/services/ml/experiment_tracking/candidate_repository.rb @@ -10,14 +10,15 @@ module Ml @user = user end - def by_iid(iid) - ::Ml::Candidate.with_project_id_and_iid(project.id, iid) + def by_eid(eid) + ::Ml::Candidate.with_project_id_and_eid(project.id, eid) end def create!(experiment, start_time, tags = nil, name = nil) candidate = experiment.candidates.create!( user: user, name: candidate_name(name, tags), + project: project, start_time: start_time || 0 ) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 8898f7feb17..39d0d0a7923 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -164,19 +164,17 @@ module Notes track_note_creation_in_ipynb(note) track_note_creation_visual_review(note) - if Feature.enabled?(:route_hll_to_snowplow_phase4, project&.namespace) && note.for_commit? - metric_key_path = 'counts.commit_comment' - - Gitlab::Tracking.event( - 'Notes::CreateService', - 'create_commit_comment', - project: project, - namespace: project&.namespace, - user: user, - label: metric_key_path, - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_key_path).to_context] - ) - end + metric_key_path = 'counts.commit_comment' + + Gitlab::Tracking.event( + 'Notes::CreateService', + 'create_commit_comment', + project: project, + namespace: project&.namespace, + user: user, + label: metric_key_path, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_key_path).to_context] + ) end def tracking_data_for(note) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 47bc36fce70..b93b44ce797 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -492,6 +492,18 @@ class NotificationService mailer.member_access_denied_email(member.real_source_type, member.source_id, member.user_id).deliver_later end + def decline_invite(member) + # Must always send, regardless of project/namespace configuration since it's a + # response to the user's action. + + mailer.member_invite_declined_email( + member.real_source_type, + member.source.id, + member.invite_email, + member.created_by_id + ).deliver_later + end + # Project invite def invite_project_member(project_member, token) return true unless project_member.notifiable?(:subscription) @@ -505,18 +517,6 @@ class NotificationService mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later end - def decline_project_invite(project_member) - # Must always send, regardless of project/namespace configuration since it's a - # response to the user's action. - - mailer.member_invite_declined_email( - project_member.real_source_type, - project_member.project.id, - project_member.invite_email, - project_member.created_by_id - ).deliver_later - end - def new_project_member(project_member) return true unless project_member.notifiable?(:mention, skip_read_ability: true) @@ -542,18 +542,6 @@ class NotificationService mailer.member_invite_accepted_email(group_member.real_source_type, group_member.id).deliver_later end - def decline_group_invite(group_member) - # Must always send, regardless of project/namespace configuration since it's a - # response to the user's action. - - mailer.member_invite_declined_email( - group_member.real_source_type, - group_member.group.id, - group_member.invite_email, - group_member.created_by_id - ).deliver_later - end - def new_group_member(group_member) return true unless group_member.notifiable?(:mention) @@ -810,6 +798,10 @@ class NotificationService end end + def new_achievement_email(user, achievement) + mailer.new_achievement_email(user, achievement) + end + protected def new_resource_email(target, current_user, method) diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb index 82c4292fca8..8eac30f0022 100644 --- a/app/services/packages/create_event_service.rb +++ b/app/services/packages/create_event_service.rb @@ -10,15 +10,6 @@ module Packages ::Packages::Event.counters_for(event_scope, event_name, originator_type).each do |event_name| ::Gitlab::UsageDataCounters::PackageEventCounter.count(event_name) end - - if Feature.enabled?(:collect_package_events) && Gitlab::Database.read_write? - ::Packages::Event.create!( - event_type: event_name, - originator: current_user&.id, - originator_type: originator_type, - event_scope: event_scope - ) - end end def originator_type diff --git a/app/services/packages/debian/find_or_create_incoming_service.rb b/app/services/packages/debian/find_or_create_incoming_service.rb index 2d29ba5f3c3..fae87f09d41 100644 --- a/app/services/packages/debian/find_or_create_incoming_service.rb +++ b/app/services/packages/debian/find_or_create_incoming_service.rb @@ -4,7 +4,7 @@ module Packages module Debian class FindOrCreateIncomingService < ::Packages::CreatePackageService def execute - find_or_create_package!(:debian, name: 'incoming', version: nil) + find_or_create_package!(:debian, name: ::Packages::Debian::INCOMING_PACKAGE_NAME, version: nil) end end end diff --git a/app/services/packages/debian/find_or_create_package_service.rb b/app/services/packages/debian/find_or_create_package_service.rb index cb765e956e7..a9481504d2b 100644 --- a/app/services/packages/debian/find_or_create_package_service.rb +++ b/app/services/packages/debian/find_or_create_package_service.rb @@ -6,13 +6,19 @@ module Packages include Gitlab::Utils::StrongMemoize def execute - package = project.packages - .debian - .with_name(params[:name]) - .with_version(params[:version]) - .with_debian_codename_or_suite(params[:distribution_name]) - .not_pending_destruction - .first + packages = project.packages + .existing_debian_packages_with(name: params[:name], version: params[:version]) + + package = packages.with_debian_codename_or_suite(params[:distribution_name]).first + + unless package + package_in_other_distribution = packages.first + + if package_in_other_distribution + raise ArgumentError, "Debian package #{params[:name]} #{params[:version]} exists " \ + "in distribution #{package_in_other_distribution.debian_distribution.codename}" + end + end package ||= create_package!( :debian, @@ -25,13 +31,12 @@ module Packages private def distribution - strong_memoize(:distribution) do - Packages::Debian::DistributionsFinder.new( - project, - codename_or_suite: params[:distribution_name] - ).execute.last! - end + Packages::Debian::DistributionsFinder.new( + project, + codename_or_suite: params[:distribution_name] + ).execute.last! end + strong_memoize_attr :distribution end end end diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index ee43fe208c9..0740da6c07e 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -196,7 +196,7 @@ module Packages file: CarrierWaveStringFile.new(content), file_md5: file_md5, file_sha256: file_sha256, - size: content.size + size: content.bytesize ) end diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb index dc16d38902b..f4fcd3a563c 100644 --- a/app/services/packages/debian/process_package_file_service.rb +++ b/app/services/packages/debian/process_package_file_service.rb @@ -6,7 +6,7 @@ module Packages include ExclusiveLeaseGuard include Gitlab::Utils::StrongMemoize - SOURCE_FIELD_SPLIT_REGEX = /[ ()]/.freeze + SOURCE_FIELD_SPLIT_REGEX = /[ ()]/ # used by ExclusiveLeaseGuard DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze @@ -54,14 +54,21 @@ module Packages strong_memoize_attr :file_metadata def package - package = temp_package.project - .packages - .debian - .with_name(package_name) - .with_version(package_version) - .with_debian_codename_or_suite(@distribution_name) - .not_pending_destruction - .last + packages = temp_package.project + .packages + .existing_debian_packages_with(name: package_name, version: package_version) + package = packages.with_debian_codename_or_suite(@distribution_name) + .first + + unless package + package_in_other_distribution = packages.first + + if package_in_other_distribution + raise ArgumentError, "Debian package #{package_name} #{package_version} exists " \ + "in distribution #{package_in_other_distribution.debian_distribution.codename}" + end + end + package || temp_package end strong_memoize_attr :package diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index dd074f7472b..33a7736dc95 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -3,15 +3,27 @@ module Packages module Npm class CreatePackageService < ::Packages::CreatePackageService include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard - PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename].freeze + PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename licenseText].freeze + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i def execute return error('Version is empty.', 400) if version.blank? return error('Package already exists.', 403) if current_package_exists? return error('File is too large.', 400) if file_size_exceeded? - ApplicationRecord.transaction { create_npm_package! } + if Feature.enabled?(:npm_obtain_lease_to_create_package, project) + package = try_obtain_lease do + ApplicationRecord.transaction { create_npm_package! } + end + + return error('Could not obtain package lease.', 400) unless package + + package + else + ApplicationRecord.transaction { create_npm_package! } + end end private @@ -103,6 +115,16 @@ module Packages def file_size_exceeded? project.actual_limits.exceeded?(:npm_max_file_size, calculated_package_file_size) end + + # used by ExclusiveLeaseGuard + def lease_key + "packages:npm:create_package_service:packages:#{project.id}_#{name}_#{version}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end end end end diff --git a/app/services/packages/npm/deprecate_package_service.rb b/app/services/packages/npm/deprecate_package_service.rb new file mode 100644 index 00000000000..2633e9f877c --- /dev/null +++ b/app/services/packages/npm/deprecate_package_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Packages + module Npm + class DeprecatePackageService < BaseService + Deprecated = Struct.new(:package_id, :message) + BATCH_SIZE = 50 + + def initialize(project, params) + super(project, nil, params) + end + + def execute(async: false) + return ::Packages::Npm::DeprecatePackageWorker.perform_async(project.id, filtered_params) if async + + packages.select(:id, :version).each_batch(of: BATCH_SIZE) do |relation| + deprecated_metadatum = handle_batch(relation) + update_metadatum(deprecated_metadatum) + end + end + + private + + # To avoid passing the whole metadata to the worker + def filtered_params + { + package_name: params[:package_name], + versions: params[:versions].transform_values { |version| version.slice(:deprecated) } + } + end + + def packages + ::Packages::Npm::PackageFinder + .new(params['package_name'], project: project, last_of_each_version: false) + .execute + end + + def handle_batch(relation) + relation + .preload_npm_metadatum + .filter_map { |package| deprecate(package) } + end + + def deprecate(package) + deprecation_message = params.dig('versions', package.version, 'deprecated') + return if deprecation_message.nil? + + npm_metadatum = package.npm_metadatum + return if identical?(npm_metadatum.package_json['deprecated'], deprecation_message) + + Deprecated.new(npm_metadatum.package_id, deprecation_message) + end + + def identical?(package_json_deprecated, deprecation_message) + package_json_deprecated == deprecation_message || + (package_json_deprecated.nil? && deprecation_message.empty?) + end + + def update_metadatum(deprecated_metadatum) + return if deprecated_metadatum.empty? + + deprecation_message = deprecated_metadatum.first.message + + ::Packages::Npm::Metadatum + .package_id_in(deprecated_metadatum.map(&:package_id)) + .update_all(update_clause(deprecation_message)) + end + + def update_clause(deprecation_message) + if deprecation_message.empty? + "package_json = package_json - 'deprecated'" + else + ["package_json = jsonb_set(package_json, '{deprecated}', ?)", deprecation_message.to_json] + end + end + end + end +end diff --git a/app/services/packages/npm/generate_metadata_service.rb b/app/services/packages/npm/generate_metadata_service.rb new file mode 100644 index 00000000000..800c3ce19b4 --- /dev/null +++ b/app/services/packages/npm/generate_metadata_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Packages + module Npm + class GenerateMetadataService + include API::Helpers::RelatedResourcesHelpers + + # Allowed fields are those defined in the abbreviated form + # defined here: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object + # except: name, version, dist, dependencies and xDependencies. Those are generated by this service. + PACKAGE_JSON_ALLOWED_FIELDS = %w[deprecated bin directories dist engines _hasShrinkwrap].freeze + + def initialize(name, packages) + @name = name + @packages = packages + end + + def execute(only_dist_tags: false) + ServiceResponse.success(payload: metadata(only_dist_tags)) + end + + private + + attr_reader :name, :packages + + def metadata(only_dist_tags) + result = { dist_tags: dist_tags } + + unless only_dist_tags + result[:name] = name + result[:versions] = versions + end + + result + end + + def versions + package_versions = {} + + packages.each_batch do |relation| + batched_packages = relation.including_dependency_links + .preload_files + .preload_npm_metadatum + + batched_packages.each do |package| + package_file = package.installable_package_files.last + + next unless package_file + + package_versions[package.version] = build_package_version(package, package_file) + end + end + + package_versions + end + + def dist_tags + build_package_tags.tap { |t| t['latest'] ||= sorted_versions.last } + end + + def build_package_tags + package_tags.to_h { |tag| [tag.name, tag.package.version] } + end + + def build_package_version(package, package_file) + abbreviated_package_json(package).merge( + name: package.name, + version: package.version, + dist: { + shasum: package_file.file_sha1, + tarball: tarball_url(package, package_file) + } + ).tap do |package_version| + package_version.merge!(build_package_dependencies(package)) + end + end + + def tarball_url(package, package_file) + expose_url api_v4_projects_packages_npm_package_name___file_name_path( + { id: package.project_id, package_name: package.name, file_name: package_file.file_name }, true + ) + end + + def build_package_dependencies(package) + dependencies = Hash.new { |h, key| h[key] = {} } + + package.dependency_links.each do |dependency_link| + dependency = dependency_link.dependency + dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern + end + + dependencies + end + + def sorted_versions + versions = packages.pluck_versions.compact + VersionSorter.sort(versions) + end + + def package_tags + Packages::Tag.for_package_ids(packages.last_of_each_version_ids) + .preload_package + end + + def abbreviated_package_json(package) + json = package.npm_metadatum&.package_json || {} + json.slice(*PACKAGE_JSON_ALLOWED_FIELDS) + end + end + end +end diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb deleted file mode 100644 index 1ea16040655..00000000000 --- a/app/services/projects/blame_service.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -# Service class to correctly initialize Gitlab::Blame and Kaminari pagination -# objects -module Projects - class BlameService - PER_PAGE = 1000 - STREAMING_FIRST_PAGE_SIZE = 200 - STREAMING_PER_PAGE = 2000 - - def initialize(blob, commit, params) - @blob = blob - @commit = commit - @streaming_enabled = streaming_state(params) - @pagination_enabled = pagination_state(params) - @page = extract_page(params) - @params = params - end - - attr_reader :page, :streaming_enabled - - def blame - Gitlab::Blame.new(blob, commit, range: blame_range) - end - - def pagination - return unless pagination_enabled - - Kaminari.paginate_array([], total_count: blob_lines_count, limit: per_page) - .tap { |pagination| pagination.max_paginates_per(per_page) } - .page(page) - end - - def per_page - streaming_enabled ? STREAMING_PER_PAGE : PER_PAGE - end - - def total_pages - total = (blob_lines_count.to_f / per_page).ceil - return total unless streaming_enabled - - ([blob_lines_count - STREAMING_FIRST_PAGE_SIZE, 0].max.to_f / per_page).ceil + 1 - end - - def total_extra_pages - [total_pages - 1, 0].max - end - - def streaming_possible - Feature.enabled?(:blame_page_streaming, commit.project) - end - - private - - attr_reader :blob, :commit, :pagination_enabled - - def blame_range - return unless pagination_enabled || streaming_enabled - - first_line = (page - 1) * per_page + 1 - - if streaming_enabled - return 1..STREAMING_FIRST_PAGE_SIZE if page == 1 - - first_line = STREAMING_FIRST_PAGE_SIZE + (page - 2) * per_page + 1 - end - - last_line = (first_line + per_page).to_i - 1 - - first_line..last_line - end - - def extract_page(params) - page = params.fetch(:page, 1).to_i - - return 1 if page < 1 || overlimit?(page) - - page - end - - def streaming_state(params) - return false unless streaming_possible - - Gitlab::Utils.to_boolean(params[:streaming], default: false) - end - - def pagination_state(params) - return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false) - - Feature.enabled?(:blame_page_pagination, commit.project) - end - - def overlimit?(page) - page > total_pages - end - - def blob_lines_count - @blob_lines_count ||= blob.data.lines.count - end - end -end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 94cc4700a49..cbea44d6aff 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -144,8 +144,10 @@ module Projects # completes), and any other affected users in the background def setup_authorizations if @project.group - group_access_level = @project.group.max_member_access_for_user(current_user, - only_concrete_membership: true) + group_access_level = @project.group.max_member_access_for_user( + current_user, + only_concrete_membership: true + ) if group_access_level > GroupMember::NO_ACCESS current_user.project_authorizations.safe_find_or_create_by!( diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 5fce816064b..aace8846afc 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -92,8 +92,10 @@ module Projects def build_fork_network_member(fork_to_project) if allowed_fork? - fork_to_project.build_fork_network_member(forked_from_project: @project, - fork_network: fork_network) + fork_to_project.build_fork_network_member( + forked_from_project: @project, + fork_network: fork_network + ) else fork_to_project.errors.add(:forked_from_project_id, 'is forbidden') end diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index 349d4d367be..6241a3e144f 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -9,7 +9,7 @@ module Projects include Gitlab::ShellAdapter attr_reader :old_disk_path, :new_disk_path, :old_storage_version, - :logger, :move_wiki, :move_design + :logger, :move_wiki, :move_design def initialize(project:, old_disk_path:, logger: nil) @project = project diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index e6ccae0a22b..ceab7098b32 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -36,8 +36,11 @@ module Projects ) message = Projects::ImportErrorFilter.filter_message(e.message) - error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % - { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }) + error( + s_( + "ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}" + ) % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message } + ) end protected diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index f7de7f98768..a87996b70e8 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -51,9 +51,7 @@ module Projects end def download_links_for(oids) - response = Gitlab::HTTP.post(remote_uri, - body: request_body(oids), - headers: headers) + response = Gitlab::HTTP.post(remote_uri, body: request_body(oids), headers: headers) raise DownloadLinksRequestEntityTooLargeError if response.request_entity_too_large? raise DownloadLinksError, response.message unless response.success? @@ -78,10 +76,12 @@ module Projects raise DownloadLinkNotFound unless link - link_list << LfsDownloadObject.new(oid: entry['oid'], - size: entry['size'], - headers: headers, - link: add_credentials(link)) + link_list << LfsDownloadObject.new( + oid: entry['oid'], + size: entry['size'], + headers: headers, + link: add_credentials(link) + ) rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.") end diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb index d3fed43363c..aff258c418b 100644 --- a/app/services/projects/overwrite_project_service.rb +++ b/app/services/projects/overwrite_project_service.rb @@ -45,11 +45,13 @@ module Projects duration = ::Gitlab::Metrics::System.monotonic_time - start_time - Gitlab::AppJsonLogger.info(class: self.class.name, - namespace_id: source_project.namespace_id, - project_id: source_project.id, - duration_s: duration.to_f, - error: exception.class.name) + Gitlab::AppJsonLogger.info( + class: self.class.name, + namespace_id: source_project.namespace_id, + project_id: source_project.id, + duration_s: duration.to_f, + error: exception.class.name + ) end def move_relationships_between(source_project, target_project) @@ -83,9 +85,11 @@ module Projects # we won't be able to query the database (only through its cached data), # for its former relationships. That's why we're adding it to the network # as a fork of the target project - ForkNetworkMember.create!(fork_network: fork_network, - project: source_project, - forked_from_project: @project) + ForkNetworkMember.create!( + fork_network: fork_network, + project: source_project, + forked_from_project: @project + ) end def remove_source_project_from_fork_network(source_project) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 0fadd75669e..403f645392c 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -90,7 +90,8 @@ module Projects file: file, file_count: deployment_update.entries_count, file_sha256: sha256, - ci_build_id: build.id + ci_build_id: build.id, + root_directory: build.options[:publish] ) break if deployment.size != file.size || deployment.file.size != file.size diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index aca6fa91eb1..b048ec128d8 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -75,12 +75,14 @@ module Projects end if message.present? - Gitlab::AppJsonLogger.info(message: "Error synching remote mirror", - project_id: project.id, - project_path: project.full_path, - remote_mirror_id: remote_mirror.id, - lfs_sync_failed: lfs_sync_failed, - divergent_ref_list: response.divergent_refs) + Gitlab::AppJsonLogger.info( + message: "Error synching remote mirror", + project_id: project.id, + project_path: project.full_path, + remote_mirror_id: remote_mirror.id, + lfs_sync_failed: lfs_sync_failed, + divergent_ref_list: response.divergent_refs + ) end [failed, message] diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index bea994e8bb2..7f25ab5883f 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -51,7 +51,7 @@ module Projects private def add_pages_unique_domain - if Feature.disabled?(:pages_unique_domain) + if Feature.disabled?(:pages_unique_domain, project) params[:project_setting_attributes]&.delete(:pages_unique_domain_enabled) return @@ -120,6 +120,8 @@ module Projects def remove_unallowed_params params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project) + + params.delete(:runner_registration_enabled) if Gitlab::CurrentSettings.valid_runner_registrars.exclude?('project') end def after_update diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb index ac02bf25617..cb2977796d7 100644 --- a/app/services/protected_branches/cache_service.rb +++ b/app/services/protected_branches/cache_service.rb @@ -74,20 +74,24 @@ module ProtectedBranches def redis_key group = project_or_group.is_a?(Group) ? project_or_group : project_or_group.group - @redis_key ||= if Feature.enabled?(:group_protected_branches, group) + @redis_key ||= if allow_protected_branches_for_group?(group) [CACHE_ROOT_KEY, project_or_group.class.name, project_or_group.id].join(':') else [CACHE_ROOT_KEY, project_or_group.id].join(':') end end + def allow_protected_branches_for_group?(group) + Feature.enabled?(:group_protected_branches, group) || + Feature.enabled?(:allow_protected_branches_for_group, group) + end + def metrics @metrics ||= Gitlab::Cache::Metrics.new(cache_metadata) end def cache_metadata Gitlab::Cache::Metadata.new( - caller_id: Gitlab::ApplicationContext.current_context_attribute(:caller_id), cache_identifier: "#{self.class}#fetch", feature_category: :source_code_management, backing_resource: :cpu diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index a3289f9e552..e5883ca06f4 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -18,6 +18,12 @@ module Releases return tag unless tag.is_a?(Gitlab::Git::Tag) + if project.catalog_resource + response = Ci::Catalog::ValidateResourceService.new(project, ref).execute + + return error(response.message) if response.error? + end + create_release(tag, evidence_pipeline) end diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb index 0534925aaec..b60a949fd4e 100644 --- a/app/services/security/ci_configuration/base_create_service.rb +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -59,7 +59,8 @@ module Security YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml rescue Psych::BadAlias raise Gitlab::Graphql::Errors::MutationError, - ".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually." + Gitlab::Utils::ErrorMessage.to_user_facing( + _(".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually.")) rescue Psych::Exception => e Gitlab::AppLogger.error("Failed to process existing .gitlab-ci.yml: #{e.message}") raise Gitlab::Graphql::Errors::MutationError, diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 9de73a00eac..5f71b7ac9e9 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -388,8 +388,8 @@ module SystemNoteService ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).log_resolving_alert(monitoring_tool) end - def change_issue_type(issue, author) - ::SystemNotes::IssuablesService.new(noteable: issue, project: issue.project, author: author).change_issue_type + def change_issue_type(issue, author, previous_type) + ::SystemNotes::IssuablesService.new(noteable: issue, project: issue.project, author: author).change_issue_type(previous_type) end def add_timeline_event(timeline_event) diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index ad9f0dd0368..61a4316e8ae 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -456,8 +456,10 @@ module SystemNotes create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true) end - def change_issue_type - body = "changed issue type to #{noteable.issue_type.humanize(capitalize: false)}" + def change_issue_type(previous_type) + previous = previous_type.humanize(capitalize: false) + new = noteable.issue_type.humanize(capitalize: false) + body = "changed type from #{previous} to #{new}" create_note(NoteSummary.new(noteable, project, author, body, action: 'issue_type')) end diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb index ba52e9abeb2..1c74e803e0b 100644 --- a/app/services/tasks_to_be_done/base_service.rb +++ b/app/services/tasks_to_be_done/base_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module TasksToBeDone - class BaseService < ::IssuableBaseService + class BaseService < ::BaseContainerService LABEL_PREFIX = 'tasks to be done' def initialize(container:, current_user:, assignee_ids: []) @@ -19,8 +19,8 @@ module TasksToBeDone update_service = Issues::UpdateService.new(container: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] }) update_service.execute(issue) else - build_service = Issues::BuildService.new(container: project, current_user: current_user, params: params) - create(build_service.execute) + create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: nil) + create_service.execute end end diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb index 849afaddec6..f72bf0390e4 100644 --- a/app/services/terraform/remote_state_handler.rb +++ b/app/services/terraform/remote_state_handler.rb @@ -2,6 +2,8 @@ module Terraform class RemoteStateHandler < BaseService + include Gitlab::OptimisticLocking + StateLockedError = Class.new(StandardError) StateDeletedError = Class.new(StandardError) UnauthorizedError = Class.new(StandardError) @@ -59,7 +61,9 @@ module Terraform private def retrieve_with_lock(find_only: false) - create_or_find!(find_only: find_only).tap { |state| state.with_lock { yield state } } + create_or_find!(find_only: find_only).tap do |state| + retry_lock(state, name: "Terraform state: #{state.id}") { yield state } + end end def create_or_find!(find_only:) @@ -70,7 +74,7 @@ module Terraform state = if find_only find_state!(find_params) else - Terraform::State.create_or_find_by(find_params) + Terraform::State.safe_find_or_create_by(find_params) end raise StateDeletedError if state.deleted_at? diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb index 353456c545d..53ec37d0ff7 100644 --- a/app/services/users/approve_service.rb +++ b/app/services/users/approve_service.rb @@ -17,6 +17,11 @@ module Users user.accept_pending_invitations! if user.active_for_authentication? DeviseMailer.user_admin_approval(user).deliver_later + if user.created_by_id + reset_token = user.generate_reset_token + NotificationService.new.new_user(user, reset_token) + end + log_event(user) after_approve_hook(user) success(message: 'Success', http_status: :created) diff --git a/app/services/users/ban_service.rb b/app/services/users/ban_service.rb index 959d4be3795..5ed31cdb778 100644 --- a/app/services/users/ban_service.rb +++ b/app/services/users/ban_service.rb @@ -17,3 +17,5 @@ module Users end end end + +Users::BanService.prepend_mod_with('Users::BanService') diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index d01fa29d8d4..b1ffd006795 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -92,5 +92,3 @@ module Users end end end - -Users::RefreshAuthorizedProjectsService.prepend_mod diff --git a/app/services/users/unban_service.rb b/app/services/users/unban_service.rb index 753a02fa752..2019f7e82e1 100644 --- a/app/services/users/unban_service.rb +++ b/app/services/users/unban_service.rb @@ -17,3 +17,5 @@ module Users end end end + +Users::UnbanService.prepend_mod_with('Users::UnbanService') diff --git a/app/services/users/unblock_service.rb b/app/services/users/unblock_service.rb index 1302395662f..d80f65b5757 100644 --- a/app/services/users/unblock_service.rb +++ b/app/services/users/unblock_service.rb @@ -27,3 +27,5 @@ module Users end end end + +Users::UnblockService.prepend_mod_with('Users::UnblockService') diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index eff2132039f..ae355dc6d96 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -2,6 +2,7 @@ module WorkItems class CreateService < Issues::CreateService + extend ::Gitlab::Utils::Override include WidgetableService def initialize(container:, spam_params:, current_user: nil, params: {}, widget_params: {}) @@ -48,6 +49,15 @@ module WorkItems private + override :handle_quick_actions + def handle_quick_actions(work_item) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return if work_item.work_item_type != WorkItems::Type.default_by_type(:issue) + + super + end + def authorization_action :create_work_item end diff --git a/app/services/work_items/export_csv_service.rb b/app/services/work_items/export_csv_service.rb index a715aab1b30..ee20a2832ce 100644 --- a/app/services/work_items/export_csv_service.rb +++ b/app/services/work_items/export_csv_service.rb @@ -17,18 +17,32 @@ module WorkItems private def associations_to_preload - [:work_item_type, :author] + [:project, [work_item_type: :enabled_widget_definitions], :author] end def header_to_value_hash { 'Id' => 'iid', 'Title' => 'title', + 'Description' => ->(work_item) { get_widget_value_for(work_item, :description) }, 'Type' => ->(work_item) { work_item.work_item_type.name }, 'Author' => 'author_name', 'Author Username' => ->(work_item) { work_item.author.username }, 'Created At (UTC)' => ->(work_item) { work_item.created_at.to_s(:csv) } } end + + def get_widget_value_for(work_item, field) + widget_name = field_to_widget_map[field] + widget = work_item.get_widget(widget_name) + + widget.try(field) + end + + def field_to_widget_map + { + description: :description + } + end end end diff --git a/app/services/work_items/parent_links/base_service.rb b/app/services/work_items/parent_links/base_service.rb new file mode 100644 index 00000000000..6f22e09a3fc --- /dev/null +++ b/app/services/work_items/parent_links/base_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module WorkItems + module ParentLinks + class BaseService < IssuableLinks::CreateService + extend ::Gitlab::Utils::Override + + private + + def set_parent(issuable, work_item) + link = WorkItems::ParentLink.for_work_item(work_item) + link.work_item_parent = issuable + link + end + + def create_notes(work_item) + SystemNoteService.relate_work_item(issuable, work_item, current_user) + end + + def linkable_issuables(work_items) + @linkable_issuables ||= if can_admin_link?(issuable) + work_items.select { |work_item| linkable?(work_item) } + else + [] + end + end + + def linkable?(work_item) + can_admin_link?(work_item) && previous_related_issuables.exclude?(work_item) + end + + def can_admin_link?(work_item) + can?(current_user, :admin_parent_link, work_item) + end + + override :previous_related_issuables + def previous_related_issuables + @previous_related_issuables ||= issuable.work_item_children.to_a + end + + override :target_issuable_type + def target_issuable_type + 'work item' + end + + override :issuables_not_found_message + def issuables_not_found_message + format(_('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/create_service.rb b/app/services/work_items/parent_links/create_service.rb index 85b470c47ca..4747d2f17e4 100644 --- a/app/services/work_items/parent_links/create_service.rb +++ b/app/services/work_items/parent_links/create_service.rb @@ -2,59 +2,34 @@ module WorkItems module ParentLinks - class CreateService < IssuableLinks::CreateService + class CreateService < WorkItems::ParentLinks::BaseService private - # rubocop: disable CodeReuse/ActiveRecord + override :relate_issuables def relate_issuables(work_item) - link = WorkItems::ParentLink.find_or_initialize_by(work_item: work_item) - link.work_item_parent = issuable + link = set_parent(issuable, work_item) link.move_to_end if link.changed? && link.save - create_notes(work_item) + relate_child_note = create_notes(work_item) + + ResourceLinkEvent.create( + user: current_user, + work_item: link.work_item_parent, + child_work_item: link.work_item, + action: ResourceLinkEvent.actions[:add], + system_note_metadata_id: relate_child_note&.system_note_metadata&.id + ) 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 + override :extract_references def extract_references params[:issuable_references] end - - def create_notes(work_item) - SystemNoteService.relate_work_item(issuable, work_item, current_user) - end - - def target_issuable_type - 'work item' - 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 index 19770b3e4b5..97145d0b360 100644 --- a/app/services/work_items/parent_links/destroy_service.rb +++ b/app/services/work_items/parent_links/destroy_service.rb @@ -15,7 +15,15 @@ module WorkItems private def create_notes - SystemNoteService.unrelate_work_item(parent, child, current_user) + unrelate_note = SystemNoteService.unrelate_work_item(parent, child, current_user) + + ResourceLinkEvent.create( + user: @current_user, + work_item: @link.work_item_parent, + child_work_item: @link.work_item, + action: ResourceLinkEvent.actions[:remove], + system_note_metadata_id: unrelate_note&.system_note_metadata&.id + ) end def not_found_message diff --git a/app/services/work_items/parent_links/reorder_service.rb b/app/services/work_items/parent_links/reorder_service.rb new file mode 100644 index 00000000000..0ee650bd8ab --- /dev/null +++ b/app/services/work_items/parent_links/reorder_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module WorkItems + module ParentLinks + class ReorderService < WorkItems::ParentLinks::BaseService + private + + override :relate_issuables + def relate_issuables(work_item) + notes_are_expected = work_item.work_item_parent != issuable + link = set_parent(issuable, work_item) + reorder(link, params[:adjacent_work_item], params[:relative_position]) + + create_notes(work_item) if link.save && notes_are_expected + + link + end + + def reorder(link, adjacent_work_item, relative_position) + WorkItems::ParentLink.move_nulls_to_end(RelativePositioning.mover.context(link).relative_siblings) + + link.move_before(adjacent_work_item.parent_link) if relative_position == 'BEFORE' + link.move_after(adjacent_work_item.parent_link) if relative_position == 'AFTER' + end + + override :render_conflict_error? + def render_conflict_error? + return false if params[:adjacent_work_item] && params[:relative_position] + + super + end + + override :linkable? + def linkable?(work_item) + can_admin_link?(work_item) + end + end + end +end diff --git a/app/services/work_items/prepare_import_csv_service.rb b/app/services/work_items/prepare_import_csv_service.rb new file mode 100644 index 00000000000..a331b2870f4 --- /dev/null +++ b/app/services/work_items/prepare_import_csv_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module WorkItems + class PrepareImportCsvService < Import::PrepareService + extend ::Gitlab::Utils::Override + + private + + override :worker + def worker + ImportWorkItemsCsvWorker + end + + override :success_message + def success_message + _("Your work items are being imported. Once finished, you'll receive a confirmation email.") + end + end +end diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index d4acadbc851..defdeebfed8 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -2,6 +2,7 @@ module WorkItems class UpdateService < ::Issues::UpdateService + extend Gitlab::Utils::Override include WidgetableService def initialize(container:, current_user: nil, params: {}, spam_params: nil, widget_params: {}) @@ -26,6 +27,15 @@ module WorkItems private + override :handle_quick_actions + def handle_quick_actions(work_item) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return unless work_item.work_item_type.default_issue? + + super + end + def prepare_update_params(work_item) execute_widgets( work_item: work_item, diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb index 9176b71c85e..7a084917ea7 100644 --- a/app/services/work_items/widgets/assignees_service/update_service.rb +++ b/app/services/work_items/widgets/assignees_service/update_service.rb @@ -5,6 +5,8 @@ module WorkItems module AssigneesService class UpdateService < WorkItems::Widgets::BaseService def before_update_in_transaction(params:) + params[:assignee_ids] = [] if new_type_excludes_widget? + return unless params.present? && params.has_key?(:assignee_ids) return unless has_permission?(:set_work_item_metadata) diff --git a/app/services/work_items/widgets/award_emoji_service/update_service.rb b/app/services/work_items/widgets/award_emoji_service/update_service.rb new file mode 100644 index 00000000000..7c58c0c9af9 --- /dev/null +++ b/app/services/work_items/widgets/award_emoji_service/update_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module AwardEmojiService + class UpdateService < WorkItems::Widgets::BaseService + def before_update_in_transaction(params:) + return unless params.present? && params.key?(:name) && params.key?(:action) + return unless has_permission?(:award_emoji) + + service_response!(service_result(params[:action], params[:name])) + end + + private + + def service_result(action, name) + class_name = { + add: ::AwardEmojis::AddService, + remove: ::AwardEmojis::DestroyService + } + + return invalid_action_error(action) unless class_name.key?(action) + + class_name[action].new(work_item, name, current_user).execute + end + + def invalid_action_error(key) + error(format(_("%{key} is not a valid action."), key: key)) + end + end + end + end +end diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb index 1ff03a09f9f..cae6ed7646f 100644 --- a/app/services/work_items/widgets/base_service.rb +++ b/app/services/work_items/widgets/base_service.rb @@ -16,9 +16,21 @@ module WorkItems private + def new_type_excludes_widget? + return false unless service_params[:work_item_type] + + service_params[:work_item_type].widgets.exclude?(@widget.class) + end + def has_permission?(permission) can?(current_user, permission, widget.work_item) end + + def service_response!(result) + return result unless result[:status] == :error + + raise WidgetError, result[:message] + end end end end diff --git a/app/services/work_items/widgets/current_user_todos_service/update_service.rb b/app/services/work_items/widgets/current_user_todos_service/update_service.rb new file mode 100644 index 00000000000..38e2ae4de32 --- /dev/null +++ b/app/services/work_items/widgets/current_user_todos_service/update_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module CurrentUserTodosService + class UpdateService < WorkItems::Widgets::BaseService + def before_update_in_transaction(params:) + return unless params.present? && params.key?(:action) + + case params[:action] + when "add" + add_todo + when "mark_as_done" + mark_as_done(params[:todo_id]) + end + end + + private + + def add_todo + return unless has_permission?(:create_todo) + + TodoService.new.mark_todo(work_item, current_user)&.first + end + + def mark_as_done(todo_id) + todos = TodosFinder.new(current_user, state: :pending, target_id: work_item.id).execute + todos = todo_id ? todos.id_in(todo_id) : todos + + return if todos.empty? + + TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_done) + end + 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 index fe591ba605e..2640c6132cd 100644 --- a/app/services/work_items/widgets/description_service/update_service.rb +++ b/app/services/work_items/widgets/description_service/update_service.rb @@ -5,6 +5,8 @@ module WorkItems module DescriptionService class UpdateService < WorkItems::Widgets::BaseService def before_update_callback(params: {}) + params[:description] = nil if new_type_excludes_widget? + return unless params.present? && params.key?(:description) return unless has_permission?(:update_work_item) diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb index 236762d6937..45393eab58c 100644 --- a/app/services/work_items/widgets/hierarchy_service/base_service.rb +++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb @@ -63,9 +63,7 @@ module WorkItems work_item.reload_work_item_parent work_item.work_item_children.reset - return result unless result[:status] == :error - - raise WidgetError, result[:message] + super 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 index 48b540f919e..00b45c04ffa 100644 --- a/app/services/work_items/widgets/hierarchy_service/update_service.rb +++ b/app/services/work_items/widgets/hierarchy_service/update_service.rb @@ -4,10 +4,68 @@ module WorkItems module Widgets module HierarchyService class UpdateService < WorkItems::Widgets::HierarchyService::BaseService + INVALID_RELATIVE_POSITION_ERROR = 'Relative position is not valid.' + CHILDREN_REORDERING_ERROR = 'Relative position cannot be combined with childrenIds.' + UNRELATED_ADJACENT_HIERARCHY_ERROR = 'The adjacent work item\'s parent must match the new parent work item.' + INVALID_ADJACENT_PARENT_ERROR = 'The adjacent work item\'s parent must match the current parent work item.' + def before_update_in_transaction(params:) return unless params.present? - service_response!(handle_hierarchy_changes(params)) + if positioning?(params) + service_response!(handle_positioning(params)) + else + service_response!(handle_hierarchy_changes(params)) + end + end + + private + + def handle_positioning(params) + validate_positioning!(params) + + arguments = { + target_issuable: work_item, + adjacent_work_item: params.delete(:adjacent_work_item), + relative_position: params.delete(:relative_position) + } + work_item_parent = params.delete(:parent) || work_item.work_item_parent + ::WorkItems::ParentLinks::ReorderService.new(work_item_parent, current_user, arguments).execute + end + + def positioning?(params) + params[:relative_position].present? || params[:adjacent_work_item].present? + end + + def error!(message) + service_response!(error(_(message))) + end + + def validate_positioning!(params) + error!(INVALID_RELATIVE_POSITION_ERROR) if incomplete_relative_position?(params) + error!(CHILDREN_REORDERING_ERROR) if positioning_children?(params) + error!(UNRELATED_ADJACENT_HIERARCHY_ERROR) if unrelated_adjacent_hierarchy?(params) + error!(INVALID_ADJACENT_PARENT_ERROR) if invalid_adjacent_parent?(params) + end + + def positioning_children?(params) + params.key?(:children) + end + + def incomplete_relative_position?(params) + params[:adjacent_work_item].blank? || params[:relative_position].blank? + end + + def unrelated_adjacent_hierarchy?(params) + return false if params[:parent].blank? + + params[:parent] != params[:adjacent_work_item].work_item_parent + end + + def invalid_adjacent_parent?(params) + return false if params[:parent].present? + + work_item.work_item_parent != params[:adjacent_work_item].work_item_parent end end end diff --git a/app/services/work_items/widgets/labels_service/update_service.rb b/app/services/work_items/widgets/labels_service/update_service.rb index f00ea5c95ca..b880398677d 100644 --- a/app/services/work_items/widgets/labels_service/update_service.rb +++ b/app/services/work_items/widgets/labels_service/update_service.rb @@ -5,6 +5,11 @@ module WorkItems module LabelsService class UpdateService < WorkItems::Widgets::BaseService def prepare_update_params(params: {}) + if new_type_excludes_widget? + params[:remove_label_ids] = @work_item.labels.map(&:id) + params[:add_label_ids] = [] + end + return if params.blank? service_params.merge!(params.slice(:add_label_ids, :remove_label_ids)) diff --git a/app/services/work_items/widgets/milestone_service/base_service.rb b/app/services/work_items/widgets/milestone_service/base_service.rb index f373e6daea3..e9dc56cb6df 100644 --- a/app/services/work_items/widgets/milestone_service/base_service.rb +++ b/app/services/work_items/widgets/milestone_service/base_service.rb @@ -20,12 +20,13 @@ module WorkItems return end - project = work_item.project + resource_group = work_item.project&.group || work_item.namespace + project_ids = [work_item.project&.id].compact milestone = MilestonesFinder.new({ - project_ids: [project.id], - group_ids: project.group&.self_and_ancestors&.select(:id), - ids: [params[:milestone_id]] - }).execute.first + project_ids: project_ids, + group_ids: resource_group&.self_and_ancestors&.select(:id), + ids: [params[:milestone_id]] + }).execute.first if milestone work_item.milestone = milestone diff --git a/app/services/work_items/widgets/milestone_service/create_service.rb b/app/services/work_items/widgets/milestone_service/create_service.rb deleted file mode 100644 index e8d6bfe503c..00000000000 --- a/app/services/work_items/widgets/milestone_service/create_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module MilestoneService - class CreateService < WorkItems::Widgets::MilestoneService::BaseService - def before_create_callback(params:) - handle_milestone_change(params: params) - end - end - end - end -end diff --git a/app/services/work_items/widgets/milestone_service/update_service.rb b/app/services/work_items/widgets/milestone_service/update_service.rb deleted file mode 100644 index 7ff0c2a5367..00000000000 --- a/app/services/work_items/widgets/milestone_service/update_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module MilestoneService - class UpdateService < WorkItems::Widgets::MilestoneService::BaseService - def before_update_callback(params:) - handle_milestone_change(params: params) - end - end - end - end -end diff --git a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb index 6a5dc0d5ef3..0dbf3aa31d9 100644 --- a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb +++ b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb @@ -5,6 +5,8 @@ module WorkItems module StartAndDueDateService class UpdateService < WorkItems::Widgets::BaseService def before_update_callback(params: {}) + return widget.work_item.assign_attributes({ start_date: nil, due_date: nil }) if new_type_excludes_widget? + return if params.blank? widget.work_item.assign_attributes(params.slice(:start_date, :due_date)) diff --git a/app/uploaders/object_storage/cdn/google_cdn.rb b/app/uploaders/object_storage/cdn/google_cdn.rb index f1fe62e9db3..f39729357ed 100644 --- a/app/uploaders/object_storage/cdn/google_cdn.rb +++ b/app/uploaders/object_storage/cdn/google_cdn.rb @@ -28,7 +28,7 @@ module ObjectStorage expiration = (Time.current + expiry).utc.to_i uri = Addressable::URI.parse(cdn_url) - uri.path = path + uri.path = Addressable::URI.encode_component(path, Addressable::URI::CharacterClasses::PATH) # Use an Array to preserve order: Google CDN needs to have # Expires, KeyName, and Signature in that order or it will return a 403 error: # https://cloud.google.com/cdn/docs/troubleshooting-steps#signing diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 967fcdc704e..8561a72444d 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -27,10 +27,14 @@ module RecordsUploads end def readd_upload - uploads.where(model: model, path: upload_path).delete_all - upload.delete if upload + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[uploads], url: "https://gitlab.com/gitlab-org/gitlab/-/issues/398199" + ) do + uploads.where(model: model, path: upload_path).delete_all + upload.delete if upload - self.upload = build_upload.tap(&:save!) + self.upload = build_upload.tap(&:save!) + end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/validators/json_schemas/application_setting_database_apdex_settings.json b/app/validators/json_schemas/application_setting_database_apdex_settings.json new file mode 100644 index 00000000000..8b58dd44586 --- /dev/null +++ b/app/validators/json_schemas/application_setting_database_apdex_settings.json @@ -0,0 +1,34 @@ +{ + "description": "Database Apdex Settings", + "type": "object", + "properties": { + "prometheus_api_url": { + "type": "string" + }, + "apdex_sli_query": { + "type": "object", + "properties": { + "main": { + "type": "string" + }, + "ci": { + "type": "string" + } + } + }, + "apdex_slo": { + "type": "object", + "properties": { + "main": { + "type": "number", + "format": "float" + }, + "ci": { + "type": "number", + "format": "float" + } + } + } + }, + "additionalProperties": false +} diff --git a/app/validators/json_schemas/build_report_result_data.json b/app/validators/json_schemas/build_report_result_data.json index d109389a046..d9ef7633acd 100644 --- a/app/validators/json_schemas/build_report_result_data.json +++ b/app/validators/json_schemas/build_report_result_data.json @@ -8,10 +8,7 @@ "format": "float" }, "tests": { - "type": "object", - "items": { - "$ref": "./build_report_result_data_tests.json" - } + "$ref": "./build_report_result_data_tests.json" } }, "additionalProperties": false diff --git a/app/validators/json_schemas/build_report_result_data_tests.json b/app/validators/json_schemas/build_report_result_data_tests.json index 3b6a2688313..456b651dd6c 100644 --- a/app/validators/json_schemas/build_report_result_data_tests.json +++ b/app/validators/json_schemas/build_report_result_data_tests.json @@ -7,7 +7,7 @@ "type": "string" }, "duration": { - "type": "string" + "type": "number" }, "failed": { "type": "integer" @@ -20,6 +20,16 @@ }, "success": { "type": "integer" + }, + "suite_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false diff --git a/app/validators/json_schemas/cluster_agent_authorization_configuration.json b/app/validators/json_schemas/clusters_agents_authorizations_ci_access_config.json index f3de0b7043b..f3de0b7043b 100644 --- a/app/validators/json_schemas/cluster_agent_authorization_configuration.json +++ b/app/validators/json_schemas/clusters_agents_authorizations_ci_access_config.json diff --git a/app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json b/app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json new file mode 100644 index 00000000000..75624af9e6a --- /dev/null +++ b/app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Cluster Agent configuration for an authorized project or group through user_access keyword", + "type": "object", + "additionalProperties": true +} diff --git a/app/validators/json_schemas/import_failure_external_identifiers.json b/app/validators/json_schemas/import_failure_external_identifiers.json index 3756e712de5..19d4e51ad21 100644 --- a/app/validators/json_schemas/import_failure_external_identifiers.json +++ b/app/validators/json_schemas/import_failure_external_identifiers.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "Import failure external identifiers", "type": "object", - "maxProperties": 3, + "maxProperties": 4, "patternProperties": { ".*": { "oneOf": [ diff --git a/app/validators/json_schemas/pinned_nav_items.json b/app/validators/json_schemas/pinned_nav_items.json new file mode 100644 index 00000000000..60dee5cc463 --- /dev/null +++ b/app/validators/json_schemas/pinned_nav_items.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Pinned navigation items per panel", + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "project": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "additionalProperties": false +} diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index 8b9bbfd0a59..39b8fe26c7b 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -26,6 +26,17 @@ = f.label :reported_from = f.text_field :reported_from_url, class: "form-control", readonly: true #js-links-to-spam{ data: { links: Array(@abuse_report.links_to_spam) } } + + .form-group.row + .col-lg-8 + = f.label :screenshot do + %span + = s_('ReportAbuse|Screenshot') + .gl-font-weight-normal + = s_('ReportAbuse|Screenshot of abuse') + %div + = render 'shared/file_picker_button', f: f, field: :screenshot, help_text: _("Screenshot must be less than 1 MB."), mime_types: valid_image_mimetypes + .form-group.row .col-lg-8 = f.label :reason diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index 6a8ef86a56e..16b2a0b8fc6 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -6,9 +6,9 @@ = f.label :container_registry_token_expire_delay, _('Authorization token duration (minutes)'), class: 'label-bold' = f.number_field :container_registry_token_expire_delay, class: 'form-control gl-form-input' .form-group - - label = _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.") + - label = _("Enable cleanup policies for projects created earlier than GitLab 12.7.") - label_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy') - - help_text = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") + - help_text = _("Existing projects will be able to use cleanup policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") - help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'use-with-external-container-registries') = f.gitlab_ui_checkbox_component :container_expiration_policies_enable_historic_entries, '%{label} %{label_link}'.html_safe % { label: label, label_link: label_link }, @@ -29,9 +29,9 @@ .form-text.text-muted = _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.") .form-group - - help_text = _("When enabled, cleanup polices execute faster but put more load on Redis.") + - help_text = _("When enabled, cleanup policies execute faster but put more load on Redis.") - help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources') - = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable container expiration caching."), + = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable cleanup policy caching."), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 83347034cc5..a8d5a45041d 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -2,39 +2,34 @@ = form_errors(application) = content_tag :div, class: 'form-group row' do - .col-sm-2.col-form-label + .col-12 = f.label :name - .col-sm-10 - = f.text_field :name, class: 'form-control gl-form-input' + = f.text_field :name, class: 'form-control gl-form-input', data: { qa_selector: 'name_field' } = doorkeeper_errors_for application, :name = content_tag :div, class: 'form-group row' do - .col-sm-2.col-form-label + .col-12 = f.label :redirect_uri - .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control gl-form-input' + = f.text_area :redirect_uri, class: 'form-control gl-form-input', data: { qa_selector: 'redirect_uri_field' } = doorkeeper_errors_for application, :redirect_uri %span.form-text.text-muted Use one line per URI = content_tag :div, class: 'form-group row' do - .col-sm-2.col-form-label.pt-0 + .col-12 = f.label :trusted - .col-sm-10 - = f.gitlab_ui_checkbox_component :trusted, _('Trusted applications are automatically authorized on GitLab OAuth flow. It\'s highly recommended for the security of users that trusted applications have the confidential setting set to true.') + = f.gitlab_ui_checkbox_component :trusted, _('Trusted applications are automatically authorized on GitLab OAuth flow. It\'s highly recommended for the security of users that trusted applications have the confidential setting set to true.'), checkbox_options: { data: { qa_selector: 'trusted_checkbox' } } = content_tag :div, class: 'form-group row' do - .col-sm-2.col-form-label.pt-0 + .col-12 = f.label :confidential - .col-sm-10 = f.gitlab_ui_checkbox_component :confidential, _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.') .form-group.row - .col-sm-2.col-form-label.pt-0 + .col-12 = f.label :scopes - .col-sm-10 = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes, f: f - .form-actions - = f.submit _('Save application'), pajamas_button: true + .gl-mt-5 + = f.submit _('Save application'), pajamas_button: true, data: { qa_selector: 'save_application_button' } = link_to _('Cancel'), admin_applications_path, class: "gl-button btn btn-default btn-cancel" diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index d6a0974d10f..60aa7ae1c56 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -10,16 +10,16 @@ - if @applications.empty? %section.empty-state.gl-text-center.gl-display-flex.gl-flex-direction-column .svg-content.svg-150 - = image_tag 'illustrations/empty-state/empty-admin-apps.svg', class: 'gl-max-w-full' + = image_tag 'illustrations/empty-state/empty-admin-apps-md.svg', class: 'gl-max-w-full' .gl-max-w-full.gl-m-auto %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found') - = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do + = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do = s_('New application') - else %hr - = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do + = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do = s_('New application') .table-responsive diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml index 00859bf6b66..9550ea2884e 100644 --- a/app/views/admin/background_migrations/index.html.haml +++ b/app/views/admin/background_migrations/index.html.haml @@ -17,6 +17,9 @@ = gl_tab_link_to admin_background_migrations_path({ tab: nil, database: params[:database] }), item_active: @current_tab == 'queued' do = _('Queued') = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['queued']) + = gl_tab_link_to admin_background_migrations_path({ tab: 'finalizing', database: params[:database] }), item_active: @current_tab == 'finalizing' do + = _('Finalizing') + = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['finalizing']) = gl_tab_link_to admin_background_migrations_path({ tab: 'failed', database: params[:database] }), item_active: @current_tab == 'failed' do = _('Failed') = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['failed']) diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 8afddd99451..01d47facb5c 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -101,7 +101,7 @@ doc_href: help_page_path('integration/omniauth')) = feature_entry(_('Reply by email'), - enabled: Gitlab::IncomingEmail.enabled?, + enabled: Gitlab::Email::IncomingEmail.enabled?, doc_href: help_page_path('administration/reply_by_email')) = render_if_exists 'admin/dashboard/elastic_and_geo' @@ -126,7 +126,7 @@ - if show_version_check? .float-right .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true", "version": gitlab_version_check.to_json } } - = link_to(sprite_icon('question'), "https://gitlab.com/gitlab-org/gitlab/-/blob/master/CHANGELOG.md", class: 'gl-ml-2', target: '_blank', rel: 'noopener noreferrer') + = link_to(sprite_icon('question-o'), "https://gitlab.com/gitlab-org/gitlab/-/blob/master/CHANGELOG.md", class: 'gl-ml-2', target: '_blank', rel: 'noopener noreferrer') %p = link_to _('GitLab'), general_admin_application_settings_path %span.float-right diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml index d92d13260fe..c079dd6b581 100644 --- a/app/views/admin/dev_ops_report/show.html.haml +++ b/app/views/admin/dev_ops_report/show.html.haml @@ -1,10 +1,9 @@ - page_title _('DevOps Reports') - add_page_specific_style 'page_bundles/dev_ops_reports' -.container - .gl-mt-3 - - if show_adoption? - = render_if_exists 'admin/dev_ops_report/devops_tabs' - - else - = render 'score' +.gl-mt-3 + - if show_adoption? + = render_if_exists 'admin/dev_ops_report/devops_tabs' + - else + = render 'score' diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 8d6df064c3c..1cdfde07adc 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -18,8 +18,8 @@ .js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) } %section.row.empty-state.gl-text-center .col-12 - .svg-content - = image_tag 'illustrations/labels.svg' + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-labels-md.svg' .col-12 .gl-mx-auto.gl-my-0.gl-p-5 %h1.gl-font-size-h-display.gl-line-height-36.h4 diff --git a/app/views/admin/labels/new.html.haml b/app/views/admin/labels/new.html.haml index 76f9eee717e..c8c3fe7b9af 100644 --- a/app/views/admin/labels/new.html.haml +++ b/app/views/admin/labels/new.html.haml @@ -1,5 +1,4 @@ - page_title _("New Label") %h1.page-title.gl-font-size-h-display = _('New Label') -%hr = render 'shared/labels/form', url: admin_labels_path, back_path: admin_labels_path diff --git a/app/views/admin/projects/_form.html.haml b/app/views/admin/projects/_form.html.haml index 18bef523168..dbb4f3a63cc 100644 --- a/app/views/admin/projects/_form.html.haml +++ b/app/views/admin/projects/_form.html.haml @@ -17,6 +17,21 @@ = f.label :description, _('Project description (optional)') = f.text_area :description, class: 'form-control gl-form-input gl-form-textarea gl-lg-form-input-xl', rows: 5 + = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-pb-3 gl-mb-6' }) do |c| + = c.title { _('Permissions and project features') } + = c.description do + = _('Configure advanced permissions') + = c.body do + - if @project.project_setting.present? + .form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_('Runners|Runner Registration') + - all_disabled = Gitlab::CurrentSettings.valid_runner_registrars.exclude?('project') + = f.gitlab_ui_checkbox_component :runner_registration_enabled, + s_('Runners|New project runners can be registered'), + checkbox_options: { checked: @project.runner_registration_enabled, disabled: all_disabled }, + help_text: html_escape_once(s_('Runners|Existing runners are not affected. To permit runner registration for all projects, enable this setting in the Admin Area in Settings > CI/CD.')).html_safe + .gl-mt-5 = f.submit _('Save changes'), pajamas_button: true = render Pajamas::ButtonComponent.new(href: admin_project_path(@project)) do diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 3950170e486..a24ef5d8ea4 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -1,4 +1,5 @@ - page_title _('Enter Admin Mode') +- add_page_specific_style 'page_bundles/login' .row.justify-content-center .col-md-5.new-session-forms-container diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml index d05cc51af41..65455f2ccf0 100644 --- a/app/views/admin/sessions/two_factor.html.haml +++ b/app/views/admin/sessions/two_factor.html.haml @@ -1,4 +1,5 @@ - page_title _('Enter 2FA for Admin Mode') +- add_page_specific_style 'page_bundles/login' .row.justify-content-center .col-md-5.new-session-forms-container diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 049f3d61294..097329a027c 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -3,45 +3,46 @@ .gl-mt-3 .row .col-sm - .bg-light.info-well.p-3 - %h4.page-title.d-flex - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('pod', size: 18, css_class: 'pod-icon gl-mr-3') - = _('CPU') - .data - - if @cpus - %h2= _('%{cores} cores') % { cores: @cpus.length } - - else - = sprite_icon('warning-solid', css_class: 'text-warning') - = _('Unable to collect CPU info') - .bg-light.info-well.p-3.gl-mt-3 - %h4.page-title.d-flex - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('status-health', size: 18, css_class: 'pod-icon gl-mr-3') - = _('Memory Usage') - .data - - if @memory - %h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)} - - else - = sprite_icon('warning-solid', css_class: 'text-warning') - = _('Unable to collect memory info') - .bg-light.info-well.p-3.gl-mt-3 - %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') - = _('System started') - .data - %h2= time_ago_with_tooltip(Rails.application.config.booted_at) + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c| + = c.body do + %h4 + = sprite_icon('pod', size: 18, css_class: 'gl-text-gray-700') + = s_('CPU') + .data + - if @cpus + %h2= _('%{cores} cores') % { cores: @cpus.length } + - else + = sprite_icon('warning-solid', css_class: 'text-warning') + = _('Unable to collect CPU info') + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c| + = c.body do + %h4 + = sprite_icon('status-health', size: 18, css_class: 'gl-text-gray-700') + = s_('Memory Usage') + .data + - if @memory + %h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)} + - else + = sprite_icon('warning-solid', css_class: 'text-warning') + = _('Unable to collect memory info') + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c| + = c.body do + %h4 + = sprite_icon('clock', size: 18, css_class: 'gl-text-gray-700') + = s_('System started') + .data + %h2= time_ago_with_tooltip(Rails.application.config.booted_at) .col-sm - .bg-light.info-well.p-3 - %h4.page-title.d-flex - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('disk', size: 18, css_class: 'pod-icon gl-mr-3') - = _('Disk Usage') - .data - %ul + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c| + = c.body do + %h4 + = sprite_icon('disk', size: 18, css_class: 'gl-text-gray-700') + = s_('Disk Usage') + .data - @disks.each do |disk| - %li - %h2 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} - %p= disk[:disk_name] - %p= disk[:mount_path] + %h2 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} + %ul + %li= disk[:disk_name] + %li= disk[:mount_path] diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml index e90dab68b39..de48129dfe6 100644 --- a/app/views/admin/users/_profile.html.haml +++ b/app/views/admin/users/_profile.html.haml @@ -1,31 +1,32 @@ -.card - .card-header += render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c| + - c.header do = _('Profile') - %ul.content-list - %li - %span.light= _('Member since') - %strong= user.created_at.to_s(:medium) - - unless user.public_email.blank? + - c.body do + %ul.content-list %li - %span.light= _('E-mail:') - %strong= link_to user.public_email, "mailto:#{user.public_email}" - - unless user.skype.blank? - %li - %span.light= _('Skype:') - %strong= link_to user.skype, "skype:#{user.skype}" - - unless user.linkedin.blank? - %li - %span.light= _('LinkedIn:') - %strong= link_to user.linkedin, "https://www.linkedin.com/in/#{user.linkedin}" - - unless user.twitter.blank? - %li - %span.light= _('Twitter:') - %strong= link_to user.twitter, "https://twitter.com/#{user.twitter}" - - unless user.website_url.blank? - %li - %span.light= _('Website:') - %strong= link_to user.short_website_url, user.full_website_url - - unless user.location.blank? - %li - %span.light= _('Location:') - %strong= user.location + %span.light= _('Member since') + %strong= user.created_at.to_s(:medium) + - unless user.public_email.blank? + %li + %span.light= _('E-mail:') + %strong= link_to user.public_email, "mailto:#{user.public_email}" + - unless user.skype.blank? + %li + %span.light= _('Skype:') + %strong= link_to user.skype, "skype:#{user.skype}" + - unless user.linkedin.blank? + %li + %span.light= _('LinkedIn:') + %strong= link_to user.linkedin, "https://www.linkedin.com/in/#{user.linkedin}" + - unless user.twitter.blank? + %li + %span.light= _('Twitter:') + %strong= link_to user.twitter, "https://twitter.com/#{user.twitter}" + - unless user.website_url.blank? + %li + %span.light= _('Website:') + %strong= link_to user.short_website_url, user.full_website_url + - unless user.location.blank? + %li + %span.light= _('Location:') + %strong= user.location diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml index 7dcec50573f..7d7bd395836 100644 --- a/app/views/authentication/_authenticate.html.haml +++ b/app/views/authentication/_authenticate.html.haml @@ -1,5 +1,8 @@ #js-authenticate-token-2fa -%a.gl-button.btn.btn-block.btn-confirm#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code") += render Pajamas::ButtonComponent.new(variant: :confirm, + block: true, + button_options: { id: 'js-login-2fa-device' }) do + = _("Sign in via 2FA code") -# haml-lint:disable InlineJavaScript %script#js-authenticate-token-2fa-in-progress{ type: "text/template" } @@ -9,7 +12,9 @@ %script#js-authenticate-token-2fa-error{ type: "text/template" } %div %p <%= error_message %> (<%= error_name %>) - %a.btn.btn-default.gl-button.btn-block#js-token-2fa-try-again= _("Try again?") + = render Pajamas::ButtonComponent.new(block: true, + button_options: { id: 'js-token-2fa-try-again', class: 'gl-mb-3' }) do + = _("Try again?") -# haml-lint:disable InlineJavaScript %script#js-authenticate-token-2fa-authenticated{ type: "text/template" } diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml index dc4511a8159..f8a03f085ff 100644 --- a/app/views/authentication/_register.html.haml +++ b/app/views/authentication/_register.html.haml @@ -1,4 +1,4 @@ -- if Feature.enabled?(:webauthn) && Feature.enabled?(:webauthn_without_totp) +- if Feature.enabled?(:webauthn_without_totp) #js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: target_path, webauthn_error: @webauthn_error) } - else #js-register-token-2fa diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index b49f1aa061a..a818f8a5c26 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -24,6 +24,7 @@ order_by: 'last_activity_at', group_id: group_id, user_id: user_id, + with_shared: true.to_s, include_subgroups: true.to_s, membership: true.to_s, selected: @cluster.management_project_id } } diff --git a/app/views/clusters/clusters/connect.html.haml b/app/views/clusters/clusters/connect.html.haml index 82750974803..a6e1837badf 100644 --- a/app/views/clusters/clusters/connect.html.haml +++ b/app/views/clusters/clusters/connect.html.haml @@ -1,4 +1,3 @@ -- @content_class = 'limit-container-width' unless fluid_layout - add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path - breadcrumb_title _('Connect a cluster') - page_title _('Connect a Kubernetes Cluster') diff --git a/app/views/clusters/clusters/new_cluster_docs.html.haml b/app/views/clusters/clusters/new_cluster_docs.html.haml index bff371b8d51..72c70f35e22 100644 --- a/app/views/clusters/clusters/new_cluster_docs.html.haml +++ b/app/views/clusters/clusters/new_cluster_docs.html.haml @@ -1,4 +1,3 @@ -- @content_class = 'limit-container-width' unless fluid_layout - add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path - breadcrumb_title _('Create a cluster') - page_title _('Create a Kubernetes cluster') diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 58e0ef96333..7660a8e4ac1 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -1,4 +1,3 @@ -- @content_class = "limit-container-width" unless fluid_layout - add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path - breadcrumb_title @cluster.name - page_title _('Kubernetes Cluster') diff --git a/app/views/dashboard/_projects_nav.html.haml b/app/views/dashboard/_projects_nav.html.haml index 87bd5209fdf..4367d201190 100644 --- a/app/views/dashboard/_projects_nav.html.haml +++ b/app/views/dashboard/_projects_nav.html.haml @@ -3,8 +3,8 @@ = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav' }) do = gl_tab_link_to dashboard_projects_path, { item_active: is_your_projects_path, class: 'shortcuts-activity', data: { placement: 'right' } } do = s_("ProjectList|Yours") - = gl_tab_counter_badge(limited_counter_with_delimiter(@total_user_projects_count)) + = gl_tab_counter_badge(limited_counter_with_delimiter(@all_user_projects)) = gl_tab_link_to starred_dashboard_projects_path, { data: { placement: 'right' } } do = s_("ProjectList|Starred") - = gl_tab_counter_badge(limited_counter_with_delimiter(@total_starred_projects_count)) + = gl_tab_counter_badge(limited_counter_with_delimiter(@all_starred_projects)) = render_if_exists "dashboard/removed_projects_tab" diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 0ddee68e93f..ff9f13ba2de 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -1,12 +1,9 @@ -- @hide_top_links = true - = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") = render_dashboard_ultimate_trial(current_user) -- page_title _("Activity") -- header_title _("Activity"), activity_dashboard_path +- page_title _("Activity") = render "projects/last_push" = render 'dashboard/activity_head' diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index fdfc2c5adb8..7f004e405a7 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,6 +1,4 @@ -- @hide_top_links = true - page_title _("Groups") -- header_title _("Groups"), dashboard_groups_path = render_dashboard_ultimate_trial(current_user) = render 'dashboard/groups_head' diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index 0933f6d6a94..7e77b31499a 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -1,4 +1,3 @@ -- @hide_top_links = true - page_title _("Issues") - @breadcrumb_link = issues_dashboard_path(assignee_username: current_user.username) - add_page_specific_style 'page_bundles/issuable_list' diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 712f987a783..eb4ce46412b 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -1,4 +1,3 @@ -- @hide_top_links = true - page_title _("Merge requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) - add_page_specific_style 'page_bundles/issuable_list' diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index 2556791da12..682dfa8458e 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -1,6 +1,4 @@ -- @hide_top_links = true - page_title _('Milestones') -- header_title _('Milestones'), dashboard_milestones_path - add_page_specific_style 'page_bundles/milestone' .page-title-holder.d-flex.align-items-center diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml index 6db018d72da..dafa3b4dc8d 100644 --- a/app/views/dashboard/projects/_starred_empty_state.html.haml +++ b/app/views/dashboard/projects/_starred_empty_state.html.haml @@ -1,7 +1,7 @@ .row.empty-state .col-12 - .svg-content.svg-250 - = image_tag 'illustrations/starred_empty.svg' + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-projects-starred-md.svg' .text-content %h4.gl-text-center = s_("StarredProjectsEmptyState|You don't have starred projects yet.") diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index f427c347dd3..140bc6e06c3 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -1,12 +1,9 @@ -- @hide_top_links = true - = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") = render_dashboard_ultimate_trial(current_user) -- page_title _("Projects") -- header_title _("Projects"), dashboard_projects_path +- page_title _("Projects") - add_page_specific_style 'page_bundles/dashboard_projects' = render "projects/last_push" diff --git a/app/views/dashboard/projects/shared/_common.html.haml b/app/views/dashboard/projects/shared/_common.html.haml index 17dcb072152..f6f67ad7712 100644 --- a/app/views/dashboard/projects/shared/_common.html.haml +++ b/app/views/dashboard/projects/shared/_common.html.haml @@ -1,6 +1,4 @@ -- @hide_top_links = true -- breadcrumb_title _("Projects") -- header_title _("Projects"), dashboard_projects_path +- page_title _("Projects") = render_dashboard_ultimate_trial(current_user) diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 42386e5b9cc..667ed617849 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -1,6 +1,4 @@ -- @hide_top_links = true -- page_title _("Snippets") -- header_title _("Snippets"), dashboard_snippets_path +- page_title _("Snippets") - button_path = new_snippet_path if can?(current_user, :create_snippet) = render 'dashboard/snippets_head' diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 7ca89651282..ca6b1071f03 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -1,9 +1,8 @@ -- @hide_top_links = true - page_title _("To-Do List") -- header_title _("To-Do List"), dashboard_todos_path = render_two_factor_auth_recovery_settings_check = render_dashboard_ultimate_trial(current_user) += render_if_exists 'dashboard/todos/saml_reauth_notice' - add_page_specific_style 'page_bundles/todos' - add_page_specific_style 'page_bundles/issuable' diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 4b586b2f580..5af247703f6 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -7,6 +7,8 @@ .form-group = f.label :email = f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', title: _('Please provide a valid email address.'), value: nil + .form-text.gl-text-secondary + = _('Requires your primary GitLab email address.') %div - if recaptcha_enabled? diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 439a2fc4d96..150f61a97e0 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -7,12 +7,12 @@ .gl-display-flex.gl-flex-wrap{ class: restyle_login_page_enabled ? 'gl-justify-content-center' : 'gl-justify-content-between' } - providers.each do |provider| - has_icon = provider_has_icon?(provider) - = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' unless restyle_login_page_enabled}", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do + = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", data: { qa_selector: "#{qa_selector_for_provider(provider)}" }, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{'gl-w-full' unless restyle_login_page_enabled}", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do - if has_icon = provider_image_tag(provider) %span.gl-button-text = label_for_provider(provider) - unless hide_remember_me - = render Pajamas::CheckboxTagComponent.new(name: 'remember_me', value: nil) do |c| + = render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c| = c.label do = _('Remember me') diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 57cd819cb89..23bb7170d87 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,12 +1,13 @@ - max_first_name_length = max_last_name_length = 127 - omniauth_providers_placement ||= :bottom - borderless ||= false +- form_resource_name = "new_#{resource_name}" .gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') } - if show_omniauth_providers && omniauth_providers_placement == :top = render 'devise/shared/signup_omniauth_providers_top' - = gitlab_ui_form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f| + = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f| .devise-errors = render 'devise/shared/error_messages', resource: resource - if Gitlab::CurrentSettings.invisible_captcha_enabled @@ -52,16 +53,12 @@ %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?') -# This is used for providing entry to Jihu on email verification = render_if_exists 'devise/shared/signup_email_additional_info' - .form-group.gl-mb-5#password-strength + .form-group.gl-mb-5 = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" - = f.password_field :password, - 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 } + %input.form-control.gl-form-input.js-password{ data: { resource_name: form_resource_name, + minimum_password_length: @minimum_password_length, + qa_selector: 'new_user_password_field' } } + %p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } = render_if_exists 'shared/password_requirements_list' = render_if_exists 'devise/shared/phone_verification', form: f %div diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index a96c8d6358b..99428708b20 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -5,7 +5,7 @@ = _("Register with:") .gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto - providers.each do |provider| - = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do + = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text @@ -15,7 +15,7 @@ = _("Create an account using:") .gl-display-flex.gl-justify-content-between.gl-flex-wrap - providers.each do |provider| - = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do + = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text diff --git a/app/views/doorkeeper/applications/edit.html.haml b/app/views/doorkeeper/applications/edit.html.haml index c48e2cd4db0..f1c3ea6aab1 100644 --- a/app/views/doorkeeper/applications/edit.html.haml +++ b/app/views/doorkeeper/applications/edit.html.haml @@ -1,5 +1,4 @@ - page_title _("Edit"), @application.name, _("Applications") -- @content_class = "limit-container-width" unless fluid_layout %h1.page-title.gl-font-size-h-display= _('Edit application') = render 'shared/doorkeeper/applications/form', url: doorkeeper_submit_path(@application) diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index d087d85a94e..5fc1384f6ee 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Applications"), oauth_applications_path - breadcrumb_title @application.name - page_title @application.name, _("Applications") -- @content_class = "limit-container-width" unless fluid_layout %h1.page-title.gl-font-size-h-display = _("Application: %{name}") % { name: @application.name } diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml index e1b7804c5a7..eb6154a446e 100644 --- a/app/views/events/_events.html.haml +++ b/app/views/events/_events.html.haml @@ -1,4 +1,4 @@ -- illustration_path = 'illustrations/profile-page/activity.svg' +- illustration_path = 'illustrations/empty-state/empty-activity-md.svg' - current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!') - primary_button_label = _('New group') - primary_button_link = new_group_path diff --git a/app/views/explore/_head.html.haml b/app/views/explore/_head.html.haml deleted file mode 100644 index eefc797cf03..00000000000 --- a/app/views/explore/_head.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -.explore-title.text-center - %h2 - = _("Explore GitLab") - %p.lead - = _("Discover projects, groups and snippets. Share your projects with others") - %br diff --git a/app/views/explore/projects/_head.html.haml b/app/views/explore/projects/_head.html.haml new file mode 100644 index 00000000000..605d85f49e0 --- /dev/null +++ b/app/views/explore/projects/_head.html.haml @@ -0,0 +1,11 @@ +- breadcrumb_title _("Projects") +- page_title _("Explore projects") + += render_dashboard_ultimate_trial(current_user) + +.page-title-holder.gl-display-flex.gl-align-items-center + %h1.page-title.gl-font-size-h-display= page_title + .page-title-controls + - if current_user&.can_create_project? + = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do + = _("New project") diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index 53b252db4fe..50d79eeefdb 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -1,15 +1,5 @@ -- breadcrumb_title _("Projects") -- page_title _("Explore projects") - page_canonical_link explore_projects_url -= render_dashboard_ultimate_trial(current_user) - -.page-title-holder.gl-display-flex.gl-align-items-center - %h1.page-title.gl-font-size-h-display= page_title - .page-title-controls - - if current_user&.can_create_project? - = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do - = _("New project") - += render 'explore/projects/head' = render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml index e13768a3ccb..1b65cdb0c56 100644 --- a/app/views/explore/projects/page_out_of_bounds.html.haml +++ b/app/views/explore/projects/page_out_of_bounds.html.haml @@ -1,14 +1,4 @@ -- @hide_top_links = true -- page_title _("Projects") -- header_title _("Projects"), dashboard_projects_path - -= render_dashboard_ultimate_trial(current_user) - -- if current_user - = render 'dashboard/projects_head', project_tab_filter: :explore -- else - = render 'explore/head' - += render 'explore/projects/head' = render 'explore/projects/nav' .nothing-here-block diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index c765c086027..8840a2dc0e3 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -1,14 +1,3 @@ -- @hide_top_links = true -- page_title _("Explore projects") -- header_title _("Projects"), dashboard_projects_path - -= render_dashboard_ultimate_trial(current_user) - -.page-title-holder.gl-display-flex.gl-align-items-center - %h1.page-title.gl-font-size-h-display= page_title - .page-title-controls - = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do - = _("New project") - += render 'explore/projects/head' = render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index 043189315b4..8840a2dc0e3 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -1,15 +1,3 @@ -- @hide_top_links = true -- page_title _("Explore projects") -- header_title _("Projects"), dashboard_projects_path - -= render_dashboard_ultimate_trial(current_user) - -.page-title-holder.gl-display-flex.gl-align-items-center - %h1.page-title.gl-font-size-h-display= page_title - .page-title-controls - - if current_user&.can_create_project? - = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do - = _("New project") - += render 'explore/projects/head' = render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml index fa1a9d2cca4..b6b409967b0 100644 --- a/app/views/groups/_flash_messages.html.haml +++ b/app/views/groups/_flash_messages.html.haml @@ -1,2 +1,2 @@ = content_for :flash_message do - = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] + = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class] diff --git a/app/views/groups/_group_readme.html.haml b/app/views/groups/_group_readme.html.haml new file mode 100644 index 00000000000..724e82594e6 --- /dev/null +++ b/app/views/groups/_group_readme.html.haml @@ -0,0 +1,3 @@ +- return unless show_group_readme?(group) + +#js-group-readme{ data: group_readme_app_data(group.group_readme) } diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index 178d8980ab8..23d397faaf5 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -1,5 +1,4 @@ - page_title _("Dependency Proxy") -- @content_class = "limit-container-width" unless fluid_layout #js-dependency-proxy{ data: { group_path: @group.full_path, no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'), diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 0c416d57b75..84b8c7b6e66 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _("General settings") - page_title _("General settings") -- @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? = render 'shared/namespaces/cascading_settings/lock_popovers' @@ -17,7 +16,7 @@ .settings-content = render 'groups/settings/general' -%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content' } } +%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content', testid: 'permissions-settings' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Permissions and group features') diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index a2a5f519221..04bf3f98a1e 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,9 +1,6 @@ - add_page_specific_style 'page_bundles/members' - page_title _('Group members') -= content_for :page_level_alert do - = render_if_exists 'shared/unlimited_members_during_trial_alert', group: @group.root_ancestor - .row.gl-mt-3 .col-lg-12 .gl-display-flex.gl-flex-wrap diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml index 59ad29ccabd..3f9073e358d 100644 --- a/app/views/groups/harbor/repositories/index.html.haml +++ b/app/views/groups/harbor/repositories/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Harbor Registry") -- @content_class = "limit-container-width" unless fluid_layout #js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), diff --git a/app/views/groups/imports/show.html.haml b/app/views/groups/imports/show.html.haml index 9cfb58da7e4..6a5c266f74d 100644 --- a/app/views/groups/imports/show.html.haml +++ b/app/views/groups/imports/show.html.haml @@ -1,5 +1,4 @@ - page_title _('Import in progress') -- @content_class = "limit-container-width" unless fluid_layout .save-group-loader .center diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index a5cbc443fa4..1d306d4d3b8 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -1,4 +1,4 @@ -- @hide_breadcrumbs = true +- @hide_top_bar = true - @hide_top_links = true - page_title _('New Group') - header_title _("Groups"), dashboard_groups_path @@ -6,8 +6,9 @@ .group-edit-container - .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s, groups_url: dashboard_groups_url }.merge(subgroup_creation_data(@group), - verification_for_group_creation_data) } + .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s, + root_path: root_path, + groups_url: dashboard_groups_url }.merge(subgroup_creation_data(@group)) } .row{ 'v-cloak': true } #create-group-pane.tab-pane diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml index 1c0627779ec..b6cf26c3677 100644 --- a/app/views/groups/packages/index.html.haml +++ b/app/views/groups/packages/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Package Registry") -- @content_class = "limit-container-width" unless fluid_layout .row .col-12 @@ -10,4 +9,5 @@ empty_list_illustration: image_path('illustrations/no-packages.svg'), npm_instance_url: package_registry_instance_url(:npm), project_list_url: '', + settings_path: show_group_package_registry_settings(@group) ? group_settings_packages_and_registries_path(@group) : '', group_list_url: group_packages_path(@group) } } diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index aaa42aaea3a..f665b1f71f3 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _("Projects") - page_title _("Projects") -- @content_class = "limit-container-width" unless fluid_layout = render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 js-search-settings-section' }, header_options: { class: 'gl-display-flex' }, body_options: { class: 'gl-py-0' }) do |c| - c.header do diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index efd2e53e100..c906beb631b 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Container Registry") -- @content_class = "limit-container-width" unless fluid_layout - add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true, sort: nil}) %section diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml index 7e98f6035a6..d619635d3e0 100644 --- a/app/views/groups/runners/index.html.haml +++ b/app/views/groups/runners/index.html.haml @@ -1,3 +1,3 @@ - page_title s_('Runners|Runners') -#js-group-runners{ data: group_runners_data_attributes(@group).merge({ registration_token: @group_runner_registration_token }) } +#js-group-runners{ data: group_runners_data_attributes(@group).merge({ registration_token: @group_runner_registration_token, new_runner_path: @group_new_runner_path }) } diff --git a/app/views/groups/runners/new.html.haml b/app/views/groups/runners/new.html.haml new file mode 100644 index 00000000000..12e7e458a79 --- /dev/null +++ b/app/views/groups/runners/new.html.haml @@ -0,0 +1,5 @@ +- add_to_breadcrumbs _('Runners'), group_runners_path(@group) +- breadcrumb_title s_('Runners|New') +- page_title s_('Runners|Create a group runner') + +#js-group-new-runner{ data: { legacy_registration_token: @group_runner_registration_token, group_id: @group.to_global_id } } diff --git a/app/views/groups/runners/register.html.haml b/app/views/groups/runners/register.html.haml new file mode 100644 index 00000000000..fdee1675475 --- /dev/null +++ b/app/views/groups/runners/register.html.haml @@ -0,0 +1,7 @@ +- runner_name = "##{@runner.id} (#{@runner.short_sha})" +- breadcrumb_title s_('Runners|Register') +- page_title s_('Runners|Register'), "##{@runner.id} (#{@runner.short_sha})" +- add_to_breadcrumbs _('Runners'), group_runners_path(@group) +- add_to_breadcrumbs runner_name, register_group_runner_path(@runner) + +#js-group-register-runner{ data: { runner_id: @runner.id, runners_path: group_runners_path(@group) } } diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 5258854c931..8c73fc95544 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -19,6 +19,11 @@ = f.label :description, s_('Groups|Group description (optional)'), class: 'label-bold' = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 + .row.gl-mt-3 + .form-group.col-md-5 + = f.label :description, s_('Groups|Group README'), class: 'label-bold' + #js-group-settings-readme{ data: group_settings_readme_app_data(@group) } + = render 'shared/repository_size_limit_setting_registration_features_cta', form: f = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index a18789b52a3..32ef830b6cb 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -30,13 +30,15 @@ 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 + - unless Feature.enabled?(:always_perform_delayed_deletion) + = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group = render 'groups/settings/ip_restriction_registration_features_cta', f: f = render_if_exists 'groups/settings/ip_restriction', f: f, group: @group = render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group - if @group.licensed_feature_available?(:group_wikis) = render_if_exists 'groups/settings/wiki', f: f, group: @group = render 'groups/settings/lfs', f: f + = render_if_exists 'groups/settings/code_suggestions', f: f, group: @group = render 'groups/settings/git_access_protocols', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml index 309633471a5..8435f32db49 100644 --- a/app/views/groups/settings/access_tokens/index.html.haml +++ b/app/views/groups/settings/access_tokens/index.html.haml @@ -2,7 +2,6 @@ - page_title _('Group Access Tokens') - type = _('group access token') - type_plural = _('group access tokens') -- @content_class = 'limit-container-width' unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4 diff --git a/app/views/groups/settings/applications/edit.html.haml b/app/views/groups/settings/applications/edit.html.haml index ee71fd5d886..0dec3906bf2 100644 --- a/app/views/groups/settings/applications/edit.html.haml +++ b/app/views/groups/settings/applications/edit.html.haml @@ -1,5 +1,4 @@ - page_title _("Edit"), @application.name, _("Group applications") -- @content_class = "limit-container-width" unless fluid_layout %h1.page-title.gl-font-size-h-display= _('Edit group application') = render 'shared/doorkeeper/applications/form', url: group_settings_application_path(@group, @application) diff --git a/app/views/groups/settings/applications/show.html.haml b/app/views/groups/settings/applications/show.html.haml index e24aa993b26..06c678b1187 100644 --- a/app/views/groups/settings/applications/show.html.haml +++ b/app/views/groups/settings/applications/show.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Group applications"), group_settings_applications_path(@group) - breadcrumb_title @application.name - page_title @application.name, _("Group applications") -- @content_class = "limit-container-width" unless fluid_layout %h1.page-title.gl-font-size-h-display = _("Group application: %{name}") % { name: @application.name } diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml index ec99ceb5f8d..93140de4dfa 100644 --- a/app/views/groups/settings/integrations/index.html.haml +++ b/app/views/groups/settings/integrations/index.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title s_('Integrations|Group-level integration management') - page_title s_('Integrations|Group-level integration management') -- @content_class = 'limit-container-width' unless fluid_layout %section.js-search-settings-section %h3= s_('Integrations|Group-level integration management') 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 faed486b20f..374ae9777a5 100644 --- a/app/views/groups/settings/packages_and_registries/show.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _('Packages and registries settings') - page_title _('Packages and registries settings') -- @content_class = 'limit-container-width' unless fluid_layout %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/show.html.haml b/app/views/groups/settings/repository/show.html.haml index c6bf2d66683..a6222f39092 100644 --- a/app/views/groups/settings/repository/show.html.haml +++ b/app/views/groups/settings/repository/show.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _('Repository Settings') - page_title _('Repository') -- @content_class = "limit-container-width" unless fluid_layout - if can?(current_user, :admin_group, @group) - deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.') diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 7983274f319..8d7a7dd6b1b 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,5 +1,4 @@ -- @content_class = "limit-container-width" unless fluid_layout -- page_itemtype 'https://schema.org/Organization' +- page_itemtype 'https://schema.org/Organization' - @skip_current_level_breadcrumb = true - add_page_specific_style 'page_bundles/group' @@ -29,3 +28,5 @@ = render_if_exists 'groups/group_activity_analytics', group: @group #js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) } + += render partial: 'groups/group_readme', locals: { group: @group } diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index 4d2186a1352..8545b5fd71d 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -5,6 +5,7 @@ - paginatable = local_assigns.fetch(:paginatable, false) - default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path - cancel_path = local_assigns.fetch(:cancel_path, nil) +- details_path = local_assigns.fetch(:details_path, nil) - provider_title = Gitlab::ImportSources.title(local_assigns.fetch(:provider)) - optional_stages = local_assigns.fetch(:optional_stages, []) @@ -19,6 +20,7 @@ default_target_namespace: default_namespace_path, import_path: url_for([:import, provider, { format: :json }]), cancel_path: cancel_path, + details_path: details_path, filterable: filterable.to_s, paginatable: paginatable.to_s, optional_stages: optional_stages.to_json }.merge(extra_data) } diff --git a/app/views/import/github/details.html.haml b/app/views/import/github/details.html.haml new file mode 100644 index 00000000000..9056af1f129 --- /dev/null +++ b/app/views/import/github/details.html.haml @@ -0,0 +1,4 @@ +- add_to_breadcrumbs _('Create a new project'), new_project_path +- page_title s_('Import|GitHub import details') + +.js-import-details diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index 4a9f8be35c3..45b5a9408be 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -11,4 +11,5 @@ provider: 'github', paginatable: paginatable, default_namespace: @namespace, cancel_path: cancel_import_github_path, + details_path: details_import_github_path, optional_stages: Gitlab::GithubImport::Settings.stages_array diff --git a/app/views/jira_connect/branches/new.html.haml b/app/views/jira_connect/branches/new.html.haml index 482012b2848..bb27e89abb9 100644 --- a/app/views/jira_connect/branches/new.html.haml +++ b/app/views/jira_connect/branches/new.html.haml @@ -1,4 +1,3 @@ -- @hide_breadcrumbs = true - @hide_top_links = true - @content_class = 'limit-container-width' - page_title _('Create branch') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 2dd6eab2e17..c608569e22c 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -1,6 +1,7 @@ - page_description brand_title unless page_description - site_name = _('GitLab') -%head{ prefix: "og: http://ogp.me/ns#" } +- omit_og = sign_in_with_redirect? +%head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } } %meta{ charset: "utf-8" } %title= page_title(site_name) @@ -64,22 +65,23 @@ = yield :project_javascripts - -# Open Graph - http://ogp.me/ - %meta{ property: 'og:type', content: "object" } - %meta{ property: 'og:site_name', content: site_name } - %meta{ property: 'og:title', content: page_title } - %meta{ property: 'og:description', content: page_description } - %meta{ property: 'og:image', content: page_image } - %meta{ property: 'og:image:width', content: '64' } - %meta{ property: 'og:image:height', content: '64' } - %meta{ property: 'og:url', content: request.base_url + request.fullpath } - - -# Twitter Card - https://dev.twitter.com/cards/types/summary - %meta{ property: 'twitter:card', content: "summary" } - %meta{ property: 'twitter:title', content: page_title } - %meta{ property: 'twitter:description', content: page_description } - %meta{ property: 'twitter:image', content: page_image } - = page_card_meta_tags + - unless omit_og + -# Open Graph - http://ogp.me/ + %meta{ property: 'og:type', content: "object" } + %meta{ property: 'og:site_name', content: site_name } + %meta{ property: 'og:title', content: page_title } + %meta{ property: 'og:description', content: page_description } + %meta{ property: 'og:image', content: page_image } + %meta{ property: 'og:image:width', content: '64' } + %meta{ property: 'og:image:height', content: '64' } + %meta{ property: 'og:url', content: request.base_url + request.fullpath } + + -# Twitter Card - https://dev.twitter.com/cards/types/summary + %meta{ property: 'twitter:card', content: "summary" } + %meta{ property: 'twitter:title', content: page_title } + %meta{ property: 'twitter:description', content: page_description } + %meta{ property: 'twitter:image', content: page_image } + = page_card_meta_tags %meta{ name: "description", content: page_description } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 74567af3554..1a647249eb7 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -6,7 +6,7 @@ - group = @parent_group || @group - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user) - - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel).to_json + - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url } } - if display_whats_new? @@ -14,7 +14,7 @@ - elsif defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" - .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } + .content-wrapper{ class: "#{@content_wrapper_class}" } .mobile-overlay = dispensable_render_if_exists 'layouts/header/verification_reminder' .alert-wrapper.gl-force-block-formatting-context @@ -34,10 +34,9 @@ = 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 - = yield :free_user_cap_alert = yield :group_invite_members_banner - - unless @hide_breadcrumbs - = render "layouts/nav/breadcrumbs" + - unless @hide_top_bar + = render "layouts/nav/top_bar" %div{ class: "#{container_class unless @no_container} #{@content_class}" } %main.content{ id: "content-body", **page_itemtype } = render "layouts/flash", extra_flash_class: 'limit-container-width' @@ -48,4 +47,4 @@ -# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/jh-team/gitlab-cn/-/issues/81) = render_if_exists "shared/footer/global_footer" -= render "layouts/nav/top_nav_responsive", class: 'layout-page content-wrapper-margin' unless show_super_sidebar? += render "layouts/nav/top_nav_responsive", class: 'layout-page' unless show_super_sidebar? diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml deleted file mode 100644 index daf2c582de2..00000000000 --- a/app/views/layouts/_search.html.haml +++ /dev/null @@ -1,42 +0,0 @@ -.search.search-form{ data: { track_label: "navbar_search", track_action: "activate_form_input", track_value: "" } } - = form_tag search_path, method: :get, class: 'form-inline form-control' do |_f| - .search-input-container - .search-input-wrap - .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: _('Search GitLab'), - class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', - spellcheck: false, - autocomplete: 'off', - data: { issues_path: issues_dashboard_path, - mr_path: merge_requests_dashboard_path, - qa_selector: 'search_term_field' }, - aria: { label: _('Search GitLab') } - %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } } - .dropdown-menu.dropdown-select{ data: { testid: 'dashboard-search-options' } } - = dropdown_content do - %ul - %li.dropdown-menu-empty-item - %a - = _('Loading...') - = dropdown_loading - = sprite_icon('search', css_class: 'search-icon') - = sprite_icon('close', css_class: 'clear-icon js-clear-input') - - = hidden_field_tag :group_id, search_context.for_group? ? search_context.group.id : '', class: 'js-search-group-options', data: search_context.group_metadata - = hidden_field_tag :project_id, search_context.for_project? ? search_context.project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: search_context.project_metadata - - - if search_context.for_project? || search_context.for_group? - = hidden_field_tag :scope, search_context.scope - = hidden_field_tag :search_code, search_context.code_search? - - - ref = search_context.ref if can?(current_user, :read_code, search_context.project) - = hidden_field_tag :snippets, search_context.for_snippets? - = hidden_field_tag :repository_ref, ref - = hidden_field_tag :nav_source, 'navbar' - - -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb - - if ENV['RAILS_ENV'] == 'test' - %noscript= button_tag 'Search' - .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, - :'data-autocomplete-project-id' => search_context.project.try(:id), - :'data-autocomplete-project-ref' => ref } diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml index 1ac5f0a8497..39f4a755340 100644 --- a/app/views/layouts/dashboard.html.haml +++ b/app/views/layouts/dashboard.html.haml @@ -1,5 +1,4 @@ -- page_title _("Dashboard") -- header_title _("Dashboard"), root_path unless header_title +- header_title _("Your work"), root_path - @left_sidebar = true - nav (@parent_group ? "group" : "your_work") diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 3532c6638ce..36a9a284e91 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/login' !!! 5 %html.devise-layout-html{ class: system_message_class } = render "layouts/head", { startup_filename: 'signin' } diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index cadba3f91e9..89aba85984f 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/login' !!! 5 %html.devise-layout-html{ lang: "en", class: system_message_class } = render "layouts/head" diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 40ec1ff199b..1f742279756 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -21,5 +21,6 @@ = dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert" = dispensable_render_if_exists "shared/free_user_cap_alert", source: @group += dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group = render template: base_layout || "layouts/application" diff --git a/app/views/layouts/header/_current_user_dropdown_item.html.haml b/app/views/layouts/header/_current_user_dropdown_item.html.haml index 3fded43ee4f..fa0a6364a15 100644 --- a/app/views/layouts/header/_current_user_dropdown_item.html.haml +++ b/app/views/layouts/header/_current_user_dropdown_item.html.haml @@ -1,7 +1,7 @@ .gl-font-weight-bold = current_user.name - if current_user.status&.busy? - %span.gl-font-weight-normal.gl-text-gray-500= s_("UserProfile|(Busy)") + = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning') = current_user.to_reference - if current_user.status .user-status.d-flex.align-items-center.gl-mt-2.gl-mr-0.gl-font-sm.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } } diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 6d000c3e9ad..7156a0e5931 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -8,7 +8,7 @@ .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 .title %span.gl-sr-only GitLab - = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do + = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do = brand_header_logo .gl-display-flex.gl-align-items-center - if Gitlab.com_and_canary? @@ -31,10 +31,7 @@ %ul.nav.navbar-nav.gl-w-full.gl-align-items-center %li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full - unless current_controller?(:search) - - if Feature.enabled?(:new_header_search) - = render 'layouts/header_search' - - else - = render 'layouts/search' + = render 'layouts/header_search' %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' } = link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body', @@ -106,7 +103,7 @@ = gl_badge_tag({ size: :sm, variant: :info }, { class: "js-todos-count gl-ml-n2 #{'hidden' if todos_pending_count == 0}", "aria-label": _("Todos count") }) do = todos_count_format(todos_pending_count) %li.nav-item.header-help.dropdown.d-none.d-md-block - = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown", track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation_top', track_experiment: 'cross_stage_fdm' } do + = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown", track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation_top' } do %span.gl-sr-only = s_('Nav|Help') = sprite_icon('question-o') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index f50df72afbc..38b9a9a5383 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -2,7 +2,6 @@ - if current_user_menu?(:help) %li = render 'layouts/header/gitlab_version' - = render_if_exists 'layouts/header/help_dropdown/cross_stage_fdm' = render 'layouts/header/whats_new_dropdown_item' %li = link_to _("Help"), help_path, data: {track_action: 'click_link', track_label: 'help', track_property: 'navigation_top'} diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml index b5cb8f2af37..83260377e72 100644 --- a/app/views/layouts/minimal.html.haml +++ b/app/views/layouts/minimal.html.haml @@ -8,10 +8,10 @@ = render 'peek/bar' = render "layouts/header/empty" .layout-page - .content-wrapper.content-wrapper-margin.gl-pt-6{ class: 'gl-md-pt-11!' } + .content-wrapper.gl-pt-6{ class: 'gl-md-pt-11!' } .alert-wrapper.gl-force-block-formatting-context = render "layouts/broadcast" - .limit-container-width{ class: container_class } + %div{ class: container_class } %main#content-body.content = render "layouts/flash" unless @hide_flash = yield diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml deleted file mode 100644 index 06dff99718c..00000000000 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ /dev/null @@ -1,28 +0,0 @@ -- container = @no_breadcrumb_container ? 'container-fluid' : container_class -- hide_top_links = @hide_top_links || false -- unless @skip_current_level_breadcrumb - - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) - -.gl-relative - .breadcrumbs{ class: [container, @content_class] } - .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) } - - if show_super_sidebar? - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { label: _('Expand sidebar') }, data: {toggle: 'tooltip', placement: 'right' } }) - - elsif defined?(@left_sidebar) - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } }) - %nav.breadcrumbs-links{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } } - %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list - - unless hide_top_links - = header_title - - if @breadcrumbs_extra_links - - @breadcrumbs_extra_links.each do |extra| - = breadcrumb_list_item link_to(extra[:text], extra[:link]) - = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after - - unless @skip_current_level_breadcrumb - %li{ data: { testid: 'breadcrumb-current-link', qa_selector: 'breadcrumb_current_link' } } - = link_to @breadcrumb_title, breadcrumb_title_link - -# haml-lint:disable InlineJavaScript - %script{ type: 'application/ld+json' } - :plain - #{schema_breadcrumb_json} - = yield :header_content diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml new file mode 100644 index 00000000000..a0e03c9c0cf --- /dev/null +++ b/app/views/layouts/nav/_top_bar.html.haml @@ -0,0 +1,14 @@ +- if show_super_sidebar? + - top_bar_class = 'top-bar-fixed container-fluid' + - top_bar_container_class = nil +- else + - top_bar_class = [@no_top_bar_container ? 'container-fluid' : container_class, @content_class] + - top_bar_container_class = 'gl-border-b' + +%div{ class: top_bar_class } + .top-bar-container.gl-display-flex.gl-align-items-center{ :class => top_bar_container_class } + - if show_super_sidebar? + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } }) + - elsif defined?(@left_sidebar) + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } }) + = render "layouts/nav/breadcrumbs/breadcrumbs" diff --git a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml new file mode 100644 index 00000000000..b5f067cf42f --- /dev/null +++ b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml @@ -0,0 +1,20 @@ +- hide_top_links = @hide_top_links || false +- unless @skip_current_level_breadcrumb + - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) + +%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } } + %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list + - unless hide_top_links + = header_title + - if @breadcrumbs_extra_links + - @breadcrumbs_extra_links.each do |extra| + = breadcrumb_list_item link_to(extra[:text], extra[:link]) + = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after + - unless @skip_current_level_breadcrumb + %li{ data: { testid: 'breadcrumb-current-link', qa_selector: 'breadcrumb_current_link' } } + = link_to @breadcrumb_title, breadcrumb_title_link + -# haml-lint:disable InlineJavaScript + %script{ type: 'application/ld+json' } + :plain + #{schema_breadcrumb_json} += yield :header_content diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 24b301fadce..bffc030dbd9 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,298 +1 @@ -%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation'), data: { qa_selector: 'admin_sidebar_content' } } - .nav-sidebar-inner-scroll - .context-header - = link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do - %span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', 's32'] } - = sprite_icon('admin', size: 18) - %span.sidebar-context-title - = _('Admin Area') - %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_overview_submenu_content' } } - = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics gitaly_servers cohorts], html_options: {class: 'home'}) do - = link_to admin_root_path, class: 'has-sub-items' do - .nav-icon-container - = sprite_icon('overview') - %span.nav-item-name - = _('Overview') - %ul.sidebar-sub-level-items - = nav_link(controller: %w[dashboard admin admin/projects users groups gitaly_servers cohorts], html_options: { class: "fly-out-top-item" }) do - = link_to admin_root_path do - %strong.fly-out-top-item-name - = _('Overview') - %li.divider.fly-out-top-item - = nav_link(controller: :dashboard, html_options: {class: 'home'}) do - = link_to admin_root_path, title: _('Overview') do - %span - = _('Dashboard') - = nav_link(controller: [:admin, 'admin/projects']) do - = link_to admin_projects_path, title: _('Projects') do - %span - = _('Projects') - = nav_link(controller: %w[users cohorts]) do - = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'admin_overview_users_link' } do - %span - = _('Users') - = nav_link(controller: :groups) do - = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'admin_overview_groups_link' } do - %span - = _('Groups') - = nav_link(controller: [:admin, 'admin/topics']) do - = link_to admin_topics_path, title: _('Topics') do - %span - = _('Topics') - = nav_link(controller: :gitaly_servers) do - = link_to admin_gitaly_servers_path, title: 'Gitaly Servers' do - %span - = _('Gitaly Servers') - - = nav_link(controller: %w[runners jobs]) do - = link_to admin_runners_path, class: 'has-sub-items' do - .nav-icon-container - = sprite_icon('rocket') - %span.nav-item-name - = _('CI/CD') - %ul.sidebar-sub-level-items - = nav_link(controller: %w[runners jobs], html_options: { class: "fly-out-top-item" }) do - = link_to admin_runners_path do - %strong.fly-out-top-item-name - = _('CI/CD') - %li.divider.fly-out-top-item - = nav_link(controller: :runners) do - = link_to admin_runners_path, title: _('Runners') do - %span - = _('Runners') - = nav_link(controller: :jobs) do - = link_to admin_jobs_path, title: _('Jobs') do - %span - = _('Jobs') - - = nav_link(controller: admin_analytics_nav_links) do - = link_to admin_dev_ops_reports_path, data: { qa_selector: 'admin_analytics_link' }, class: 'has-sub-items' do - .nav-icon-container - = sprite_icon('chart') - %span.nav-item-name - = _('Analytics') - - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_analytics_submenu_content' } } - = nav_link(controller: admin_analytics_nav_links, html_options: { class: "fly-out-top-item" }) do - = link_to admin_dev_ops_reports_path do - %strong.fly-out-top-item-name - = _('Analytics') - %li.divider.fly-out-top-item - = nav_link(controller: :dev_ops_report) do - = link_to admin_dev_ops_reports_path, title: _('DevOps Reports') do - %span - = _('DevOps Reports') - = nav_link(controller: :usage_trends) do - = link_to admin_usage_trends_path, title: _('Usage Trends') do - %span - = _('Usage Trends') - - = nav_link(controller: admin_monitoring_nav_links) do - = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_menu_link' }, class: 'has-sub-items' do - .nav-icon-container - = sprite_icon('monitor') - %span.nav-item-name - = _('Monitoring') - - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_monitoring_submenu_content' } } - = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" }) do - = link_to admin_system_info_path do - %strong.fly-out-top-item-name - = _('Monitoring') - %li.divider.fly-out-top-item - = nav_link(controller: :system_info) do - = link_to admin_system_info_path, title: _('System Info') do - %span - = _('System Info') - = nav_link(controller: :background_migrations) do - = link_to admin_background_migrations_path, title: _('Background Migrations') do - %span - = _('Background Migrations') - = nav_link(controller: :background_jobs) do - = link_to admin_background_jobs_path, title: _('Background Jobs') do - %span - = _('Background Jobs') - = nav_link(controller: :health_check) do - = link_to admin_health_check_path, title: _('Health Check') do - %span - = _('Health Check') - - if Gitlab::CurrentSettings.current_application_settings.grafana_enabled? - = nav_link do - = link_to Gitlab::CurrentSettings.current_application_settings.grafana_url, target: '_blank', title: _('Metrics Dashboard'), rel: 'noopener noreferrer' do - %span - = _('Metrics Dashboard') - = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar' - - = nav_link(controller: :broadcast_messages) do - = link_to admin_broadcast_messages_path do - .nav-icon-container - = sprite_icon('messages') - %span.nav-item-name - = _('Messages') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :broadcast_messages, html_options: { class: "fly-out-top-item" }) do - = link_to admin_broadcast_messages_path do - %strong.fly-out-top-item-name - = _('Messages') - - = nav_link(controller: [:hooks, :hook_logs]) do - = link_to admin_hooks_path do - .nav-icon-container - = sprite_icon('hook') - %span.nav-item-name - = _('System Hooks') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: [:hooks, :hook_logs], html_options: { class: "fly-out-top-item" }) do - = link_to admin_hooks_path do - %strong.fly-out-top-item-name - = _('System Hooks') - - = nav_link(controller: :applications) do - = link_to admin_applications_path do - .nav-icon-container - = sprite_icon('applications') - %span.nav-item-name - = _('Applications') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :applications, html_options: { class: "fly-out-top-item" }) do - = link_to admin_applications_path do - %strong.fly-out-top-item-name - = _('Applications') - - = nav_link(controller: :abuse_reports) do - = link_to admin_abuse_reports_path do - .nav-icon-container - = sprite_icon('slight-frown') - %span.nav-item-name - = _('Abuse Reports') - = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" }) do - = link_to admin_abuse_reports_path do - %strong.fly-out-top-item-name - = _('Abuse Reports') - = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm - - = render_if_exists 'layouts/nav/sidebar/licenses_link' - - - if instance_clusters_enabled? - = nav_link(controller: :clusters) do - = link_to admin_clusters_path do - .nav-icon-container - = sprite_icon('cloud-gear') - %span.nav-item-name - = _('Kubernetes') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" }) do - = link_to admin_clusters_path do - %strong.fly-out-top-item-name - = _('Kubernetes') - - - if anti_spam_service_enabled? - = nav_link(controller: :spam_logs) do - = link_to admin_spam_logs_path do - .nav-icon-container - = sprite_icon('spam') - %span.nav-item-name - = _('Spam Logs') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :spam_logs, html_options: { class: "fly-out-top-item" }) do - = link_to admin_spam_logs_path do - %strong.fly-out-top-item-name - = _('Spam Logs') - - = render_if_exists 'layouts/nav/sidebar/push_rules_link' - - = render_if_exists 'layouts/nav/ee/admin/geo_sidebar' - - = nav_link(controller: :deploy_keys) do - = link_to admin_deploy_keys_path do - .nav-icon-container - = sprite_icon('key') - %span.nav-item-name - = _('Deploy Keys') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :deploy_keys, html_options: { class: "fly-out-top-item" }) do - = link_to admin_deploy_keys_path do - %strong.fly-out-top-item-name - = _('Deploy Keys') - - = render_if_exists 'layouts/nav/sidebar/credentials_link' - - = nav_link(controller: :labels) do - = link_to admin_labels_path do - .nav-icon-container - = sprite_icon('labels') - %span.nav-item-name - = _('Labels') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :labels, html_options: { class: "fly-out-top-item" }) do - = link_to admin_labels_path do - %strong.fly-out-top-item-name - = _('Labels') - - = nav_link(controller: [:application_settings, :integrations, :appearances]) do - = link_to general_admin_application_settings_path, class: 'has-sub-items' do - .nav-icon-container - = sprite_icon('settings') - %span.nav-item-name{ data: { qa_selector: 'admin_settings_menu_link' } } - = _('Settings') - - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_settings_submenu_content' } } - -# This active_nav_link check is also used in `app/views/layouts/admin.html.haml` - = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" }) do - = link_to general_admin_application_settings_path do - %strong.fly-out-top-item-name - = _('Settings') - %li.divider.fly-out-top-item - = nav_link(path: 'application_settings#general') do - = link_to general_admin_application_settings_path, title: _('General'), data: { qa_selector: 'admin_settings_general_link' } do - %span - = _('General') - - = render_if_exists 'layouts/nav/sidebar/advanced_search', data: { qa_selector: 'admin_settings_advanced_search_link' } - - - if instance_level_integrations? - = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do - = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'admin_settings_integrations_link' } do - %span - = _('Integrations') - = nav_link(path: 'application_settings#repository') do - = link_to repository_admin_application_settings_path, title: _('Repository'), data: { qa_selector: 'admin_settings_repository_link' } do - %span - = _('Repository') - - if Gitlab.ee? && License.feature_available?(:custom_file_templates) - = nav_link(path: 'application_settings#templates') do - = link_to templates_admin_application_settings_path, title: _('Templates'), data: { qa_selector: 'admin_settings_templates_link' } do - %span - = _('Templates') - = nav_link(path: 'application_settings#ci_cd') do - = link_to ci_cd_admin_application_settings_path, title: _('CI/CD') do - %span - = _('CI/CD') - = nav_link(path: 'application_settings#reporting') do - = link_to reporting_admin_application_settings_path, title: _('Reporting') do - %span - = _('Reporting') - = nav_link(path: 'application_settings#metrics_and_profiling') do - = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), data: { qa_selector: 'admin_settings_metrics_and_profiling_link' } do - %span - = _('Metrics and profiling') - = nav_link(path: ['application_settings#service_usage_data']) do - = link_to service_usage_data_admin_application_settings_path, title: _('Service usage data') do - %span - = _('Service usage data') - = nav_link(path: 'application_settings#network') do - = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_link' } do - %span - = _('Network') - = nav_link(controller: :appearances) do - = link_to admin_application_settings_appearances_path do - %span - = _('Appearance') - = nav_link(path: 'application_settings#preferences') do - = link_to preferences_admin_application_settings_path, title: _('Preferences'), data: { qa_selector: 'admin_settings_preferences_link' } do - %span - = _('Preferences') - - = render 'shared/sidebar_toggle_button' += render partial: 'shared/nav/sidebar', object: Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil)) diff --git a/app/views/layouts/nav/sidebar/_search.html.haml b/app/views/layouts/nav/sidebar/_search.html.haml new file mode 100644 index 00000000000..956079c351a --- /dev/null +++ b/app/views/layouts/nav/sidebar/_search.html.haml @@ -0,0 +1 @@ +-# if this file is missing empty or not the old left menu throws error diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 09fa8575106..214b41d5ab6 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -10,6 +10,7 @@ - content_for :flash_message do = dispensable_render_if_exists "projects/storage_enforcement_alert", context: @project = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project + = dispensable_render_if_exists "projects/deprecate_license_check_alert", project: @project - content_for :project_javascripts do - project = @target_project || @project @@ -23,5 +24,6 @@ = dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert" = dispensable_render_if_exists "projects/free_user_cap_alert", project: @project += dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project = render template: "layouts/application" diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml index 4d0bb36d4b5..8cbea686d51 100644 --- a/app/views/layouts/signup_onboarding.html.haml +++ b/app/views/layouts/signup_onboarding.html.haml @@ -1,6 +1,7 @@ !!! 5 %html.devise-layout-html.navless{ class: system_message_class } - add_page_specific_style 'page_bundles/signup' + - add_page_specific_style 'page_bundles/login' = render "layouts/head" %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" diff --git a/app/views/layouts/simple_registration.html.haml b/app/views/layouts/simple_registration.html.haml index dc7ec25c96e..a68941b031f 100644 --- a/app/views/layouts/simple_registration.html.haml +++ b/app/views/layouts/simple_registration.html.haml @@ -1,6 +1,7 @@ !!! 5 %html{ lang: "en" } = render "layouts/head" + - add_page_specific_style 'page_bundles/login' %body.login-page.application.navless{ class: user_application_theme, data: { page: body_data_page } } = render "layouts/header/logo_with_title" = render "layouts/broadcast" diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index e396f38499a..ad566f262cf 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,7 +1,7 @@ - page_title _("Snippets") -- header_title _("Snippets"), dashboard_snippets_path +- header_title _("Your work"), root_path +- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path - snippets_upload_path = snippets_upload_path(@snippet, current_user) - - @left_sidebar = true - if current_user diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 032be73f70c..71c622d7a62 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -1,6 +1,6 @@ !!! 5 - add_page_specific_style 'page_bundles/terms' -- @hide_breadcrumbs = true +- @hide_top_bar = true - body_classes = [user_application_theme] %html{ lang: I18n.locale, class: page_class } = render "layouts/head" diff --git a/app/views/notify/access_token_created_email.html.haml b/app/views/notify/access_token_created_email.html.haml index 9eea8f44142..8216994f8fa 100644 --- a/app/views/notify/access_token_created_email.html.haml +++ b/app/views/notify/access_token_created_email.html.haml @@ -1,7 +1,7 @@ %p = _('Hi %{username}!') % { username: sanitize_name(@user.name) } %p - = html_escape(_('A new personal access token, named %{token_name}, has been created.')) % { token_name: @token_name } + = html_escape(_('A new personal access token, named %{code_start}%{token_name}%{code_end}, has been created.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe } %p - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } = html_escape(_('You can check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/new_achievement_email.html.haml b/app/views/notify/new_achievement_email.html.haml new file mode 100644 index 00000000000..f802684fb56 --- /dev/null +++ b/app/views/notify/new_achievement_email.html.haml @@ -0,0 +1,7 @@ +- namespace_link = link_to(@achievement.namespace.full_path, group_url(@achievement.namespace)) +- profile_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">' % { url: user_url(@user) } + +%p + = sprintf(s_("Achievements|%{namespace_link} awarded you the %{bold_start}%{achievement_name}%{bold_end} achievement!"), { namespace_link: namespace_link, achievement_name: @achievement.name, bold_start: '<b>', bold_end: '</b>' }).html_safe +%p + = sprintf(s_("Achievements|View your achievements on your %{link_start}profile%{link_end}."), { link_start: profile_link_start, link_end: '</a>' }).html_safe diff --git a/app/views/notify/new_achievement_email.text.erb b/app/views/notify/new_achievement_email.text.erb new file mode 100644 index 00000000000..6d66c1130bf --- /dev/null +++ b/app/views/notify/new_achievement_email.text.erb @@ -0,0 +1,4 @@ +<%= sprintf(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement!"), + { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name }) %> + +<%= s_("Achievements|View your achievements on your profile.") %> diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index dc0d8fc80b0..f37c8ffa515 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -2,5 +2,5 @@ Reassigned Issue <%= @issue.iid %> <%= url_for([@issue.project, @issue, { only_path: false }]) %> -Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> +Assignee changed<%= " from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/service_desk_custom_email_verification_email.text.erb b/app/views/notify/service_desk_custom_email_verification_email.text.erb new file mode 100644 index 00000000000..c3d49a67263 --- /dev/null +++ b/app/views/notify/service_desk_custom_email_verification_email.text.erb @@ -0,0 +1,4 @@ +This email is auto-generated. It verifies the ownership of the entered Service Desk custom email address and +correct functionality of email forwarding. + +Verification token: <%= @verification_token %> diff --git a/app/views/notify/service_desk_verification_result_email.html.haml b/app/views/notify/service_desk_verification_result_email.html.haml new file mode 100644 index 00000000000..d63177e4a42 --- /dev/null +++ b/app/views/notify/service_desk_verification_result_email.html.haml @@ -0,0 +1,58 @@ +- project_link = @service_desk_setting.project.web_url +- project_link_start = '<a href="%{project_link}" target="_blank" rel="noopener noreferrer" class="highlight">'.html_safe % { project_link: project_link } +- project_name = @service_desk_setting.project.human_name +- project_link_end = '</a>'.html_safe +- settings_link = edit_project_url(@service_desk_setting.project, anchor: 'js-service-desk') +- settings_link_start = '<a href="%{settings_link}" target="_blank" rel="noopener noreferrer" class="highlight">'.html_safe % { settings_link: settings_link } +- settings_link_end = '</a>'.html_safe +- strong_open = '<strong>'.html_safe +- strong_close = '</strong>'.html_safe +- email_address = @service_desk_setting.custom_email +- verify_email_address = @service_desk_setting.custom_email_address_for_verification +- code_open = '<code>'.html_safe +- code_end = '</code>'.html_safe + +%tr + %td.text-content + - if @verification.verified? + %h1{ :style => "margin-top:0;" } + = s_("Notify|Email successfully verified") + %p + = html_escape(s_('Notify|Your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} was verified successfully.')) % { email_address: email_address, project_link_start: project_link_start, project_name: project_name, project_link_end: project_link_end, strong_open: strong_open, strong_close: strong_close } + %p + = html_escape(s_('Notify|To enable the custom email address, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.')) % { settings_link_start: settings_link_start, settings_link_end: settings_link_end } + - else + %h1{ :style => "margin-top:0;" } + = s_("Notify|Email could not be verified") + %p + = html_escape(s_('Notify|We could not verify your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end}.')) % { email_address: email_address, project_link_start: project_link_start, project_name: project_name, project_link_end: project_link_end, strong_open: strong_open, strong_close: strong_close } + - if @verification.smtp_host_issue? + %p + %b + = s_('Notify|SMTP host issue:') + = s_('Notify|We were not able to make a connection to the specified host or there was an SSL issue.') + - if @verification.invalid_credentials? + %p + %b + = s_('Notify|Invalid credentials:') + = s_('Notify|The given credentials (username and password) were rejected by the SMTP server.') + - if @verification.mail_not_received_within_timeframe? + %p + %b + = s_('Notify|Verification email not received within timeframe:') + = html_escape(s_('Notify|We did not receive the verification email we sent out to %{strong_open}%{email_address}%{strong_close} in time.')) % { email_address: verify_email_address, strong_open: strong_open, strong_close: strong_close } + %p + = s_('Notify|We wait for 30 minutes for messages to appear in your instance\'s Service Desk inbox.') + = s_('Notify|Please check that your service provider supports email subaddressing and that you have set up email forwarding correctly.') + - if @verification.incorrect_from? + %p + %b + = html_escape(s_('Notify|Incorrect %{code_open}From%{code_end} header:')) % { code_open: code_open, code_end: code_end } + = html_escape(s_('Notify|Check your forwarding settings and make sure the original email sender remains in the %{code_open}From%{code_end} header.')) % { code_open: code_open, code_end: code_end } + - if @verification.incorrect_token? + %p + %b + = s_('Notify|Incorrect verification token:') + = s_('Notify|We could not verify that we received the email we sent to your email inbox.') + %p + = html_escape(s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.')) % { settings_link_start: settings_link_start, settings_link_end: settings_link_end } diff --git a/app/views/notify/service_desk_verification_result_email.text.erb b/app/views/notify/service_desk_verification_result_email.text.erb new file mode 100644 index 00000000000..a78e3b19d1e --- /dev/null +++ b/app/views/notify/service_desk_verification_result_email.text.erb @@ -0,0 +1,38 @@ +<% project_name = @service_desk_setting.project.human_name %> +<% email_address = @service_desk_setting.custom_email %> +<% verify_email_address = @service_desk_setting.custom_email_address_for_verification %> + +<% if @verification.verified? %> + <%= s_("Notify|Email successfully verified") %> + + <%= s_('Notify|Your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} was verified successfully.') % { email_address: email_address, project_link_start: '', project_name: project_name, project_link_end: '', strong_open: '', strong_close: '' } %> + + <%= s_('Notify|To enable the custom email address, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.') % { settings_link_start: '', settings_link_end: '' } %> +<% else %> + <%= s_("Notify|Email could not be verified") %> + + <%= s_('Notify|We could not verify your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end}.') % { email_address: email_address, project_link_start: '', project_name: project_name, project_link_end: '', strong_open: '', strong_close: '' } %> + + <% if @verification.smtp_host_issue? %> + <%= s_('Notify|SMTP host issue:') %> + <%= s_('Notify|We were not able to make a connection to the specified host or there was an SSL issue.') %> + <% elsif @verification.invalid_credentials? %> + <%= s_('Notify|Invalid credentials:') %> + <%= s_('Notify|The given credentials (username and password) were rejected by the SMTP server.') %> + <% elsif @verification.mail_not_received_within_timeframe? %> + <%= s_('Notify|Verification email not received within timeframe:') %> + <%= s_('Notify|We did not receive the verification email we sent out to %{strong_open}%{email_address}%{strong_close} in time.') % { email_address: verify_email_address, strong_open: '', strong_close: '' } %> + + <%= s_('Notify|We wait for 30 minutes for messages to appear in your instance\'s Service Desk inbox.') %> + + <%= s_('Notify|Please check that your service provider supports email subaddressing and that you have set up email forwarding correctly.') %> + <% elsif @verification.incorrect_from? %> + <%= s_('Notify|Incorrect %{code_open}From%{code_end} header:') % { code_open: '', code_end: '' } %> + <%= s_('Notify|Check your forwarding settings and make sure the original email sender remains in the %{code_open}From%{code_end} header.') % { code_open: '', code_end: '' } %> + <% elsif @verification.incorrect_token? %> + <%= s_('Notify|Incorrect verification token:') %> + <%= s_('Notify|We could not verify that we received the email we sent to your email inbox.') %> + <% end %> + + <%= s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.') % { settings_link_start: '', settings_link_end: '' } %> +<% end %> diff --git a/app/views/notify/service_desk_verification_triggered_email.html.haml b/app/views/notify/service_desk_verification_triggered_email.html.haml new file mode 100644 index 00000000000..f2174af9615 --- /dev/null +++ b/app/views/notify/service_desk_verification_triggered_email.html.haml @@ -0,0 +1,18 @@ +- user_name = '@' + @triggerer.username +- project_link = @service_desk_setting.project.web_url +- project_link_start = '<a href="%{project_link}" target="_blank" rel="noopener noreferrer" class="highlight">'.html_safe % { project_link: project_link} +- project_name = @service_desk_setting.project.human_name +- project_link_end = '</a>'.html_safe +- strong_open = '<strong>'.html_safe +- strong_close = '</strong>'.html_safe +- email_address = @service_desk_setting.custom_email +- smtp_host = @smtp_address + +%tr + %td.text-content + %p + = html_escape(s_('Notify|%{strong_open}%{user_name}%{strong_close} updated the custom email address credentials for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} and triggered the verification process.')) % { user_name: user_name, project_link_start: project_link_start, project_name: project_name, project_link_end: project_link_end, strong_open: strong_open, strong_close: strong_close } + %p + = html_escape(s_('Notify|The provided custom email address is %{strong_open}%{email_address}%{strong_close} and uses the SMTP host %{strong_open}%{smtp_host}%{strong_close}.')) % { email_address: email_address, smtp_host: smtp_host, strong_open: strong_open, strong_close: strong_close } + %p + = s_('Notify|If this was a mistake you can change these settings or deactivate the custom email address in the project settings.') diff --git a/app/views/notify/service_desk_verification_triggered_email.text.erb b/app/views/notify/service_desk_verification_triggered_email.text.erb new file mode 100644 index 00000000000..98c79e2d2f1 --- /dev/null +++ b/app/views/notify/service_desk_verification_triggered_email.text.erb @@ -0,0 +1,10 @@ +<% user_name = '@' + @triggerer.username %> +<% project_name = @service_desk_setting.project.human_name %> +<% email_address = @service_desk_setting.custom_email %> +<% smtp_host = @smtp_address %> + +<%= s_('Notify|%{strong_open}%{user_name}%{strong_close} updated the custom email address credentials for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} and triggered the verification process.') % { user_name: user_name, project_link_start: '', project_name: project_name, project_link_end: '', strong_open: '', strong_close: '' } %> + +<%= s_('Notify|The provided custom email address is %{strong_open}%{email_address}%{strong_close} and uses the SMTP host %{strong_open}%{smtp_host}%{strong_close}.') % { email_address: email_address, smtp_host: smtp_host, strong_open: '', strong_close: '' } %> + +<%= s_('Notify|If this was a mistake you can change these settings or deactivate the custom email address in the project settings.') %> diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml index 8914bfab336..cc58ad248e8 100644 --- a/app/views/peek/_bar.html.haml +++ b/app/views/peek/_bar.html.haml @@ -3,5 +3,6 @@ #js-peek{ data: { env: Peek.env, request_id: peek_request_id, stats_url: ENV.fetch('GITLAB_PERFORMANCE_BAR_STATS_URL', ''), - peek_url: "#{peek_routes_path}/results" }, + peek_url: "#{peek_routes_path}/results", + request_method: request.method, }, class: Peek.env } diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index bc0d615bb64..1065ddb59e6 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -1,5 +1,4 @@ - page_title _('Account') -- @content_class = "limit-container-width" unless fluid_layout - if current_user.ldap_user? = render Pajamas::AlertComponent.new(alert_options: { class: 'gl-my-5' }, diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index e9e6ca3ecce..e2b6008934c 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -1,5 +1,4 @@ - page_title _('Active Sessions') -- @content_class = "limit-container-width" unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 9997c8c4b4c..6072042001c 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,5 +1,4 @@ - page_title _('Authentication log') -- @content_class = "limit-container-width" unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 6de5f183981..8a1814e55c3 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,5 +1,4 @@ - page_title _('Chat') -- @content_class = "limit-container-width" unless fluid_layout - @hide_search_settings = true .row.gl-mt-5.js-search-settings-section diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml index 8ff2e6f34a0..bc30ccc5821 100644 --- a/app/views/profiles/chat_names/new.html.haml +++ b/app/views/profiles/chat_names/new.html.haml @@ -4,10 +4,10 @@ .gl-max-w-80.gl-mx-auto.gl-mt-6 = render Pajamas::CardComponent.new do |c| - c.header do - %h4.gl-m-0= s_('SlackIntegration|Authorize GitLab for Slack app (%{user}) to use your account?').html_safe % { user: @chat_name_params[:chat_name] } + %h4.gl-m-0= sprintf(s_('Integrations|Authorize %{integration_name} (%{user}) to use your account?'), { user: @chat_name_params[:chat_name], integration_name: @integration_name }) - c.body do %p - = s_('SlackIntegration|An application called GitLab for Slack app is requesting access to your GitLab account. This application was created by GitLab Inc.') + = sprintf(s_('Integrations|An application called %{integration_name} is requesting access to your GitLab account. This application was created by GitLab Inc.'), { integration_name: @integration_name }) %p = _('This application will be able to:') %ul diff --git a/app/views/profiles/comment_templates/index.html.haml b/app/views/profiles/comment_templates/index.html.haml new file mode 100644 index 00000000000..dd5b43aa802 --- /dev/null +++ b/app/views/profiles/comment_templates/index.html.haml @@ -0,0 +1,10 @@ +- page_title _('Comment Templates') + +#js-comment-templates-root.row.gl-mt-5{ data: { base_path: profile_comment_templates_path } } + .col-lg-4 + %h4.gl-mt-0 + = page_title + %p + = _('Comment templates can be used when creating comments inside issues, merge requests, and epics.') + .col-lg-8 + = gl_loading_icon(size: 'lg') diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index f4513d15a30..53db00c1638 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,5 +1,4 @@ - page_title _('Emails') -- @content_class = "limit-container-width" unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar @@ -56,7 +55,7 @@ %li= s_('Profiles|Public email') - if email.email == current_user.notification_email_or_default %li= s_('Profiles|Notification email') - .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-wrap-reverse.gl-gap-3 + .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3 - unless email.confirmed? - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default' diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 539a0cd1f0e..d018035c5d6 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,6 +1,5 @@ - page_title _('GPG Keys') - add_page_specific_style 'page_bundles/profile' -- @content_class = "limit-container-width" unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 69e92b9e508..9f1614d4f49 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,6 +1,5 @@ - page_title _('SSH Keys') - add_page_specific_style 'page_bundles/profile' -- @content_class = "limit-container-width" unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml index 09c16b0c038..f5fed281e20 100644 --- a/app/views/profiles/keys/show.html.haml +++ b/app/views/profiles/keys/show.html.haml @@ -1,5 +1,4 @@ - add_to_breadcrumbs _('SSH Keys'), profile_keys_path - breadcrumb_title @key.title - page_title @key.title, _('SSH Keys') -- @content_class = "limit-container-width" unless fluid_layout = render "key_details" diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index efc1e23d9b4..c757f774d4e 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -1,6 +1,5 @@ - add_page_specific_style 'page_bundles/notifications' - page_title _('Notifications') -- @content_class = "limit-container-width" unless fluid_layout %div - if @user.errors.any? diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 99c89dcebb4..b6d12bbefc6 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _('Edit Password') - page_title _('Password') -- @content_class = "limit-container-width" unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 82df6b1b2c7..bc3f63372a3 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -2,7 +2,6 @@ - page_title s_('AccessTokens|Personal Access Tokens') - type = _('personal access token') - type_plural = _('personal access tokens') -- @content_class = 'limit-container-width' unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 5f74a4c4427..c16469bbf79 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,6 +1,5 @@ - page_title _('Preferences') - add_page_specific_style 'page_bundles/profiles/preferences' -- @content_class = "limit-container-width" unless fluid_layout - user_theme_id = Gitlab::Themes.for_user(@user).id - user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id - user_fields = { theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json @@ -37,7 +36,7 @@ %h4.gl-mt-0 = s_('Preferences|Syntax highlighting theme') %p - = s_('Preferences|This setting allows you to customize the appearance of the syntax.') + = s_('Preferences|Customize the appearance of the syntax.') = succeed '.' do = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer' .col-lg-8.syntax-theme @@ -69,7 +68,7 @@ %h4.gl-mt-0 = s_('Preferences|Behavior') %p - = s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.') + = s_('Preferences|Customize the behavior of the system layout and default views.') = succeed '.' do = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer' .col-lg-8 @@ -79,7 +78,7 @@ = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select' .form-text.text-muted = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } - .js-listbox-input{ data: { label: s_('Preferences|Dashboard'), description: s_('Preferences|Choose what content you want to see by default on your dashboard.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } } + .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } } = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific diff --git a/app/views/profiles/saved_replies/index.html.haml b/app/views/profiles/saved_replies/index.html.haml deleted file mode 100644 index 2ae7a092249..00000000000 --- a/app/views/profiles/saved_replies/index.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- page_title _('Saved Replies') - -#js-saved-replies-root.row.gl-mt-5{ data: { base_path: profile_saved_replies_path } } - .col-lg-4 - %h4.gl-mt-0 - = page_title - %p - = _('Saved replies can be used when creating comments inside issues, merge requests, and epics.') - .col-lg-8 - = gl_loading_icon(size: 'lg') diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 659b218bdef..ba17078f4c4 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,7 +1,6 @@ - breadcrumb_title s_("Profiles|Edit Profile") - page_title s_("Profiles|Edit Profile") - add_page_specific_style 'page_bundles/profile' -- @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host = 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| @@ -158,8 +157,13 @@ %legend.col-form-label.col-form-label = s_("Profiles|Private contributions") = f.gitlab_ui_checkbox_component :include_private_contributions, - s_('Profiles|Include private contributions on my profile'), + s_('Profiles|Include private contributions on your profile'), help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") + %fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_("Profiles|Achievements") + = f.gitlab_ui_checkbox_component :achievements_enabled, + s_('Profiles|Display achievements on your profile') .row.js-hide-when-nothing-matches-search .col-lg-12 %hr diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 61fe6ba8e47..9cc7f6bdd49 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -1,8 +1,6 @@ - breadcrumb_title _('Two-Factor Authentication') - page_title _('Two-Factor Authentication'), _('Account') - add_to_breadcrumbs _('Account'), profile_account_path -- @content_class = "limit-container-width" unless fluid_layout -- webauthn_enabled = Feature.enabled?(:webauthn) .js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path } .row.gl-mt-3 @@ -68,7 +66,7 @@ %p = _('Set up a hardware device to enable two-factor authentication (2FA).') %p - - if webauthn_enabled && Feature.enabled?(:webauthn_without_totp) + - if Feature.enabled?(:webauthn_without_totp) = _("Not all browsers support WebAuthn. You must save your recovery codes after you first register a two-factor authenticator to be able to sign in, even from an unsupported browser.") - else = _("Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in, even from an unsupported browser.") @@ -134,7 +132,7 @@ dismissible: false) do |c| = c.body do = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' - .js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } } + .js-manage-two-factor-form{ data: { current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } } - else %p = _("Register a one-time password authenticator or a WebAuthn device first.") diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 6ac084b7749..5c7f83fc579 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,4 +1,3 @@ -- @no_breadcrumb_border = true - show_auto_devops_callout = show_auto_devops_callout?(@project) - is_project_overview = local_assigns.fetch(:is_project_overview, false) - ref = local_assigns.fetch(:ref) { current_ref } @@ -7,18 +6,17 @@ - if readme_path = @project.repository.readme_path - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") -#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Projects::BlameService::PER_PAGE } } - .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-2 +#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } } + .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-5 #js-last-commit.gl-m-auto = gl_loading_icon(size: 'md') - #js-code-owners + #js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } } .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch = render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview - - if project.forked? && Feature.enabled?(:fork_divergence_counts, @project.fork_source) - - #js-fork-info{ data: vue_fork_divergence_data(project, ref), project_id: @project.id } + - if project.forked? + #js-fork-info{ data: vue_fork_divergence_data(project, ref) } - if is_project_overview .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } } diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index 2d9f7e49ddc..dc0c9547901 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -10,5 +10,5 @@ - if show_auto_devops_callout?(@project) = render 'shared/auto_devops_callout' = render_if_exists 'projects/above_size_limit_warning', project: project - = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)] + = render_if_exists 'shared/shared_runners_minutes_limit', project: project = render_if_exists 'projects/terraform_banner', project: project diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 65fd02b291c..9cb5ec39de2 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,9 +1,8 @@ - empty_repo = @project.empty_repo? - show_auto_devops_callout = show_auto_devops_callout?(@project) - emails_disabled = @project.emails_disabled? -- cache_enabled = Feature.enabled?(:cache_home_panel, @project, type: :development) -.project-home-panel.js-show-on-project-root.gl-mt-2.gl-mb-5{ class: [("empty-project" if empty_repo)] } +.project-home-panel.js-show-on-project-root.gl-mt-4.gl-mb-5{ class: [("empty-project" if empty_repo)] } .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3.gl-gap-5 .home-panel-title-row.gl-display-flex.gl-align-items-center %div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' } @@ -25,28 +24,26 @@ %span.gl-ml-3.gl-mb-3 = render 'shared/members/access_request_links', source: @project - = cache_if(cache_enabled, [@project, @project.star_count, @project.forks_count, :buttons, current_user, @notification_setting], expires_in: 1.day) do - .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3 - - if current_user - - if current_user.admin? - = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'), - data: {toggle: 'tooltip', placement: 'top', container: 'body'} do - = sprite_icon('admin') - - if @notification_setting - .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } } + .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3 + - if current_user + - if current_user.admin? + = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'), + data: {toggle: 'tooltip', placement: 'top', container: 'body'} do + = sprite_icon('admin') + - if @notification_setting + .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } } - = render 'projects/buttons/star' - = render 'projects/buttons/fork' + = render 'projects/buttons/star' + = render 'projects/buttons/fork' - if can?(current_user, :read_code, @project) - = cache_if(cache_enabled, [@project, :read_code], expires_in: 1.minute) do - %nav.project-stats - - if @project.empty_repo? - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors - - else - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) + %nav.project-stats + - if @project.empty_repo? + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors + - else + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) .gl-my-3 - = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled + = render "shared/projects/topics", project: @project .home-panel-home-desc.mt-1 - if @project.description.present? .home-panel-description.text-break @@ -55,15 +52,6 @@ %button.btn.gl-button.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") - - if @project.forked? && Feature.disabled?(:fork_divergence_counts, @project.fork_source) - %p - - source = visible_fork_source(@project) - - if source - #{ s_('ForkedFromProjectPath|Forked from') } - = link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' } - - else - = s_('ForkedFromProjectPath|Forked from an inaccessible project.') - = render_if_exists "projects/home_mirror" - if @project.badges.present? diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index ed238dab4ff..dec3199ffe1 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -7,7 +7,6 @@ %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: '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 bfc1e77118a..260c2b2272e 100644 --- a/app/views/projects/_remove_fork.html.haml +++ b/app/views/projects/_remove_fork.html.haml @@ -7,6 +7,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: 'remove-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' + %strong= _('After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks.') .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 349cd88437f..7654677d8a8 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -12,7 +12,7 @@ enabled: "#{@project.service_desk_enabled}", incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled), custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled), - custom_email_enabled: "#{Gitlab::ServiceDeskEmail.enabled?}", + custom_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}", selected_template: "#{@project.service_desk_setting&.issue_template_key}", selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", diff --git a/app/views/projects/_terraform_banner.html.haml b/app/views/projects/_terraform_banner.html.haml index 881e4ccd9df..24711fc39d8 100644 --- a/app/views/projects/_terraform_banner.html.haml +++ b/app/views/projects/_terraform_banner.html.haml @@ -1,5 +1,3 @@ -- @content_class = "container-limited limit-container-width" unless fluid_layout - - if show_terraform_banner?(project) .container-fluid{ class: @content_class } .js-terraform-notification{ data: { terraform_image_path: image_path('illustrations/third-party-logos/ci_cd-template-logos/terraform.svg') } } diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index ee7ca9cd351..a56d398d3a0 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,15 +1,17 @@ - page_title _("Blame"), @blob.path, @ref - add_page_specific_style 'page_bundles/tree' -- if @streaming_enabled && total_extra_pages > 0 +- blame_streaming_url = blame_pages_streaming_url(@id, @project) + +- if @blame_mode.streaming? && @blame_pagination.total_extra_pages > 0 - content_for :startup_js do = javascript_tag do :plain window.blamePageStream = (() => { - const url = new URL("#{@blame_pages_url}"); + const url = new URL("#{blame_streaming_url}"); url.searchParams.set('page', 2); return fetch(url).then(response => response.body); })(); -- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page, total_extra_pages: total_extra_pages - 1, pages_url: @blame_pages_url } +- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_pagination.per_page, total_extra_pages: @blame_pagination.total_extra_pages - 1, pages_url: blame_streaming_url } #blob-content-holder.tree-holder.js-per-page{ data: dataset } = render "projects/blob/breadcrumb", blob: @blob, blame: true @@ -35,21 +37,20 @@ .blame-table-wrapper = render partial: 'page' - - if @streaming_enabled + - if @blame_mode.streaming? #blame-stream-container.blame-stream-container - - if @blame_pagination && @blame_pagination.total_pages > 1 + - if @blame_mode.pagination? && @blame_pagination.total_pages > 1 .gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100 - = render Pajamas::ButtonComponent.new(href: @entire_blame_path, size: :small, button_options: { class: 'gl-mt-3' }) do |c| + = render Pajamas::ButtonComponent.new(href: entire_blame_path(@id, @project, @blame_mode), size: :small, button_options: { class: 'gl-mt-3' }) do |c| = _('Show full blame') - - if @streaming_enabled + - if @blame_mode.streaming? #blame-stream-loading.blame-stream-loading .gradient = gl_loading_icon(size: 'sm') %span.gl-mx-2 = _('Loading full blame...') - - if @blame_pagination - = paginate(@blame_pagination, theme: "gitlab") - + - if @blame_mode.pagination? + = paginate(@blame_pagination.paginator, theme: "gitlab") diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 17d5ef69b76..453a60a62f4 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -10,19 +10,19 @@ %ul.blob-commit-info = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref - #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref } } + #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } } = render "projects/blob/auxiliary_viewer", blob: blob -#blob-content-holder.blob-content-holder.js-per-page{ data: { blame_per_page: Projects::BlameService::PER_PAGE } } +- if project.forked? + #js-fork-info{ data: vue_fork_divergence_data(project, ref) } + +#blob-content-holder.blob-content-holder.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } } - 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 !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, - project_path: @project.full_path, - target_branch: project.empty_repo? ? ref : @ref, - original_branch: @ref } } + #js-view-blob-app{ data: vue_blob_app_data(project, blob, ref) } = gl_loading_icon(size: 'md') - else %article.file-holder diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 528999f5c89..0f37ae8ad41 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -3,18 +3,20 @@ - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco') - add_page_specific_style 'page_bundles/editor' - - if @conflict = render Pajamas::AlertComponent.new(alert_options: { class: 'gl-mb-5 gl-mt-5' }, variant: :danger, dismissible: false) do |c| - - blob_url = project_blob_path(@project, @id) - - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do - - sprite_icon('external-link', css_class: 'gl-icon').html_safe - - blob_link_start = '<a href="%{url}" class="gl-link" target="_blank" rel="noopener noreferrer">'.html_safe % { url: blob_url } = c.body do - = _('Someone edited the file the same time you did. Please check out %{link_start}the file %{icon}%{link_end} and make sure your changes will not unintentionally remove theirs.').html_safe % { link_start: blob_link_start, link_end: '</a>'.html_safe , icon: external_link_icon } - + - blob_link_start = '<a href="%{url}" class="gl-link" target="_blank" rel="noopener noreferrer">'.html_safe + - link_end = '</a>'.html_safe + - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do + - sprite_icon('external-link', css_class: 'gl-icon').html_safe + - if @different_project + = _("Error: Can't edit this file. The fork and upstream project have diverged. %{link_start}Edit the file on the fork %{icon}%{link_end}, and create a merge request.").html_safe % {link_start: blob_link_start % { url: project_blob_path(@project_to_commit_into, @id) } , link_end: link_end, icon: external_link_icon } + - else + - blob_url = project_blob_path(@project, @id) + = _('Someone edited the file the same time you did. Please check out %{link_start}the file %{icon}%{link_end} and make sure your changes will not unintentionally remove theirs.').html_safe % { link_start: blob_link_start % { url: blob_url }, link_end: link_end , icon: external_link_icon } %h1.page-title.gl-font-size-h-display.blob-edit-page-title Edit file diff --git a/app/views/projects/branch_defaults/_branch_names_fields.html.haml b/app/views/projects/branch_defaults/_branch_names_fields.html.haml index 3bdb81f02ad..4e4a72c154f 100644 --- a/app/views/projects/branch_defaults/_branch_names_fields.html.haml +++ b/app/views/projects/branch_defaults/_branch_names_fields.html.haml @@ -10,6 +10,6 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Issue::MAX_BRANCH_TEMPLATE }) - - branch_name_help_link = help_page_path('user/project/merge_requests/creating_merge_requests.md', anchor: 'from-an-issue') + - branch_name_help_link = help_page_path('user/project/repository/branches/index.md', anchor: 'name-your-branch') = link_to _('What variables can I use?'), branch_name_help_link, target: "_blank" = render_if_exists 'projects/branch_defaults/branch_names_help' diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml index 6e70dc42776..605715e2899 100644 --- a/app/views/projects/branch_rules/_show.html.haml +++ b/app/views/projects/branch_rules/_show.html.haml @@ -3,7 +3,7 @@ - show_status_checks = @project.licensed_feature_available?(:external_status_checks) - show_approvers = @project.licensed_feature_available?(:merge_request_approvers) -%section.settings.no-animate#branch-rules{ class: ('expanded' if expanded) } +%section.settings.no-animate#branch-rules{ class: ('expanded' if expanded), data: { qa_selector: 'branch_rules_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch rules') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 91efd5ef048..86bed956bc4 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -7,22 +7,18 @@ = @error %h1.page-title.gl-font-size-h-display = _('New Branch') -%hr = form_tag namespace_project_branches_path, method: :post, id: "new-branch-form", class: "js-create-branch-form js-requires-input" do - .form-group.row - = label_tag :branch_name, _('Branch name'), class: 'col-form-label col-sm-2' - .col-sm-10 - = text_field_tag :branch_name, params[:branch_name], required: true, autofocus: true, class: 'form-control js-branch-name monospace' - .form-text.text-muted.text-danger.js-branch-name-error - .form-group.row - = label_tag :ref, _('Create from'), class: 'col-form-label col-sm-2' - .col-sm-auto.create-from - .js-new-branch-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } } - .form-text.text-muted - = _('Existing branch name, tag, or commit SHA') - .form-actions - = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do - = _('Create branch') - = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel' + .form-group.gl-max-w-80 + = label_tag :branch_name, _('Branch name') + = text_field_tag :branch_name, params[:branch_name], required: true, autofocus: true, class: 'form-control js-branch-name monospace' + .form-text.text-muted.text-danger.js-branch-name-error{ 'aria-live': 'assertive' } + .form-group.gl-max-w-80 + = label_tag :ref, _('Create from') + .js-new-branch-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } } + .form-text.text-muted + = _('Existing branch name, tag, or commit SHA') + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do + = _('Create branch') + = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel' diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index feaac255d8c..0868475c49f 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -3,13 +3,11 @@ - add_to_breadcrumbs _('Commits'), project_commits_path(@project) - breadcrumb_title @commit.short_id - container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' -- limited_container_width = fluid_layout ? '' : 'limit-container-width' -- @content_class = limited_container_width - page_title "#{@commit.title} (#{@commit.short_id})", _('Commits') - page_description @commit.description - add_page_specific_style 'page_bundles/pipelines' -.container-fluid{ class: [limited_container_width, container_class] } +.container-fluid{ class: [container_class] } = render "commit_box" = render "ci_menu" = render "projects/diffs/diffs", diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 780bb3404cc..0a87ae145ac 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -1,7 +1,7 @@ - diff_file = local_assigns.fetch(:diff_file, nil) - file_hash = hexdigest(diff_file.file_path) -.diff-content +.diff-content.gl-rounded-bottom-base - if diff_file.has_renderable? .hidden{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'rawViewer' } } = render 'projects/diffs/viewer', viewer: diff_file.viewer diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 03e26fd4456..88354f57c55 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -2,13 +2,13 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) - can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_page_context = local_assigns.fetch(:diff_page_context, nil) -- load_diff_files_async = Feature.enabled?(:async_commit_diff_files, @project) && diff_page_context == "is-commit" +- load_diff_files_async = diff_page_context == "is-commit" - paginate_diffs = local_assigns.fetch(:paginate_diffs, false) - paginate_diffs_per_page = local_assigns.fetch(:paginate_diffs_per_page, nil) - page = local_assigns.fetch(:page, nil) - diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs, page: page, per: paginate_diffs_per_page) -.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed +.files-changed.diff-files-changed.js-diff-files-changed.gl-py-3 .files-changed-inner .inline-parallel-buttons.gl-display-none.gl-md-display-flex - if !diffs_expanded? && diff_files.any?(&:collapsed?) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index b2270e0faf7..b0eef923411 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -1,7 +1,6 @@ - breadcrumb_title _("General Settings") - page_title _("General") - add_page_specific_style 'page_bundles/projects_edit' -- @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? - reduce_visibility_form_id = 'reduce-visibility-form' diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index ca3f49bae95..b6c21588193 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,4 +1,3 @@ -- @content_class = "limit-container-width" unless fluid_layout - default_branch_name = @project.default_branch_or_main - escaped_default_branch_name = default_branch_name.shellescape - @skip_current_level_breadcrumb = true diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index e4b8750b96c..7ddaf868a35 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -8,4 +8,5 @@ "help-page-path" => help_page_path("ci/environments/index.md"), "project-path" => @project.full_path, "project-id" => @project.id, - "default-branch-name" => @project.default_branch_or_main } } + "default-branch-name" => @project.default_branch_or_main, + "kas-tunnel-url" => ::Gitlab::Kas.tunnel_url } } diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml index 121dcd31a13..28a8f8729dd 100644 --- a/app/views/projects/feature_flags/edit.html.haml +++ b/app/views/projects/feature_flags/edit.html.haml @@ -1,7 +1,7 @@ - @gfm_form = true -- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project) - breadcrumb_title @feature_flag.name -- page_title s_('FeatureFlags|Edit Feature Flag'), @feature_flag.name +- page_title s_('FeatureFlags|Edit Feature flag'), @feature_flag.name #js-edit-feature-flag{ data: edit_feature_flag_data } diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml index a6eaeacc61f..e473a6f3cfd 100644 --- a/app/views/projects/feature_flags/index.html.haml +++ b/app/views/projects/feature_flags/index.html.haml @@ -1,4 +1,4 @@ -- page_title s_('FeatureFlags|Feature Flags') +- page_title s_('FeatureFlags|Feature flags') #feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json), "project-id" => @project.id, diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml index c91487ad198..3a32a249d1e 100644 --- a/app/views/projects/feature_flags/new.html.haml +++ b/app/views/projects/feature_flags/new.html.haml @@ -1,7 +1,7 @@ - @breadcrumb_link = new_project_feature_flag_path(@project) -- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project) - breadcrumb_title s_('FeatureFlags|New') -- page_title s_('FeatureFlags|New Feature Flag') +- page_title s_('FeatureFlags|New feature flag') #js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json), feature_flags_path: project_feature_flags_path(@project), diff --git a/app/views/projects/feature_flags_user_lists/edit.html.haml b/app/views/projects/feature_flags_user_lists/edit.html.haml index 1ff488ff0f0..417b6354ec0 100644 --- a/app/views/projects/feature_flags_user_lists/edit.html.haml +++ b/app/views/projects/feature_flags_user_lists/edit.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project) - add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project) - breadcrumb_title s_('FeatureFlags|Edit User List') - page_title s_('FeatureFlags|Edit User List') diff --git a/app/views/projects/feature_flags_user_lists/index.html.haml b/app/views/projects/feature_flags_user_lists/index.html.haml index f0e3c36992a..c0e98b27d29 100644 --- a/app/views/projects/feature_flags_user_lists/index.html.haml +++ b/app/views/projects/feature_flags_user_lists/index.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project) - breadcrumb_title s_('FeatureFlags|User Lists') -- page_title s_('FeatureFlags|Feature Flag User Lists') +- page_title s_('FeatureFlags|Feature flag User Lists') #js-user-lists{ data: { project_id: @project.id, feature_flags_help_page_path: help_page_path("operations/feature_flags"), diff --git a/app/views/projects/feature_flags_user_lists/new.html.haml b/app/views/projects/feature_flags_user_lists/new.html.haml index f2e1ea38d9c..cea55c0ca2a 100644 --- a/app/views/projects/feature_flags_user_lists/new.html.haml +++ b/app/views/projects/feature_flags_user_lists/new.html.haml @@ -1,5 +1,5 @@ - @breadcrumb_link = new_project_feature_flags_user_list_path(@project) -- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project) - add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project) - breadcrumb_title s_('FeatureFlags|New User List') - page_title s_('FeatureFlags|New User List') diff --git a/app/views/projects/feature_flags_user_lists/show.html.haml b/app/views/projects/feature_flags_user_lists/show.html.haml index 2c88f3da66b..5c4e93e7707 100644 --- a/app/views/projects/feature_flags_user_lists/show.html.haml +++ b/app/views/projects/feature_flags_user_lists/show.html.haml @@ -1,7 +1,7 @@ -- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project) - add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project) - breadcrumb_title s_('FeatureFlags|List details') -- page_title s_('FeatureFlags|Feature Flag User List Details') +- page_title s_('FeatureFlags|Feature flag user list details') #js-edit-user-list{ data: { project_id: @project.id, user_list_iid: @user_list.iid, diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml index dab49d5032a..07aaf9b2513 100644 --- a/app/views/projects/google_cloud/configuration/index.html.haml +++ b/app/views/projects/google_cloud/configuration/index.html.haml @@ -2,6 +2,4 @@ - 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/cloudsql_form.html.haml b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml index 05838717b49..ea0a53010ef 100644 --- a/app/views/projects/google_cloud/databases/cloudsql_form.html.haml +++ b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml @@ -3,7 +3,5 @@ - breadcrumb_title @title - page_title @title -- @content_class = "limit-container-width" unless fluid_layout - = form_tag project_google_cloud_databases_path(@project), method: 'post' do #js-google-cloud-databases-cloudsql-form{ 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 index 0528ac3d1f5..0d54c1618e4 100644 --- a/app/views/projects/google_cloud/databases/index.html.haml +++ b/app/views/projects/google_cloud/databases/index.html.haml @@ -2,6 +2,4 @@ - 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 index 22a365671bc..96f73fc3dd1 100644 --- a/app/views/projects/google_cloud/deployments/index.html.haml +++ b/app/views/projects/google_cloud/deployments/index.html.haml @@ -2,6 +2,4 @@ - 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/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml index 4cc218ff548..378ec592a74 100644 --- a/app/views/projects/google_cloud/gcp_regions/index.html.haml +++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml @@ -2,7 +2,5 @@ - breadcrumb_title s_('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-gcp-regions{ 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 8f70818abd9..0e114350193 100644 --- a/app/views/projects/google_cloud/service_accounts/index.html.haml +++ b/app/views/projects/google_cloud/service_accounts/index.html.haml @@ -2,7 +2,5 @@ - 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-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 e6f0e3e950c..b6f6fb64451 100644 --- a/app/views/projects/harbor/repositories/index.html.haml +++ b/app/views/projects/harbor/repositories/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Harbor Registry") -- @content_class = "limit-container-width" unless fluid_layout #js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index d610ef21400..0f4dc4b5e32 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -1,4 +1,3 @@ -- @content_class = 'limit-container-width' unless fluid_layout - add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path - page_title _('Webhook Logs') diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index 3e63faaf448..b553249c4b8 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -1,4 +1,3 @@ -- @content_class = 'limit-container-width' unless fluid_layout - add_to_breadcrumbs _('Webhook Settings'), project_hooks_path(@project) - page_title _('Webhook') diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index 15cb7869dc5..35214ad38dc 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -1,4 +1,3 @@ -- @content_class = 'limit-container-width' unless fluid_layout - breadcrumb_title _('Webhook Settings') - page_title _('Webhooks') diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml index 9fe541c5912..7f509aee07c 100644 --- a/app/views/projects/imports/show.html.haml +++ b/app/views/projects/imports/show.html.haml @@ -1,5 +1,4 @@ - page_title import_in_progress_title -- @content_class = "limit-container-width" unless fluid_layout .save-project-loader .center diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml index 5e2b2bbfcc4..df5ab1d4a7c 100644 --- a/app/views/projects/issues/_design_management.html.haml +++ b/app/views/projects/issues/_design_management.html.haml @@ -13,7 +13,7 @@ issue_path: project_issue_path(@project, @issue), register_path: new_user_registration_path(redirect_to_referer: 'yes'), sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'), - saved_replies_new_path: profile_saved_replies_path } } + new_comment_template_path: profile_comment_templates_path } } - else .gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center = enable_lfs_message diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 8f259fe73e1..c6e5102889a 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -13,4 +13,4 @@ current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json, can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}", report_abuse_path: add_category_abuse_reports_path, - saved_replies_new_path: profile_saved_replies_path } } + new_comment_template_path: profile_comment_templates_path } } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 18975bc3db6..fc6ef2ea153 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -50,10 +50,10 @@ %ul.controls - if issue.closed? && issue.moved? %li.issuable-status - = _('CLOSED (MOVED)') + = render Pajamas::BadgeComponent.new(_('Closed (moved)'), size: 'sm', variant: 'info') - elsif issue.closed? %li.issuable-status - = _('CLOSED') + = render Pajamas::BadgeComponent.new(_('Closed'), size: 'sm', variant: 'info') - if issue.assignees.any? %li.gl-display-flex = render 'shared/issuable/assignees', project: @project, issuable: issue diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index f9798d25b06..90d99d51d29 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -11,18 +11,18 @@ .create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path(from: @issue.to_branch_name, source_project: @project, to: @project.default_branch, mr_params: { issue_iid: @issue.iid }), create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } } .btn-group.unavailable - %button.gl-button.btn{ type: 'button', disabled: 'disabled' } + = render Pajamas::ButtonComponent.new(button_options: { disabled: 'disabled' }) do = gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none') %span.text - Checking branch availability… + = _('Checking branch availability…') + .btn-group.available.hidden - %button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } } - = gl_loading_icon(css_class: 'js-create-mr-spinner js-spinner gl-mr-2 gl-display-none') + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-create-merge-request', data: { action: data_action } }) do + = gl_loading_icon(inline: true , css_class: 'js-create-mr-spinner js-spinner gl-display-none') = value - %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } - = sprite_icon('chevron-down') + = render Pajamas::ButtonComponent.new(variant: :confirm, icon: 'chevron-down', button_options: { class: 'js-dropdown-toggle dropdown-toggle create-merge-request-dropdown-toggle', data: { 'dropdown-trigger': '#create-merge-request-dropdown', display: 'static' } }) .droplab-dropdown %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } } @@ -57,7 +57,7 @@ %span.js-ref-message.form-text .form-group - %button.btn.gl-button.btn-confirm.js-create-target{ type: 'button', data: { action: 'create-mr' } } + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-create-target', data: { action: 'create-mr' } }) do = create_mr_text - if can_create_confidential_merge_request? diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 466eca2fdb0..d26b0f96992 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,12 +1,24 @@ - if @related_branches.any? - %h2.gl-font-lg - = pluralize(@related_branches.size, 'Related Branch') - %ul.related-merge-requests.gl-pl-0.gl-mb-3 - - @related_branches.each do |branch| - %li.gl-display-flex.gl-align-items-center - - if branch[:pipeline_status].present? - %span.related-branch-ci-status - = render 'ci/status/icon', status: branch[:pipeline_status] - %span.related-branch-info - %strong - = link_to branch[:name], branch[:link], class: "ref-name" + - if @related_branches.any? + = render Pajamas::CardComponent.new(card_options: { class: 'gl-bg-gray-10 gl-mt-5 gl-mb-0' }, header_options: { class: 'gl-bg-white gl-pl-5 gl-pr-4 gl-py-4' } , body_options: { class: 'gl-py-3 gl-px-4' }) do |c| + - c.header do + %h3.card-title.h5.gl-my-0.gl-display-flex.gl-align-items-center.gl-flex-grow-1.gl-relative.gl-line-height-24 + = link_to "", "#related-branches", class: "gl-link anchor position-absolute gl-text-decoration-none", "aria-hidden": true + = _('Related branches') + .gl-display-inline-flex.gl-mx-3.gl-text-gray-500 + .gl-display-inline-flex.gl-align-items-center + = sprite_icon('branch', css_class: "gl-mr-2 gl-text-gray-500 gl-icon") + = @related_branches.size + - c.body do + %ul.related-merge-requests.content-list.gl-p-3! + - @related_branches.each do |branch| + %li.list-item{ class: "gl-py-0! gl-border-0!" } + .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2 + .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7 + .item-title.gl-display-flex.mb-xl-0.gl-min-w-0 + - if branch[:pipeline_status].present? + %span.related-branch-ci-status + = render 'ci/status/icon', status: branch[:pipeline_status] + %span.related-branch-info + %strong + = link_to branch[:name], branch[:link], class: "ref-name" diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index 617579cdd6f..d344ae6a4e6 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -2,7 +2,7 @@ - breadcrumb_title _("New") - page_title _("New Issue") -.top-area.gl-lg-flex-direction-row.gl-border-bottom-0 +.page-title-holder %h1.page-title.gl-font-size-h-display= _("New Issue") = render "form" diff --git a/app/views/projects/mattermosts/new.html.haml b/app/views/projects/mattermosts/new.html.haml index 8254198bd41..025ca1e1fd4 100644 --- a/app/views/projects/mattermosts/new.html.haml +++ b/app/views/projects/mattermosts/new.html.haml @@ -2,7 +2,6 @@ - add_to_breadcrumbs @integration.title, scoped_edit_integration_path(@integration, project: @project, group: @group) - breadcrumb_title _('New') - page_title @integration.title, _('Integrations') -- @content_class = 'limit-container-width' unless fluid_layout - if @teams_error_message = render Pajamas::AlertComponent.new(variant: :danger) do |c| 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 92b0a5a0b90..b8ee62055f0 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 @@ -1,7 +1,7 @@ - display_issuable_type = issuable_display_type(@merge_request) .btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full - = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Merge request actions'), testid: 'merge-request-actions', 'aria-label': _('Merge request actions') } do + = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", title: _('Merge request actions'), 'aria-label': _('Merge request actions'), data: { toggle: 'dropdown', testid: 'merge-request-actions' } do = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon" = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do %span.gl-dropdown-button-text= _('Merge request actions') diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml index 2ef89a7bf04..4cab6fac388 100644 --- a/app/views/projects/merge_requests/_code_dropdown.html.haml +++ b/app/views/projects/merge_requests/_code_dropdown.html.haml @@ -32,7 +32,7 @@ %li.gl-dropdown-item = link_to merge_request_path(@merge_request, format: :patch), class: 'dropdown-item', download: '', data: { qa_selector: 'download_email_patches_menu_item' } do .gl-dropdown-item-text-wrapper - = _('Email patches') + = _('Patches') %li.gl-dropdown-item = link_to merge_request_path(@merge_request, format: :diff), class: 'dropdown-item', download: '', data: { qa_selector: 'download_plain_diff_menu_item' } do .gl-dropdown-item-text-wrapper diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml index 1dd4cc6495c..5590f9e6184 100644 --- a/app/views/projects/merge_requests/_description.html.haml +++ b/app/views/projects/merge_requests/_description.html.haml @@ -1,6 +1,6 @@ %div - if @merge_request.description.present? - .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' , data: { qa_selector: 'description_content' } } + .description{ class: ['gl-mt-4!', can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : ''], data: { qa_selector: 'description_content' } } .md = markdown_field(@merge_request, :description) %textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } } diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index b96d869e9d7..85396134db2 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -39,19 +39,18 @@ = sprite_icon('branch', size: 12, css_class: 'fork-sprite') = merge_request.target_branch - if merge_request.labels.any? - - - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label| - = link_to_label(label, type: :merge_request, small: true) + .gl-mt-1{ role: 'group', 'aria-label': _('Labels') } + - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label| + = link_to_label(label, type: :merge_request, small: true) .issuable-meta %ul.controls.d-flex.align-items-end - if merge_request.merged? - %li.issuable-status.d-none.d-sm-inline-block - = _('MERGED') + %li.d-none.d-sm-flex + = render Pajamas::BadgeComponent.new(_('Merged'), size: 'sm', variant: 'info') - elsif merge_request.closed? - %li.issuable-status.d-none.d-sm-inline-block - = sprite_icon('cancel', css_class: 'gl-vertical-align-text-bottom') - = _('CLOSED') + %li.d-none.d-sm-flex + = render Pajamas::BadgeComponent.new(_('Closed'), size: 'sm', variant: 'danger') = render 'shared/merge_request_pipeline_status', merge_request: merge_request - if merge_request.open? && merge_request.broken? %li.issuable-pipeline-broken.d-none.d-sm-flex @@ -67,6 +66,6 @@ = render 'shared/issuable_meta_data', issuable: merge_request - .float-right.issuable-updated-at.d-none.d-sm-inline-block + .float-right.issuable-updated-at.d-none.d-sm-inline-block.gl-text-gray-500 %span = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago') } diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index 901a2ebfd1e..6f662b81dd7 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -1,3 +1,3 @@ -.detail-page-description.py-2.gl-display-flex.gl-align-items-center.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } +.detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-center.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } = render 'shared/issuable/status_box', issuable: @merge_request = merge_request_header(@project, @merge_request) diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index d0bd176028f..aee746100ea 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -1,4 +1,3 @@ -- @no_breadcrumb_border = true - can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) - 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) @@ -13,7 +12,7 @@ = c.body do = _('The source project of this merge request has been removed.') - .detail-page-header.border-bottom-0.pt-0.pb-0.gl-display-block{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } + .detail-page-header.border-bottom-0.gl-display-block.gl-pt-5{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } .detail-page-header-body .issuable-meta.gl-display-flex #js-issuable-header-warnings{ data: { hidden: @merge_request.hidden?.to_s } } diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 5bd33cd210d..1f6c95d920f 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -8,6 +8,8 @@ - page_card_attributes @merge_request.card_attributes - suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md') - mr_action = j(params[:tab].presence || 'show') +-# @diffs_count is a number when the value is 0, but a string when there are other values +- diffs_count_display = @diffs_count.to_s == "0" ? "-" : @diffs_count - add_page_specific_style 'page_bundles/issuable' - add_page_specific_style 'page_bundles/design_management' - add_page_specific_style 'page_bundles/merge_requests' @@ -16,7 +18,7 @@ - add_page_specific_style 'page_bundles/ci_status' - add_page_startup_api_call @endpoint_metadata_url -- if mr_action == 'diffs' +- if mr_action == 'diffs' && (!@file_by_file_default || !single_file_file_by_file?) - add_page_startup_api_call @endpoint_diff_batch_url .merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version, diffs_batch_cache_key: @diffs_batch_cache_key } } @@ -45,7 +47,7 @@ = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do = tab_link_for @merge_request, :diffs do = _("Changes") - = gl_badge_tag @diffs_count, { size: :sm } + = gl_badge_tag diffs_count_display, { size: :sm } .d-flex.flex-wrap.align-items-center.justify-content-lg-end #js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } } - if moved_mr_sidebar_enabled? @@ -82,7 +84,7 @@ current_user_data: @current_user_data, is_locked: @merge_request.discussion_locked.to_s, report_abuse_path: add_category_abuse_reports_path, - saved_replies_new_path: profile_saved_replies_path } } + new_comment_template_path: profile_comment_templates_path } } - if moved_mr_sidebar_enabled? = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch @@ -106,7 +108,7 @@ - if @merge_request.can_be_cherry_picked? = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit -#js-review-bar{ data: { saved_replies_new_path: profile_saved_replies_path } } +#js-review-bar{ data: { new_comment_template_path: profile_comment_templates_path } } - if current_user && Feature.enabled?(:mr_experience_survey, current_user) #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } } diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index 1246c45a529..35e8b30e6e9 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -10,12 +10,24 @@ - if params[:nav_source].present? = hidden_field_tag(:nav_source, params[:nav_source]) -.mr-compare.merge-request.js-merge-request-new-submit{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" } +.mr-compare.merge-request.js-merge-request-new-submit.gl-mt-5{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" } - if @commits.empty? - .commits-empty - %h4 - = _("There are no commits yet.") - = custom_icon ('illustration_no_commits') + .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } + .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0.js-tabs-affix + %li.commits-tab.new-tab + = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do + = _("Commits") + = gl_badge_tag @total_commit_count, { size: :sm }, { class: 'gl-tab-counter-badge' } + + #diff-notes-app.tab-content + #new.commits.tab-pane.active + .commits-empty.gl-text-left.gl-my-5.gl-text-gray-500 + %p + = _("There are no commits yet.") - else .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml index 5dbbb72db56..b06aca063fd 100644 --- a/app/views/projects/mirrors/_mirror_repos_list.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -10,7 +10,7 @@ - c.body do = _('There are currently no mirrored repositories.') - else - %table.table.push-pull-table + %table.table.gl-table.gl-mt-5 %thead %tr %th diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml index 52145eb0964..cfec627d249 100644 --- a/app/views/projects/ml/experiments/show.html.haml +++ b/app/views/projects/ml/experiments/show.html.haml @@ -3,15 +3,14 @@ - page_title @experiment.name - add_page_specific_style 'page_bundles/ml_experiment_tracking' +- experiment = experiment_as_data(@experiment) - items = candidates_table_items(@candidates) - metrics = unique_logged_names(@candidates, &:latest_metrics) - params = unique_logged_names(@candidates, &:params) - page_info = formatted_page_info(@page_info) -.page-title-holder.d-flex.align-items-center - %h1.page-title.gl-font-size-h-display= @experiment.name - #js-show-ml-experiment{ data: { + experiment: experiment, candidates: items, metrics: metrics, params: params, diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index f4a5862b2c0..e64ed2c7b8f 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -1,4 +1,4 @@ -- @hide_breadcrumbs = true +- @hide_top_bar = true - @hide_top_links = true - page_title _('New Project') - header_title _("Projects"), dashboard_projects_path @@ -14,6 +14,7 @@ new_project_guidelines: brand_new_project_guidelines, push_to_create_project_command: push_to_create_project_command, working_with_projects_help_path: help_page_path("user/project/working_with_projects"), + root_path: root_path, parent_group_url: @project.parent && group_url(@project.parent), parent_group_name: @project.parent&.name, projects_url: dashboard_projects_url } } diff --git a/app/views/projects/packages/infrastructure_registry/index.html.haml b/app/views/projects/packages/infrastructure_registry/index.html.haml index 5a118997ff9..9577f6383e9 100644 --- a/app/views/projects/packages/infrastructure_registry/index.html.haml +++ b/app/views/projects/packages/infrastructure_registry/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Infrastructure Registry") -- @content_class = "limit-container-width" unless fluid_layout .row .col-12 diff --git a/app/views/projects/packages/infrastructure_registry/show.html.haml b/app/views/projects/packages/infrastructure_registry/show.html.haml index e7c77478170..8624fdacda7 100644 --- a/app/views/projects/packages/infrastructure_registry/show.html.haml +++ b/app/views/projects/packages/infrastructure_registry/show.html.haml @@ -1,8 +1,7 @@ -- add_to_breadcrumbs _("Infrastructure Registry"), project_infrastructure_registry_index_path(@project) +- add_to_breadcrumbs _("Terraform Module Registry"), project_infrastructure_registry_index_path(@project) - add_to_breadcrumbs @package.name, project_infrastructure_registry_index_path(@project) - breadcrumb_title @package.version -- page_title _("Infrastructure Registry") -- @content_class = "limit-container-width" unless fluid_layout +- page_title _("Terraform Module Registry") .row .col-12 diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml index 4ab16f25dd2..48aaf0884c8 100644 --- a/app/views/projects/packages/packages/index.html.haml +++ b/app/views/projects/packages/packages/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Package Registry") -- @content_class = "limit-container-width" unless fluid_layout .row .col-12 @@ -10,4 +9,5 @@ empty_list_illustration: image_path('illustrations/no-packages.svg'), npm_instance_url: package_registry_instance_url(:npm), project_list_url: project_packages_path(@project), + settings_path: show_package_registry_settings(@project) ? project_settings_packages_and_registries_path(@project) : '', group_list_url: '' } } diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml index 11e105d349d..4c8ec21db39 100644 --- a/app/views/projects/pages/_pages_settings.html.haml +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -17,7 +17,7 @@ %p.gl-pl-6 = s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end } - - if Feature.enabled?(:pages_unique_domain) + - if Feature.enabled?(:pages_unique_domain, @project) .form-group = f.fields_for :project_setting do |settings| = settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled, diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 7a889570f56..3ff370dfaa4 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -21,10 +21,7 @@ - duration = time_interval_in_words(@pipeline.duration) - queued_duration = time_interval_in_words(@pipeline.queued_duration) %span.gl-pl-7{ 'data-testid': 'pipeline-stats-text' } - - if Feature.enabled?(:refactor_ci_minutes_consumption, @project) - = render_if_exists 'projects/pipelines/pipeline_stats_text', duration: duration, pipeline: @pipeline, queued_duration: queued_duration - - else - = s_("in %{duration} and was queued for %{queued_duration}").html_safe % { duration: duration, queued_duration: queued_duration } + = render_if_exists 'projects/pipelines/pipeline_stats_text', duration: duration, pipeline: @pipeline, queued_duration: queued_duration - if has_pipeline_badges?(@pipeline) .well-segment diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index ee51ee9b0e2..63b44de0d74 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -7,7 +7,6 @@ #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), - config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project), default_branch: @project.default_branch, pipelines_editor_path: project_ci_pipeline_editor_path(@project), can_view_pipeline_editor: can_view_pipeline_editor?(@project), diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 0cfb5ff6a3d..a0a90fbe204 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -3,10 +3,6 @@ = render_if_exists 'shared/ultimate_feature_removal_banner', project: @project -= content_for :page_level_alert do - - if can_invite_members_for_project?(@project) - = render_if_exists 'shared/unlimited_members_during_trial_alert', group: @project.root_ancestor - .row.gl-mt-3 .col-lg-12 .gl-display-flex.gl-flex-wrap diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 910aab6da72..644aca2477b 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Container Registry") -- @content_class = "limit-container-width" unless fluid_layout - add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false, sort: nil}) %section diff --git a/app/views/projects/releases/new.html.haml b/app/views/projects/releases/new.html.haml index 4348035a324..87197f2662d 100644 --- a/app/views/projects/releases/new.html.haml +++ b/app/views/projects/releases/new.html.haml @@ -1,3 +1,4 @@ - page_title s_('Releases|New Release') +- add_page_specific_style 'page_bundles/releases' #js-new-release-page{ data: data_for_new_release_page } diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml index 2904fb81afe..63e175f96e5 100644 --- a/app/views/projects/security/configuration/show.html.haml +++ b/app/views/projects/security/configuration/show.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _("Security configuration") - page_title _("Security configuration") -- @content_class = "limit-container-width" unless fluid_layout #js-security-configuration{ data: { **@configuration.to_html_data_attribute, vulnerability_training_docs_path: vulnerability_training_docs_path, diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index f6c5c4e2950..b581ccaceec 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -2,7 +2,6 @@ - page_title _('Project Access Tokens') - type = _('project access token') - type_plural = _('project access tokens') -- @content_class = 'limit-container-width' unless fluid_layout .row.gl-mt-3.js-search-settings-section .col-lg-4 diff --git a/app/views/projects/settings/branch_rules/index.html.haml b/app/views/projects/settings/branch_rules/index.html.haml index f05a528745c..efebc4223d9 100644 --- a/app/views/projects/settings/branch_rules/index.html.haml +++ b/app/views/projects/settings/branch_rules/index.html.haml @@ -1,6 +1,9 @@ - add_to_breadcrumbs _('Repository Settings'), project_settings_repository_path(@project) +- add_to_breadcrumbs _('Branch rules'), project_settings_repository_path(@project, anchor: 'branch-rules') +- breadcrumb_title _('Details') +- @breadcrumb_link = '#' - page_title s_('BranchRules|Branch rules details') -%h3.gl-mb-5= s_('BranchRules|Branch rules details') +%h3.gl-mb-5= page_title #js-branch-rules{ data: branch_rules_data(@project) } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index b27f5a0e5ed..d8e26c7ad72 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -1,4 +1,3 @@ -- @content_class = "limit-container-width" unless fluid_layout - page_title _("CI/CD Settings") - page_title _("CI/CD") @@ -58,7 +57,7 @@ %p = _("A job artifact is an archive of files and directories saved by a job when it finishes.") .settings-content - #js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } } + #js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/jobs/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } } %section.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } } .settings-header diff --git a/app/views/projects/settings/integrations/edit.html.haml b/app/views/projects/settings/integrations/edit.html.haml index 46276e6c6c9..84d3ac2ded9 100644 --- a/app/views/projects/settings/integrations/edit.html.haml +++ b/app/views/projects/settings/integrations/edit.html.haml @@ -1,7 +1,6 @@ - breadcrumb_title @integration.title - add_to_breadcrumbs _('Integration Settings'), project_settings_integrations_path(@project) - page_title @integration.title, _('Integrations') -- @content_class = 'limit-container-width' unless fluid_layout = render 'form', integration: @integration diff --git a/app/views/projects/settings/integrations/index.html.haml b/app/views/projects/settings/integrations/index.html.haml index c316b4e9cac..ed65cce5acb 100644 --- a/app/views/projects/settings/integrations/index.html.haml +++ b/app/views/projects/settings/integrations/index.html.haml @@ -1,4 +1,3 @@ -- @content_class = "limit-container-width" unless fluid_layout - breadcrumb_title _('Integration Settings') - page_title _('Integrations') diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml index 5fca734222b..5c9389c9c1c 100644 --- a/app/views/projects/settings/members/show.html.haml +++ b/app/views/projects/settings/members/show.html.haml @@ -1,5 +1,3 @@ -- @content_class = "limit-container-width" unless fluid_layout - - page_title _("Members") = render "projects/project_members/index" diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml index 7dfd304e07b..6cc5dfd8c90 100644 --- a/app/views/projects/settings/merge_requests/show.html.haml +++ b/app/views/projects/settings/merge_requests/show.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _('Merge requests') - page_title _('Merge requests') -- @content_class = 'limit-container-width' unless fluid_layout %section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } .settings-header diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 90e0ccce8b4..2aae408b88f 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -1,4 +1,3 @@ -- @content_class = 'limit-container-width' unless fluid_layout - page_title _('Monitor Settings') - breadcrumb_title _('Monitor Settings') diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml index d27d268d65e..ad9ba0b506c 100644 --- a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml +++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml @@ -1,6 +1,5 @@ - add_to_breadcrumbs _('Packages and registries settings'), project_settings_packages_and_registries_path(@project) -- breadcrumb_title s_('ContainerRegistry|Clean up image tags') -- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages and registries settings') -- @content_class = 'limit-container-width' unless fluid_layout +- breadcrumb_title s_('ContainerRegistry|Cleanup policies') +- page_title s_('ContainerRegistry|Cleanup policies'), _('Packages and registries settings') #js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data } 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 c81b38f44dd..22385677192 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -1,5 +1,4 @@ - breadcrumb_title _('Packages and registries settings') - page_title _('Packages and registries settings') -- @content_class = 'limit-container-width' unless fluid_layout #js-registry-settings{ data: settings_data } diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index de171a25e8d..c532c19e0d1 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _("Repository Settings") - page_title _("Repository") -- @content_class = "limit-container-width" unless fluid_layout - deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.') = render "projects/branch_defaults/show" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index f47f4ebc7ee..ab2f6745dfd 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,5 +1,4 @@ - current_route_path = request.fullpath.match(%r{-/tree/[^/]+/(.+$)}).to_a[1] -- @content_class = "limit-container-width" unless fluid_layout - @skip_current_level_breadcrumb = true - add_page_specific_style 'page_bundles/project' - add_page_specific_style 'page_bundles/tree' diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml index d9bf064ad24..6e1ebdeedf0 100644 --- a/app/views/projects/snippets/edit.html.haml +++ b/app/views/projects/snippets/edit.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title @snippet.to_reference - page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") -- @content_class = "limit-container-width" unless fluid_layout %h1.page-title.gl-font-size-h-display = _("Edit Snippet") diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml index 5086b5eaa3d..59b2536c5d0 100644 --- a/app/views/projects/snippets/new.html.haml +++ b/app/views/projects/snippets/new.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title _("New") - page_title _("New Snippet") -- @content_class = "limit-container-width" unless fluid_layout %h1.page-title.gl-font-size-h-display = _("New Snippet") diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index a9c3309e38c..3124f47c832 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -57,13 +57,5 @@ %pre.wrap{ data: { qa_selector: 'tag_message_content' } } = strip_signature(@tag.message) -- if can?(current_user, :read_release, @release) - .gl-mb-3.gl-mt-3 - - if @release&.description.present? - .description.md{ data: { qa_selector: 'tag_release_notes_content' } } - = markdown_field(@release, :description) - - else - = s_('TagsPage|This tag has no release notes.') - - if can?(current_user, :admin_tag, @project) .js-delete-tag-modal diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 6d1ab80bdc5..fbbf1c04613 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -4,7 +4,6 @@ - add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path }) - add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"}) - breadcrumb_title _("Repository") -- @content_class = "limit-container-width" unless fluid_layout - page_title @path.presence || _("Files"), @ref = content_for :meta_tags do diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index 5e2217d3c9f..5bfe6b650d1 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -1,4 +1,5 @@ - page_title s_("UsageQuota|Usage") +- add_page_specific_style 'page_bundles/projects_usage_quotas' = render_if_exists 'shared/ultimate_feature_removal_banner', project: @project @@ -17,10 +18,12 @@ %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' } = s_('UsageQuota|Learn more about usage quotas') + '.' -= gl_tabs_nav do += gl_tabs_nav({ id: 'js-project-usage-quotas-tabs' }) do = gl_tab_link_to '#storage-quota-tab', item_active: true do = s_('UsageQuota|Storage') + = render_if_exists 'projects/usage_quotas/transfer_tab_link' .tab-content .tab-pane.active#storage-quota-tab #js-project-storage-count-app{ data: { project_path: @project.full_path } } + = render_if_exists 'projects/usage_quotas/transfer_tab_content' diff --git a/app/views/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml index 8235411d240..ed2d420ffcd 100644 --- a/app/views/protected_branches/shared/_branches_list.html.haml +++ b/app/views/protected_branches/shared/_branches_list.html.haml @@ -26,7 +26,7 @@ %th = s_("ProtectedBranch|Allowed to force push") %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' } - = sprite_icon('question', size: 16, css_class: 'gl-text-gray-500') + = sprite_icon('question-o', size: 16, css_class: 'gl-text-blue-500') = render_if_exists 'protected_branches/ee/code_owner_approval_table_head', protected_branch_entity: protected_branch_entity diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml index 109d92af8a7..9bc224b2e78 100644 --- a/app/views/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -40,3 +40,5 @@ = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity - c.footer do = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true + + .js-alert-protected-branch-created-container.gl-mb-5 diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 2796f0c0a7e..45c23aa7190 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -1,6 +1,7 @@ - @html_class = "subscriptions-layout-html" - page_title _('Your profile') - add_page_specific_style 'page_bundles/signup' +- add_page_specific_style 'page_bundles/login' - gitlab_experience_text = _('To personalize your GitLab experience, we\'d like to know a bit more about you') - content_for :page_specific_javascripts do = render "layouts/google_tag_manager_head" diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 3280dcf2cd4..99558f61b25 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,9 +1,5 @@ -- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' - = render_if_exists 'shared/promotions/promote_advanced_search' -.results.gl-md-display-flex.gl-mt-0 - #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } } - .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - = render partial: 'search/results_status' if @search_objects.present? - = render partial: 'search/results_list' +.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden + = render partial: 'search/results_status' unless @search_objects.to_a.empty? + = render partial: 'search/results_list' diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml index c36acaf9ea8..fcbf0ba4452 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -5,7 +5,9 @@ - elsif @search_objects.blank? = render partial: "search/results/empty" - else - .gl-md-pl-5 + - statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : '' + + .section{ class: statusBarClass } - if @scope == 'commits' %ul.content-list.commit-list = render partial: "search/results/commit", collection: @search_objects diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index 4ab68caaf22..6fc07d35296 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -1,5 +1,7 @@ - return unless @search_service_presenter.show_results_status? -.gl-md-pl-5 +- statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : '' + +.section{ class: statusBarClass } .search-results-status .gl-display-flex.gl-flex-direction-column .gl-p-5.gl-display-flex diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 826d78c470d..934f59ea586 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -1,12 +1,14 @@ - @hide_top_links = true - breadcrumb_title _('Search') - page_title @search_term +- nav 'search' - if params[:group_id].present? = hidden_field_tag :group_id, params[:group_id] - if params[:project_id].present? = hidden_field_tag :project_id, params[:project_id] - group_attributes = @group&.attributes&.slice('id', 'name')&.merge(full_name: @group&.full_name) - project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) +- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' - if @search_results && !(@search_results.respond_to?(:failed?) && @search_results.failed?) - if @search_service_presenter.without_count? @@ -20,5 +22,7 @@ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } } -- if @search_term - = render 'search/results' +.results.gl-md-display-flex.gl-mt-0 + #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } } + - if @search_term + = render 'search/results' diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 93f919f01d9..c468b3a2001 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,4 +1,4 @@ -- container = @no_breadcrumb_container ? 'container-fluid' : container_class +- container = @no_top_bar_container ? 'container-fluid' : container_class %div{ class: [container, @content_class, 'gl-pt-5!'] } = render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'), diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index a749d1037a1..9dfbad20726 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -2,14 +2,8 @@ - offset = defined?(first_line_number) ? first_line_number : 1 - highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil -- file_line_blame = Feature.enabled?(:file_line_blame) - -- if file_line_blame - - line_class = "js-line-links" - - blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) -- else - - line_class = nil - - blame_path = nil +- line_class = "js-line-links" +- blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) - highlighted_blob = blob.present.highlight diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml index 8d76e9c1b7d..beb564f7c7c 100644 --- a/app/views/shared/_file_picker_button.html.haml +++ b/app/views/shared/_file_picker_button.html.haml @@ -1,9 +1,10 @@ - classes = local_assigns.fetch(:classes, '') +- mime_types = local_assigns.fetch(:mime_types, '') %span.js-filepicker = render Pajamas::ButtonComponent.new(button_options: { class: "js-filepicker-button #{classes}" }) do = _("Choose file…") - %span.file_name.js-filepicker-filename= _("No file chosen.") - = f.file_field field, class: "js-filepicker-input hidden" + %span.file_name.gl-ml-3.js-filepicker-filename= _("No file chosen.") + = f.file_field field, class: "js-filepicker-input hidden", accept: mime_types - if help_text.present? .form-text.text-muted= help_text diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 547f12ac8fc..7f2511d3e28 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -44,10 +44,10 @@ = 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 + = render Pajamas::ButtonComponent.new(button_options: { data: { toggle: 'dropdown' } }) do = _('Subscribe') = sprite_icon('chevron-down') - .dropdown-menu.dropdown-open-left + .dropdown-menu.dropdown-menu-right %ul %li = 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 diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml deleted file mode 100644 index fa718a9c907..00000000000 --- a/app/views/shared/_ref_switcher.html.haml +++ /dev/null @@ -1,22 +0,0 @@ -- return unless @project - -- ref = local_assigns.fetch(:ref, @ref) -- form_path = local_assigns.fetch(:form_path, switch_project_refs_path(@project)) -- dropdown_toggle_text = ref || @project.default_branch -- field_name = local_assigns.fetch(:field_name, 'ref') - -= form_tag form_path, method: :get, class: "project-refs-form" do - - if defined?(destination) - = hidden_field_tag :destination, destination - - if defined?(path) - = hidden_field_tag :path, path - - @options && @options.each do |key, value| - = hidden_field_tag key, value, id: nil - .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, ref_type: @ref_type, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true, qa_selector: "branches_dropdown", testid: "branches-select" }, { toggle_class: "js-project-refs-dropdown" } - .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-right" if local_assigns[:align_right]), data: { qa_selector: "branches_dropdown_content" } } - .dropdown-page-one - = dropdown_title _("Switch branch/tag") - = dropdown_filter _("Search branches and tags") - = dropdown_content - = dropdown_loading diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index c3835386d5a..e5aa4c58da1 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -1,5 +1,5 @@ - board = local_assigns.fetch(:board, nil) -- @no_breadcrumb_container = true +- @no_top_bar_container = true - @no_container = true - @content_wrapper_class = "#{@content_wrapper_class} gl-relative gl-pb-0" - @content_class = "issue-boards-content js-focus-mode-board" diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 93f31629ca7..584d0758c76 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -27,6 +27,11 @@ .col-sm-10 = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' +.form-group + .col-sm-10 + = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' + = form.text_field :expires_at, 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| .form-group 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 11fa44fe282..c9e17b18264 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -15,6 +15,10 @@ .form-group.row = deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'), help_text: _('Allow this key to push to this repository') + .form-group.row + = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' + = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_key_expires_at_field' }, value: f.object.expires_at + %p.form-text.text-muted= ssh_key_expires_field_description .form-group.row = f.submit _("Add key"), data: { qa_selector: "add_deploy_key_button"}, pajamas_button: true diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index 6a770a4fcb2..c2a47a88f02 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -1,5 +1,3 @@ -- @content_class = "limit-container-width" unless fluid_layout - .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index 19f4c971c1d..b9095e2a1a1 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -8,23 +8,14 @@ %td .clipboard-group .input-group - %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } + %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true, data: { qa_selector: 'application_id_field' } } .input-group-append = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default") %tr %td = _('Secret') %td - - if @application.plaintext_secret - = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5'}) do |c| - = c.body do - = _('This is the only time the secret is accessible. Copy the secret and store it securely.') - = clipboard_button(clipboard_text: @application.plaintext_secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button") - - else - = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5'}) do |c| - = c.body do - = _('The secret is only available when you create the application or renew the secret.') - = render 'shared/doorkeeper/applications/update_form', path: renew_path + #js-oauth-application-secret{ data: { initial_secret: @application.plaintext_secret, renew_path: renew_path } } %tr %td @@ -55,3 +46,6 @@ = link_to _('Continue'), index_path, class: 'btn btn-confirm btn-md gl-button gl-mr-3' = link_to _('Edit'), edit_path, class: 'btn btn-default btn-md gl-button' = render 'shared/doorkeeper/applications/delete_form', path: delete_path + +-# Create a hidden field to save the ID of application created += hidden_field_tag(:id_of_application, @application.id, data: { qa_selector: 'id_of_application_field' }) diff --git a/app/views/shared/doorkeeper/applications/_update_form.html.haml b/app/views/shared/doorkeeper/applications/_update_form.html.haml deleted file mode 100644 index 1bee3288639..00000000000 --- a/app/views/shared/doorkeeper/applications/_update_form.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- path = local_assigns.fetch(:path) -= form_for(@application, url: path, html: {class: 'gl-display-inline-block', method: "put"}) do |f| - = submit_tag s_('AuthorizedApplication|Renew secret'), data: { confirm: s_("AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab."), confirm_btn_variant: "danger" }, aria: { label: s_('AuthorizedApplication|Renew secret') }, class: 'gl-button btn btn-md btn-default' diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index e96fcd11cef..da88c139a6e 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,7 +1,7 @@ .row.empty-state.labels .col-12 - .svg-content{ data: { qa_selector: 'label_svg_content' } } - = image_tag 'illustrations/labels.svg' + .svg-content.svg-150{ data: { qa_selector: 'label_svg_content' } } + = image_tag 'illustrations/empty-state/empty-labels-md.svg' .col-12 .text-content %h4= _("Labels can be applied to issues and merge requests to categorize them.") diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index e34166bac6c..87de756093d 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -2,8 +2,8 @@ .row.empty-state .col-12 - .svg-content{ data: { qa_selector: 'svg_content' } } - = image_tag 'illustrations/snippets_empty.svg' + .svg-content.svg-150{ data: { qa_selector: 'svg_content' } } + = image_tag 'illustrations/empty-state/empty-snippets-md.svg' .text-content.gl-text-center.gl-pt-0 - if current_user %h4 diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml index 0283e852c7d..cd60d966d71 100644 --- a/app/views/shared/empty_states/_topics.html.haml +++ b/app/views/shared/empty_states/_topics.html.haml @@ -1,7 +1,7 @@ .row.empty-state .col-12 - .svg-content - = image_tag 'illustrations/labels.svg' + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-labels-md.svg' .text-content.gl-text-center.gl-pt-0! %h4= _('There are no topics to show.') %p= _('Add topics to projects to help users find them.') diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 2c46b2191c6..415849672b6 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -1,9 +1,9 @@ +- @gfm_form = true - project = local_assigns.fetch(:project) - model = local_assigns.fetch(:model) - form = local_assigns.fetch(:form) - placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a description or drag your files here…') - -- supports_quick_actions = true +- no_issuable_templates = issuable_templates(ref_project, model.to_ability_name).empty? - preview_url = preview_markdown_path(project, target_type: model.class.name) .form-group @@ -16,12 +16,14 @@ = render 'shared/form_elements/apply_template_warning', issuable: model - = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do - = render 'shared/zen', f: form, attr: :description, - classes: 'note-textarea rspec-issuable-form-description', - placeholder: placeholder, - supports_quick_actions: supports_quick_actions, - qa_selector: 'issuable_form_description_field' - = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions - .clearfix - .error-alert + .js-markdown-editor{ data: { render_markdown_path: preview_url, + markdown_docs_path: help_page_path('user/markdown'), + quick_actions_docs_path: help_page_path('user/project/quick_actions'), + qa_selector: 'issuable_form_description_field', + form_field_placeholder: placeholder, + form_field_classes: 'js-gfm-input markdown-area note-textarea rspec-issuable-form-description' } } + = form.hidden_field :description + + - if no_issuable_templates && can?(current_user, :push_code, model.project) + = render 'shared/issuable/form/default_templates' + diff --git a/app/views/shared/hook_logs/_index.html.haml b/app/views/shared/hook_logs/_index.html.haml index 6a46b0b3510..7dab14b95c1 100644 --- a/app/views/shared/hook_logs/_index.html.haml +++ b/app/views/shared/hook_logs/_index.html.haml @@ -1,4 +1,4 @@ -- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks') +- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting') - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } - link_end = '</a>'.html_safe diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml index 0ae0eea59d8..9d613d2ad94 100644 --- a/app/views/shared/integrations/edit.html.haml +++ b/app/views/shared/integrations/edit.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group) - breadcrumb_title @integration.title - page_title @integration.title, _('Integrations') -- @content_class = 'limit-container-width' unless fluid_layout %h2.gl-mb-4 = @integration.title diff --git a/app/views/shared/integrations/overrides.html.haml b/app/views/shared/integrations/overrides.html.haml index a63053bde0a..c25527a605c 100644 --- a/app/views/shared/integrations/overrides.html.haml +++ b/app/views/shared/integrations/overrides.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group) - breadcrumb_title @integration.title - page_title @integration.title, _('Integrations') -- @content_class = 'limit-container-width' unless fluid_layout %h1.page-title.gl-font-size-h-display = @integration.title diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 07cdbbece8c..5ba92676f89 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -62,9 +62,9 @@ = sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end }) - if issuable.new_record? - = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - else - = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button', data: { 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 js-reset-autosave' diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 95c5f51c339..06bc0ff5173 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -194,7 +194,7 @@ = render_if_exists 'shared/issuable/filter_epic', type: type - %button.clear-search.hidden{ type: 'button' } + %button.clear-search.hidden.gl-rounded-base{ type: 'button' } = sprite_icon('close', size: 16, css_class: 'clear-search-icon') .filter-dropdown-container.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-align-items-flex-start - if type != :productivity_analytics && show_sorting_dropdown diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml index 50f30e58b35..2dda0049c09 100644 --- a/app/views/shared/issuable/form/_default_templates.html.haml +++ b/app/views/shared/issuable/form/_default_templates.html.haml @@ -1,4 +1,4 @@ -%p.form-text.text-muted +.gl-mt-3.gl-text-secondary - template_link_url = help_page_path('user/project/description_templates') - template_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: template_link_url } = s_('Promotions|Add %{link_start} description templates %{link_end} to help your contributors to communicate effectively!').html_safe % { link_start: template_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 09086d3aa82..8e9793cdba5 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -9,22 +9,25 @@ %label = _('Merge options') - if issuable.can_remove_source_branch?(current_user) - .form-check.gl-mb-3 + .form-check.gl-pl-0 = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input js-form-update' - = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do - = _("Delete source branch when merge request is accepted.") + = render Pajamas::CheckboxTagComponent.new(name: 'merge_request[force_remove_source_branch]', checked: issuable.force_remove_source_branch?, value: '1', checkbox_options: { class: 'js-form-update' }) do |c| + = c.label do + = _("Delete source branch when merge request is accepted.") + - if !project.squash_never? - .form-check + .form-check.gl-pl-0 - if project.squash_always? = hidden_field_tag 'merge_request[squash]', '1', id: nil - = check_box_tag 'merge_request[squash]', '1', project.squash_enabled_by_default?, class: 'form-check-input', disabled: 'true' + = render Pajamas::CheckboxTagComponent.new(name: 'merge_request[squash]', checked: project.squash_enabled_by_default?, value: '1', checkbox_options: { class: 'js-form-update', disabled: true }) do |c| + = c.label do + = _("Squash commits when merge request is accepted.") + = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer' + = c.help_text do + = _('Required in this project.') - else = hidden_field_tag 'merge_request[squash]', '0', id: nil - = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input js-form-update' - = label_tag 'merge_request[squash]', class: 'form-check-label' do - = _("Squash commits when merge request is accepted.") - = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer' - - if project.squash_always? - .gl-text-gray-400 - = _('Required in this project.') + = render Pajamas::CheckboxTagComponent.new(name: 'merge_request[squash]', checked: issuable_squash_option?(issuable, project), value: '1', checkbox_options: { class: 'js-form-update' }) do |c| + = c.label do + = _("Squash commits when merge request is accepted.") + = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 4d31baee25b..be836f4b8a9 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -1,6 +1,5 @@ - issuable = local_assigns.fetch(:issuable) - form = local_assigns.fetch(:form) -- no_issuable_templates = issuable_templates(ref_project, issuable.to_ability_name).empty? %div{ data: { testid: 'issue-title-input-field' } } = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true, @@ -13,6 +12,3 @@ = s_('MergeRequests|Mark as draft') = c.help_text do = s_('MergeRequests|Drafts cannot be merged until marked ready.') - - - if no_issuable_templates && can?(current_user, :push_code, issuable.project) - = render 'shared/issuable/form/default_templates' diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index 6d4cd83d55b..2350864f0a6 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -5,28 +5,8 @@ = _('Type') #js-type-popover - .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") - .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')} + .issuable-form-select-holder.form-group.gl-mb-0.gl-display-block + #js-type-select{ data: issuable_type_selector_data(issuable) } - if issuable.incident? %p.form-text.text-muted diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index e189cc34899..fdbe247c6ba 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -2,7 +2,7 @@ - api_awards_path = local_assigns.fetch(:api_awards_path, nil) .issue-details.issuable-details.js-issue-details - .detail-page-description.content-block.js-detail-page-description.gl-pt-2.gl-pb-0.gl-border-none + .detail-page-description.content-block.js-detail-page-description.gl-pt-4.gl-pb-0.gl-border-none #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, issuable_id: issuable.id, full_path: @project.full_path, @@ -30,14 +30,14 @@ #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 } } - - if can?(current_user, :admin_feature_flags_issue_links, @project) - = render_if_exists 'projects/issues/related_feature_flags' - - if can?(current_user, :read_code, @project) - add_page_startup_api_call related_branches_path #related-branches{ data: { url: related_branches_path } } -# This element is filled in using JavaScript. + - if can?(current_user, :admin_feature_flags_issue_links, @project) + = render_if_exists 'projects/issues/related_feature_flags' + .js-issue-widgets = render 'projects/issues/discussion' diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index ccb501dae11..9f7ed6b17c3 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -2,7 +2,7 @@ - badge_classes = 'issuable-status-badge gl-mr-3' .detail-page-header - .detail-page-header-body.gl-flex-wrap-wrap + .detail-page-header-body.gl-flex-wrap = gl_badge_tag({ variant: :info, icon: 'issue-closed', icon_classes: 'gl-mr-0!' }, { class: "#{issue_status_visibility(issuable, status_box: :closed)} #{badge_classes} issuable-status-badge-closed" }) do .gl-display-none.gl-sm-display-block.gl-ml-2 = issue_closed_text(issuable, current_user) diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 5d749b16eee..9148cb615d4 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -19,9 +19,7 @@ %input.label-color-preview.gl-w-7.gl-h-full.gl-border-1.gl-border-solid.gl-border-gray-500.gl-border-r-0.gl-rounded-top-right-none.gl-rounded-bottom-right-none{ type: "color", placeholder: _('Select color') } = f.text_field :color, class: "gl-form-input form-control", data: { qa_selector: 'label_color_field' } .form-text.text-muted - = _('Choose any color.') - %br - = _("Or you can choose one of the suggested colors below") + = _('Select a color from the color picker or from the presets below.') = render_suggested_colors .gl-display-flex.gl-justify-content-space-between %div diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml index 432d2efc36e..caab7710fa8 100644 --- a/app/views/shared/milestones/_delete_button.html.haml +++ b/app/views/shared/milestones/_delete_button.html.haml @@ -1,8 +1,6 @@ - milestone_url = @milestone.project_milestone? ? project_milestone_path(@project, @milestone) : group_milestone_path(@group, @milestone) -= render Pajamas::ButtonComponent.new(variant: :danger, - button_options: { class: 'js-delete-milestone-button btn-grouped', data: { milestone_id: @milestone.id, milestone_title: markdown_field(@milestone, :title), milestone_url: milestone_url, milestone_issue_count: @milestone.issues.count, milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true }) do - = gl_loading_icon(inline: true, css_class: "gl-mr-2 js-loading-icon hidden") - = _('Delete') - +%button.gl-button.btn.btn-link.menu-item.js-delete-milestone-button{ data: { milestone_id: @milestone.id, milestone_title: markdown_field(@milestone, :title), milestone_url: milestone_url, milestone_issue_count: @milestone.issues.count, milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true } + .gl-dropdown-item-text-wrapper.gl-text-red-500 + = _('Delete') #js-delete-milestone-modal diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml index d7908b1c210..a63702661d0 100644 --- a/app/views/shared/milestones/_description.html.haml +++ b/app/views/shared/milestones/_description.html.haml @@ -1,4 +1,4 @@ -.detail-page-description.milestone-detail.gl-py-5 +.detail-page-description.milestone-detail.gl-py-4 %h2.gl-m-0{ data: { qa_selector: "milestone_title_content" } } = markdown_field(milestone, :title) .gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'milestone_id_content' }, itemprop: 'identifier' } diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml index 900c71675d9..3413d6ff399 100644 --- a/app/views/shared/milestones/_header.html.haml +++ b/app/views/shared/milestones/_header.html.haml @@ -1,30 +1,57 @@ -.detail-page-header.milestone-page-header - = gl_badge_tag milestone_status_string(milestone), { variant: milestone_badge_variant(milestone) }, { class: 'gl-mr-3' } +.detail-page-header + .detail-page-header-body.gl-flex-wrap + = gl_badge_tag milestone_status_string(milestone), { variant: milestone_badge_variant(milestone) }, { class: 'gl-mr-3' } - .header-text-content - %span.identifier - %strong - = _('Milestone') - - if milestone.due_date || milestone.start_date - = milestone_date_range(milestone) - - .milestone-buttons - - if can?(current_user, :admin_milestone, @group || @project) - = render Pajamas::ButtonComponent.new(href: edit_milestone_path(milestone), button_options: { class: 'btn-grouped' }) do - = _('Edit') - - - if milestone.project_milestone? && milestone.project.group - = 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 + .header-text-content + %span.identifier + %strong + = _('Milestone') + - if milestone.due_date || milestone.start_date + = milestone_date_range(milestone) + = 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' }) + - if can?(current_user, :admin_milestone, @group || @project) + .milestone-buttons.detail-page-header-actions.gl-display-flex.gl-align-self-start - if milestone.active? - = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-grouped btn-close' }) do + = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-close gl-display-none gl-md-display-inline-block' }) do = _('Close milestone') - else - = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'btn-grouped' }) do + = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'gl-display-none gl-md-display-inline-block' }) do = _('Reopen milestone') - = render 'shared/milestones/delete_button' - - = 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' }) + .btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full + = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Milestone actions'), testid: 'milestone-actions', 'aria-label': _('Milestone actions') }, aria: { label: _('Milestone actions') } do + = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon" + = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do + %span.gl-dropdown-button-text= _('Milestone actions') + = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon" + .dropdown-menu.dropdown-menu-right + .gl-dropdown-inner + .gl-dropdown-contents + %ul + %li.gl-dropdown-item + = link_to edit_milestone_path(milestone), class: 'menu-item' do + .gl-dropdown-item-text-wrapper + = _('Edit') + - if milestone.project_milestone? && milestone.project.group + %li.gl-dropdown-item + %button.gl-button.btn.btn-link.menu-item.js-promote-project-milestone-button{ data: { milestone_title: milestone.title, + group_name: milestone.project.group.name, + url: promote_project_milestone_path(milestone.project, milestone)}, + disabled: true, + type: 'button' } + .gl-dropdown-item-text-wrapper + = _('Promote') + #promote-milestone-modal + - if milestone.active? + %li.gl-dropdown-item{ class: "gl-md-display-none!" } + = link_to update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'menu-item' do + .gl-dropdown-item-text-wrapper + = _('Close milestone') + - else + %li.gl-dropdown-item{ class: "gl-md-display-none!" } + = link_to update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'menu-item' do + .gl-dropdown-item-text-wrapper + = _('Reopen milestone') + %li.gl-dropdown-item + = render 'shared/milestones/delete_button' diff --git a/app/views/shared/nav/_admin_scope_header.html.haml b/app/views/shared/nav/_admin_scope_header.html.haml new file mode 100644 index 00000000000..3a18b3660d4 --- /dev/null +++ b/app/views/shared/nav/_admin_scope_header.html.haml @@ -0,0 +1,6 @@ +%li.context-header + = link_to admin_root_path, title: _('Admin Area'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do + %span.avatar-container.icon-avatar.rect-avatar.s32 + = sprite_icon('admin', size: 18) + %span.sidebar-context-title + = _('Admin Area') diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index cbf0b6f1051..72081856da6 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -9,6 +9,7 @@ .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning = _("Finish editing this message first!") - = submit_tag _('Save comment'), class: 'gl-button btn btn-confirm js-comment-save-button', data: { qa_selector: 'save_comment_button' } + = render Pajamas::ButtonComponent.new(type: 'submit', variant: :confirm, button_options: { class: 'js-comment-save-button', data: { qa_selector: 'save_comment_button' } }) do + = _("Save comment") = render Pajamas::ButtonComponent.new(button_options: { class: 'note-edit-cancel' }) do = _("Cancel") diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index c552e94ac57..95e0beee5e0 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -12,7 +12,7 @@ note_id: note.id } } .timeline-entry-inner - if note.system - .timeline-icon + .gl-float-left.gl--flex-center.gl-rounded-full.gl-mt-n1.gl-ml-2.gl-w-6.gl-h-6.gl-bg-gray-50.gl-text-gray-600 = icon_for_system_note(note) - else .timeline-avatar.gl-float-left diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 09d63347ed6..a2c831bfd1c 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -10,13 +10,12 @@ - skip_pagination = false unless local_assigns[:skip_pagination] == true - compact_mode = false unless local_assigns[:compact_mode] == true - css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}" -- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg' - contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.') - contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects') -- starred_projects_illustration_path = 'illustrations/starred_empty.svg' +- starred_projects_illustration_path = 'illustrations/empty-state/empty-projects-starred-md.svg' - starred_projects_current_user_empty_message_header = s_('UserProfile|Star projects to track their progress and show your appreciation.') - starred_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t starred any projects') -- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg' +- own_projects_illustration_path = 'illustrations/empty-state/empty-projects-md.svg' - own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.') - own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.') - own_projects_visitor_empty_message = s_('UserProfile|There are no projects available to be displayed here.') @@ -43,7 +42,7 @@ = paginate_collection(projects, remote: remote) unless skip_pagination - else - if @contributed_projects - = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path, + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path, current_user_empty_message_header: contributed_projects_current_user_empty_message_header, primary_button_label: new_project_button_label, primary_button_link: new_project_button_link, diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 2adc7844a67..141118110ea 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -14,8 +14,7 @@ - show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) - last_pipeline = project.last_pipeline if show_pipeline_status_icon - css_controls_class = "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present? -- css_controls_container_class = compact_mode ? "" : "gl-lg-flex-direction-row gl-justify-content-space-between" -- css_metadata_classes = "gl-display-flex gl-align-items-center gl-mr-5 gl-reset-color! icon-wrapper has-tooltip" +- css_metadata_classes = "gl-display-flex gl-align-items-center gl-ml-5 gl-reset-color! icon-wrapper has-tooltip" %li.project-row = cache(cache_key) do @@ -28,7 +27,7 @@ = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5') .project-cell{ class: css_class } .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } } - .gl-display-flex.gl-align-items-center.gl-flex-wrap-wrap + .gl-display-flex.gl-align-items-center.gl-flex-wrap %h2.gl-font-base.gl-line-height-20.gl-my-0 = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document' do %span.namespace-name.gl-font-weight-normal @@ -55,10 +54,10 @@ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project, additional_classes: 'gl-ml-3!' - if show_last_commit_as_description - .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2 + .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2.gl-font-sm = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") - elsif project.description.present? - .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2 + .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2.gl-font-sm = markdown_field(project, :description) - if project.topics.any? @@ -71,7 +70,7 @@ .controls.gl-display-flex.gl-align-items-center - if show_pipeline_status_icon && last_pipeline.present? - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref) - %span.icon-wrapper.pipeline-status.gl-mr-5 + %span.icon-wrapper.pipeline-status = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path = render_if_exists 'shared/projects/archived', project: project @@ -79,17 +78,17 @@ = link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do = sprite_icon('star-o', size: 14, css_class: 'gl-mr-2') = badge_count(project.star_count) - .updated-note.gl-ml-3.gl-sm-ml-0 + .updated-note.gl-font-sm.gl-ml-3.gl-sm-ml-0 %span = _('Updated') = updated_tooltip .project-cell{ class: "#{css_class} gl-xs-display-none!" } - .project-controls.gl-display-flex.gl-flex-direction-column.gl-w-full{ class: css_controls_container_class, data: { testid: 'project_controls'} } - .controls.gl-display-flex.gl-align-items-center{ class: css_controls_class } + .project-controls.gl-display-flex.gl-flex-direction-column.gl-align-items-flex-end.gl-w-full{ data: { testid: 'project_controls'} } + .controls.gl-display-flex.gl-align-items-center.gl-mb-2{ class: "#{css_controls_class} gl-pr-0!" } - if show_pipeline_status_icon && last_pipeline.present? - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref) - %span.icon-wrapper.pipeline-status.gl-mr-5 + %span.icon-wrapper.pipeline-status = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path = render_if_exists 'shared/projects/archived', project: project @@ -109,7 +108,7 @@ = link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do = sprite_icon('issues', size: 14, css_class: 'gl-mr-2') = badge_count(project.open_issues_count) - .updated-note.gl-white-space-nowrap.gl-justify-content-end + .updated-note.gl-font-sm.gl-white-space-nowrap.gl-justify-content-end %span = _('Updated') = updated_tooltip diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 47e0e165276..72709b3ed2f 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -2,7 +2,7 @@ - admin_view ||= false - top_padding = admin_view ? 'gl-lg-pt-3' : '' -= form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| += form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], placeholder: placeholder, class: "project-filter-form-field form-control input-short js-projects-list-filter gl-m-0!", diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index be513af4e3f..12246d1dcfa 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -1,31 +1,29 @@ -- cache_enabled = false unless local_assigns[:cache_enabled] == true - max_project_topic_length = 15 - if project.topics.present? - = cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do - .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' } - %span.gl-p-2.gl-text-gray-500 - = _('Topics') + ':' - - project.topics_to_show.each do |topic| - - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) - - if topic[:title].length > max_project_topic_length - %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) - - else - %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag topic[:title] + .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' } + %span.gl-p-2.gl-text-gray-500 + = _('Topics') + ':' + - project.topics_to_show.each do |topic| + - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) + - if topic[:title].length > max_project_topic_length + %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) + - else + %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag topic[:title] - - if project.has_extra_topics? - - title = _('More topics') - - content = capture do - %span.gl-display-inline-flex.gl-flex-wrap - - project.topics_not_shown.each do |topic| - - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) - - if topic[:title].length > max_project_topic_length - %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) - - else - %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag topic[:title] - .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } } - = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown } + - if project.has_extra_topics? + - title = _('More topics') + - content = capture do + %span.gl-display-inline-flex.gl-flex-wrap + - project.topics_not_shown.each do |topic| + - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) + - if topic[:title].length > max_project_topic_length + %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) + - else + %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag topic[:title] + .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } } + = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown } diff --git a/app/views/shared/users/index.html.haml b/app/views/shared/users/index.html.haml index dd6b14d6be2..c6a61e1c4df 100644 --- a/app/views/shared/users/index.html.haml +++ b/app/views/shared/users/index.html.haml @@ -1,7 +1,7 @@ -- followers_illustration_path = 'illustrations/starred_empty.svg' +- followers_illustration_path = 'illustrations/empty-state/empty-projects-starred-md.svg' - followers_visitor_empty_message = s_('UserProfile|This user doesn\'t have any followers.') - followers_current_user_empty_message_header = s_('UserProfile|You do not have any followers.') -- following_illustration_path = 'illustrations/starred_empty.svg' +- following_illustration_path = 'illustrations/empty-state/empty-projects-starred-md.svg' - following_visitor_empty_message = s_('UserProfile|This user isn\'t following other users.') - following_current_user_empty_message_header = s_('UserProfile|You are not following other users.') diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml index d9155b397b8..f8e2dc3d8dd 100644 --- a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml +++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml @@ -8,6 +8,6 @@ = c.body do = s_('Webhooks|A webhook in this project was automatically disabled after being retried multiple times.') = succeed '.' do - = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' = c.actions do = link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button' diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml index 38a7e6fc813..2c5c3aa68a3 100644 --- a/app/views/shared/wikis/_sidebar_wiki_page.html.haml +++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml @@ -1,3 +1,7 @@ +- wiki_path = wiki_page_path(@wiki, wiki_page) + %li{ class: active_when(params[:id] == wiki_page.slug) } - = link_to wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.human_title } do - = wiki_page.human_title + .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } } + = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) + = link_to wiki_path, data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.human_title } do + = wiki_page.human_title diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index ced51e1f697..6a066e0a838 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,8 +1,11 @@ +- wiki_path = wiki_page_path(@wiki, wiki_directory) + %li{ class: active_when(params[:id] == wiki_directory.slug), data: { qa_selector: 'wiki_directory_content' } } - .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list< + .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } }< = sprite_icon('chevron-right', css_class: 'js-wiki-list-expand-button wiki-list-expand-button gl-mr-2 gl-cursor-pointer') = sprite_icon('chevron-down', css_class: 'js-wiki-list-collapse-button wiki-list-collapse-button gl-mr-2 gl-cursor-pointer') - = link_to wiki_page_path(@wiki, wiki_directory), data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do + = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) + = link_to wiki_path, data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do = wiki_directory.title %ul - wiki_directory.entries.each do |entry| diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index 1d22575803b..eeea8a34002 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -1,5 +1,5 @@ - link_project = local_assigns.fetch(:link_project, false) -- illustration_path = 'illustrations/profile-page/activity.svg' +- illustration_path = 'illustrations/empty-state/empty-snippets-md.svg' - current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.') - current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.') - primary_button_label = _('New snippet') diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 583f25b68eb..fe05a3de13a 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -5,7 +5,7 @@ - else - add_page_startup_graphql_call('snippet/user_permissions') - if @snippet.author != current_user - -# Different breadcrumbs if this page is rendered as part of the Explore section + -# If current user is not the snippet author, then it renders with the Explore layout which doesn't have this breadcrumb. - add_to_breadcrumbs _("Snippets"), explore_snippets_path - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") diff --git a/app/views/time_tracking/timelogs/index.html.haml b/app/views/time_tracking/timelogs/index.html.haml new file mode 100644 index 00000000000..b0bfc749606 --- /dev/null +++ b/app/views/time_tracking/timelogs/index.html.haml @@ -0,0 +1,7 @@ +- @force_fluid_layout = true +- page_title _('Time tracking report') + +.page-title-holder.gl-display-flex.gl-flex-align-items-center + %h1.page-title.gl-font-size-h-display= _('Time tracking report') + +#js-timelogs-app{ data: { limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } } diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index a7875f9b089..ce82a5e1614 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -14,9 +14,10 @@ .gl-display-flex %ol.breadcrumb.gl-breadcrumb-list.gl-mb-4 %li.breadcrumb-item.gl-breadcrumb-item - = link_to @user.username, project_path(@user.user_project) - %span.gl-breadcrumb-separator - = sprite_icon("chevron-right", size: 16) + = link_to project_path(@user.user_project) do + = @user.username + %span.gl-breadcrumb-separator + = sprite_icon("chevron-right", size: 16) %li.breadcrumb-item.gl-breadcrumb-item = link_to @user.user_readme.path, @user.user_project.readme_url - if current_user == @user diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 3543d5c4336..70dccc4821b 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true -- @hide_breadcrumbs = true - @no_container = true +- breadcrumb_title user_display_name(@user) - page_title user_display_name(@user) - page_description @user.bio unless @user.blocked? || !@user.confirmed? - page_itemtype 'http://schema.org/Person' @@ -14,161 +14,163 @@ = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") .user-profile - .cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty?)] } - = render layout: 'users/cover_controls' do - - if @user == current_user - = render Pajamas::ButtonComponent.new(href: profile_path, - icon: 'pencil', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - elsif current_user - #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } } - - verified_gpg_keys = @user.gpg_keys.select(&:verified?) - - if verified_gpg_keys.any? - = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path, - icon: 'key', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - if can?(current_user, :read_user_profile, @user) - = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options), - icon: 'rss', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - if current_user && current_user.admin? - = render Pajamas::ButtonComponent.new(href: [:admin, @user], - icon: 'user', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'}}) - - if current_user && current_user.id != @user.id - - if current_user.following?(@user) - = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do - = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do - = _('Unfollow') - - else - = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do - = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do - = _('Follow') + %div{ class: container_class } + .cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty? || show_super_sidebar?)] } + = render layout: 'users/cover_controls' do + - if @user == current_user + = render Pajamas::ButtonComponent.new(href: profile_path, + icon: 'pencil', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + - elsif current_user + #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } } + - verified_gpg_keys = @user.gpg_keys.select(&:verified?) + - if verified_gpg_keys.any? + = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path, + icon: 'key', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + - if can?(current_user, :read_user_profile, @user) + = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options), + icon: 'rss', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + - if current_user && current_user.admin? + = render Pajamas::ButtonComponent.new(href: [:admin, @user], + icon: 'user', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'}}) + - if current_user && current_user.id != @user.id + - if current_user.following?(@user) + = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do + = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do + = _('Unfollow') + - else + = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do + = _('Follow') - .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } - .gl-display-inline-block.gl-mx-8.gl-vertical-align-top - .avatar-holder - = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do - = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" }) - #js-user-achievements{ data: { root_url: root_url, user_id: @user.id } } - .gl-display-inline-block.gl-vertical-align-top.gl-text-left - - if @user.blocked? || !@user.confirmed? - .user-info - %h1.cover-title.gl-my-0 - = user_display_name(@user) - = render "users/profile_basic_info" - - else - .user-info - %h1.cover-title.gl-my-0{ itemprop: 'name' } - = @user.name - - if @user.pronouns.present? - %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle - = "(#{@user.pronouns})" - - if @user.status&.busy? - %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)") + .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } + .gl-display-inline-block.gl-mx-8.gl-vertical-align-top + .avatar-holder + = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do + = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" }) + - if @user.achievements_enabled && Ability.allowed?(current_user, :read_user_profile, @user) + #js-user-achievements{ data: { root_url: root_url, user_id: @user.id } } + .gl-display-inline-block.gl-vertical-align-top.gl-text-left.gl-max-w-80 + - if @user.blocked? || !@user.confirmed? + .user-info + %h1.cover-title.gl-my-0 + = user_display_name(@user) + = render "users/profile_basic_info" + - else + .user-info + %h1.cover-title.gl-my-0{ itemprop: 'name' } + = @user.name + - if @user.pronouns.present? + %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle + = "(#{@user.pronouns})" + - if @user.status&.busy? + = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning', class: 'gl-vertical-align-middle') - - if @user.pronunciation.present? - .gl-align-items-center - %p.gl-mb-4.gl-text-gray-500.gl-max-w-80.gl-mx-auto= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation } + - if @user.pronunciation.present? + .gl-align-items-center + %p.gl-mb-4.gl-text-gray-500= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation } - - if @user.status&.customized? - .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3 - = emoji_icon(@user.status.emoji, class: 'gl-mr-2') - = markdown_field(@user.status, :message) - = render "users/profile_basic_info" - - user_local_time = local_time(@user.timezone) - - if @user.location.present? || user_local_time.present? || work_information(@user).present? + - if @user.status&.customized? + .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3 + = emoji_icon(@user.status.emoji, class: 'gl-mr-2') + = markdown_field(@user.status, :message) + = render "users/profile_basic_info" + - user_local_time = local_time(@user.timezone) + - if @user.location.present? || user_local_time.present? || work_information(@user).present? + .gl-text-gray-900 + - if @user.location.present? + = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do + = sprite_icon('location', css_class: 'fgray') + %span{ itemprop: 'addressLocality' } + = @user.location + - if user_local_time.present? + = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do + = sprite_icon('clock', css_class: 'fgray') + %span + = user_local_time + - if work_information(@user).present? + = render 'middle_dot_divider', stacking: true do + = sprite_icon('work', css_class: 'fgray') + %span + = work_information(@user, with_schema_markup: true) .gl-text-gray-900 - - if @user.location.present? - = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do - = sprite_icon('location', css_class: 'fgray') - %span{ itemprop: 'addressLocality' } - = @user.location - - if user_local_time.present? - = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do - = sprite_icon('clock', css_class: 'fgray') - %span - = user_local_time - - if work_information(@user).present? + - if @user.skype.present? + = render 'middle_dot_divider' do + = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do + = sprite_icon('skype', css_class: 'skype-icon') + - if @user.linkedin.present? + = render 'middle_dot_divider' do + = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('linkedin', css_class: 'linkedin-icon') + - if @user.twitter.present? + = render 'middle_dot_divider', breakpoint: 'sm' do + = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('twitter', css_class: 'twitter-icon') + - if @user.discord.present? + = render 'middle_dot_divider', breakpoint: 'sm' do + = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('discord', css_class: 'discord-icon') + - if @user.website_url.present? + = render 'middle_dot_divider', stacking: true do + - if Feature.enabled?(:security_auto_fix) && @user.bot? + = sprite_icon('question-o', css_class: 'gl-text-blue-500') + = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url' + - if display_public_email?(@user) = render 'middle_dot_divider', stacking: true do - = sprite_icon('work', css_class: 'fgray') - %span - = work_information(@user, with_schema_markup: true) - .gl-text-gray-900 - - if @user.skype.present? - = render 'middle_dot_divider' do - = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do - = sprite_icon('skype', css_class: 'skype-icon') - - if @user.linkedin.present? - = render 'middle_dot_divider' do - = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('linkedin', css_class: 'linkedin-icon') - - if @user.twitter.present? - = render 'middle_dot_divider', breakpoint: 'sm' do - = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('twitter', css_class: 'twitter-icon') - - if @user.discord.present? - = render 'middle_dot_divider', breakpoint: 'sm' do - = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('discord', css_class: 'discord-icon') - - if @user.website_url.present? - = render 'middle_dot_divider', stacking: true do - - if Feature.enabled?(:security_auto_fix) && @user.bot? - = sprite_icon('question', css_class: 'gl-text-blue-600') - = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url' - - if display_public_email?(@user) - = render 'middle_dot_divider', stacking: true do - = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email' - - if @user.bio.present? && @user.confirmed? && !@user.blocked? - %p.profile-user-bio.gl-mb-3 - = @user.bio + = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email' + - if @user.bio.present? && @user.confirmed? && !@user.blocked? + %p.profile-user-bio.gl-mb-3 + = @user.bio - - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user) - .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] } - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs - - if profile_tab?(:overview) - %li.js-overview-tab - = link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do - = s_('UserProfile|Overview') - - if profile_tab?(:activity) - %li.js-activity-tab - = link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do - = s_('UserProfile|Activity') - - unless Feature.enabled?(:security_auto_fix) && @user.bot? - - if profile_tab?(:groups) - %li.js-groups-tab - = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do - = s_('UserProfile|Groups') - - if profile_tab?(:contributed) - %li.js-contributed-tab - = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do - = s_('UserProfile|Contributed projects') - - if profile_tab?(:projects) - %li.js-projects-tab - = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do - = s_('UserProfile|Personal projects') - - if profile_tab?(:starred) - %li.js-starred-tab - = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do - = s_('UserProfile|Starred projects') - - if profile_tab?(:snippets) - %li.js-snippets-tab - = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do - = s_('UserProfile|Snippets') - - if profile_tab?(:followers) - %li.js-followers-tab - = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do - = s_('UserProfile|Followers') - = gl_badge_tag @user.followers.count, size: :sm - - if profile_tab?(:following) - %li.js-following-tab - = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json), qa_selector: 'following_tab' } do - = s_('UserProfile|Following') - = gl_badge_tag @user.followees.count, size: :sm - - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user) - #js-profile-tabs{ data: user_profile_tabs_app_data(@user) } + - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user) + .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] } + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs + - if profile_tab?(:overview) + %li.js-overview-tab + = link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do + = s_('UserProfile|Overview') + - if profile_tab?(:activity) + %li.js-activity-tab + = link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do + = s_('UserProfile|Activity') + - unless Feature.enabled?(:security_auto_fix) && @user.bot? + - if profile_tab?(:groups) + %li.js-groups-tab + = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do + = s_('UserProfile|Groups') + - if profile_tab?(:contributed) + %li.js-contributed-tab + = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do + = s_('UserProfile|Contributed projects') + - if profile_tab?(:projects) + %li.js-projects-tab + = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do + = s_('UserProfile|Personal projects') + - if profile_tab?(:starred) + %li.js-starred-tab + = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do + = s_('UserProfile|Starred projects') + - if profile_tab?(:snippets) + %li.js-snippets-tab + = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do + = s_('UserProfile|Snippets') + - if profile_tab?(:followers) + %li.js-followers-tab + = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do + = s_('UserProfile|Followers') + = gl_badge_tag @user.followers.count, size: :sm + - if profile_tab?(:following) + %li.js-following-tab + = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json), qa_selector: 'following_tab' } do + = s_('UserProfile|Following') + = gl_badge_tag @user.followees.count, size: :sm + - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user) + #js-profile-tabs{ data: user_profile_tabs_app_data(@user) } %div{ class: container_class } - unless Feature.enabled?(:profile_tabs_vue, current_user) .tab-content diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 1624538152e..f47d5da95f0 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -131,7 +131,7 @@ :tags: [] - :name: cluster_agent:clusters_agents_delete_expired_events :worker_name: Clusters::Agents::DeleteExpiredEventsWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -374,7 +374,7 @@ :tags: [] - :name: cronjob:database_ci_namespace_mirrors_consistency_check :worker_name: Database::CiNamespaceMirrorsConsistencyCheckWorker - :feature_category: :pods + :feature_category: :cell :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -383,7 +383,7 @@ :tags: [] - :name: cronjob:database_ci_project_mirrors_consistency_check :worker_name: Database::CiProjectMirrorsConsistencyCheckWorker - :feature_category: :pods + :feature_category: :cell :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -518,7 +518,7 @@ :tags: [] - :name: cronjob:loose_foreign_keys_cleanup :worker_name: LooseForeignKeys::CleanupWorker - :feature_category: :pods + :feature_category: :cell :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -543,6 +543,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:metrics_global_metrics_update + :worker_name: Metrics::GlobalMetricsUpdateWorker + :feature_category: :metrics + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:namespaces_in_product_marketing_emails :worker_name: Namespaces::InProductMarketingEmailsWorker :feature_category: :experimentation_activation @@ -579,6 +588,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:packages_debian_cleanup_dangling_package_files + :worker_name: Packages::Debian::CleanupDanglingPackageFilesWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:pages_domain_removal_cron :worker_name: PagesDomainRemovalCronWorker :feature_category: :pages @@ -833,7 +851,7 @@ :tags: [] - :name: cronjob:users_deactivate_dormant_users :worker_name: Users::DeactivateDormantUsersWorker - :feature_category: :subscription_cost_management + :feature_category: :seat_cost_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -932,7 +950,7 @@ :tags: [] - :name: gcp_cluster:cluster_configure_istio :worker_name: ClusterConfigureIstioWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -941,7 +959,7 @@ :tags: [] - :name: gcp_cluster:cluster_install_app :worker_name: ClusterInstallAppWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -950,7 +968,7 @@ :tags: [] - :name: gcp_cluster:cluster_patch_app :worker_name: ClusterPatchAppWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -959,7 +977,7 @@ :tags: [] - :name: gcp_cluster:cluster_provision :worker_name: ClusterProvisionWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -968,7 +986,7 @@ :tags: [] - :name: gcp_cluster:cluster_update_app :worker_name: ClusterUpdateAppWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -977,7 +995,7 @@ :tags: [] - :name: gcp_cluster:cluster_upgrade_app :worker_name: ClusterUpgradeAppWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -986,7 +1004,7 @@ :tags: [] - :name: gcp_cluster:cluster_wait_for_app_installation :worker_name: ClusterWaitForAppInstallationWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :cpu @@ -995,7 +1013,7 @@ :tags: [] - :name: gcp_cluster:cluster_wait_for_app_update :worker_name: ClusterWaitForAppUpdateWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -1004,7 +1022,7 @@ :tags: [] - :name: gcp_cluster:cluster_wait_for_ingress_ip_address :worker_name: ClusterWaitForIngressIpAddressWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -1013,7 +1031,7 @@ :tags: [] - :name: gcp_cluster:clusters_applications_activate_integration :worker_name: Clusters::Applications::ActivateIntegrationWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -1022,7 +1040,7 @@ :tags: [] - :name: gcp_cluster:clusters_applications_deactivate_integration :worker_name: Clusters::Applications::DeactivateIntegrationWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -1031,7 +1049,7 @@ :tags: [] - :name: gcp_cluster:clusters_applications_uninstall :worker_name: Clusters::Applications::UninstallWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -1040,7 +1058,7 @@ :tags: [] - :name: gcp_cluster:clusters_applications_wait_for_uninstall_app :worker_name: Clusters::Applications::WaitForUninstallAppWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :cpu @@ -1049,7 +1067,7 @@ :tags: [] - :name: gcp_cluster:clusters_cleanup_project_namespace :worker_name: Clusters::Cleanup::ProjectNamespaceWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -1058,7 +1076,7 @@ :tags: [] - :name: gcp_cluster:clusters_cleanup_service_account :worker_name: Clusters::Cleanup::ServiceAccountWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -1067,7 +1085,7 @@ :tags: [] - :name: gcp_cluster:wait_for_cluster_creation :worker_name: WaitForClusterCreationWorker - :feature_category: :kubernetes_management + :feature_category: :deployment_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -1740,6 +1758,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: package_repositories:packages_npm_deprecate_package + :worker_name: Packages::Npm::DeprecatePackageWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_repositories:packages_nuget_extraction :worker_name: Packages::Nuget::ExtractionWorker :feature_category: :package_registry @@ -2294,7 +2321,7 @@ :feature_category: :importers :has_external_dependencies: true :urgency: :low - :resource_boundary: :unknown + :resource_boundary: :memory :weight: 1 :idempotent: false :tags: [] @@ -2303,7 +2330,7 @@ :feature_category: :importers :has_external_dependencies: false :urgency: :low - :resource_boundary: :unknown + :resource_boundary: :memory :weight: 1 :idempotent: true :tags: [] @@ -2919,9 +2946,18 @@ :weight: 1 :idempotent: false :tags: [] +- :name: ml_experiment_tracking_associate_ml_candidate_to_package + :worker_name: Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker + :feature_category: :mlops + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: namespaces_process_sync_events :worker_name: Namespaces::ProcessSyncEventsWorker - :feature_category: :pods + :feature_category: :cell :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown @@ -3200,7 +3236,7 @@ :tags: [] - :name: projects_process_sync_events :worker_name: Projects::ProcessSyncEventsWorker - :feature_category: :pods + :feature_category: :cell :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown @@ -3347,7 +3383,7 @@ :feature_category: :importers :has_external_dependencies: true :urgency: :low - :resource_boundary: :unknown + :resource_boundary: :memory :weight: 1 :idempotent: false :tags: [] @@ -3434,7 +3470,7 @@ :tags: [] - :name: update_highest_role :worker_name: UpdateHighestRoleWorker - :feature_category: :subscription_cost_management + :feature_category: :seat_cost_management :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown @@ -3504,6 +3540,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: work_items_import_work_items_csv + :worker_name: WorkItems::ImportWorkItemsCsvWorker + :feature_category: :team_planning + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: x509_certificate_revoke :worker_name: X509CertificateRevokeWorker :feature_category: :source_code_management diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 8f03c74e13e..f03e0bc0656 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -12,6 +12,7 @@ module BulkImports sidekiq_options retry: false, dead: false worker_has_external_dependencies! deduplicate :until_executing + worker_resource_boundary :memory def perform(pipeline_tracker_id, stage, entity_id) @entity = ::BulkImports::Entity.find(entity_id) diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb index dcac841b3b2..9d1ed30caf6 100644 --- a/app/workers/bulk_imports/relation_export_worker.rb +++ b/app/workers/bulk_imports/relation_export_worker.rb @@ -11,6 +11,7 @@ module BulkImports data_consistency :always feature_category :importers sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + worker_resource_boundary :memory def perform(user_id, portable_id, portable_class, relation) user = User.find(user_id) diff --git a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb index 9a11db33fb6..9407e7c0e0a 100644 --- a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb +++ b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb @@ -15,7 +15,7 @@ module Ci idempotent! def perform - result = ::Ci::Runners::StaleMachinesCleanupService.new.execute + result = ::Ci::Runners::StaleManagersCleanupService.new.execute log_extra_metadata_on_done(:status, result.status) log_hash_metadata_on_done(result.payload) end diff --git a/app/workers/concerns/cluster_agent_queue.rb b/app/workers/concerns/cluster_agent_queue.rb index 68de7cca135..8fdfba11111 100644 --- a/app/workers/concerns/cluster_agent_queue.rb +++ b/app/workers/concerns/cluster_agent_queue.rb @@ -5,6 +5,6 @@ module ClusterAgentQueue included do queue_namespace :cluster_agent - feature_category :kubernetes_management + feature_category :deployment_management end end diff --git a/app/workers/concerns/cluster_cleanup_methods.rb b/app/workers/concerns/cluster_cleanup_methods.rb index 04fa4d69666..c0e670dfbe7 100644 --- a/app/workers/concerns/cluster_cleanup_methods.rb +++ b/app/workers/concerns/cluster_cleanup_methods.rb @@ -55,19 +55,12 @@ module ClusterCleanupMethods cluster.make_cleanup_errored!("#{self.class.name} exceeded the execution limit") end - def cluster_applications_and_status(cluster) - cluster.persisted_applications - .map { |application| "#{application.name}:#{application.status_name}" } - .join(",") - end - def log_exceeded_execution_limit_error(cluster) logger.error({ exception: ExceededExecutionLimitError.name, cluster_id: cluster.id, class_name: self.class.name, cleanup_status: cluster.cleanup_status_name, - applications: cluster_applications_and_status(cluster), event: :failed_to_remove_cluster_and_resources, message: "exceeded execution limit of #{execution_limit} tries" }) diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb index 60ba8785347..5f1a90a99d0 100644 --- a/app/workers/concerns/cluster_queue.rb +++ b/app/workers/concerns/cluster_queue.rb @@ -8,6 +8,6 @@ module ClusterQueue included do queue_namespace :gcp_cluster - feature_category :kubernetes_management + feature_category :deployment_management 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 7e488862696..408354d5caa 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -134,6 +134,8 @@ module Gitlab end def add_identifiers_to_failure(failure, external_identifiers) + external_identifiers[:object_type] = object_type + failure.update_column(:external_identifiers, external_identifiers) end end diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb index 37b40c73ca6..53c92ab8969 100644 --- a/app/workers/database/batched_background_migration/execution_worker.rb +++ b/app/workers/database/batched_background_migration/execution_worker.rb @@ -11,7 +11,6 @@ module Database INTERVAL_VARIANCE = 5.seconds.freeze LEASE_TIMEOUT_MULTIPLIER = 3 - MAX_RUNNING_MIGRATIONS = 4 included do data_consistency :always @@ -21,7 +20,7 @@ module Database class_methods do def max_running_jobs - MAX_RUNNING_MIGRATIONS + Gitlab::CurrentSettings.database_max_running_batched_background_migrations end # We have to overirde this one, as we want diff --git a/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb b/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb index 8918dca372d..e01b29ad4ff 100644 --- a/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb +++ b/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb @@ -6,7 +6,7 @@ module Database include CronjobQueue # rubocop: disable Scalability/CronWorkerContext sidekiq_options retry: false - feature_category :pods + feature_category :cell data_consistency :sticky idempotent! diff --git a/app/workers/database/ci_project_mirrors_consistency_check_worker.rb b/app/workers/database/ci_project_mirrors_consistency_check_worker.rb index 5f10310f8d6..e04e3ab3cc7 100644 --- a/app/workers/database/ci_project_mirrors_consistency_check_worker.rb +++ b/app/workers/database/ci_project_mirrors_consistency_check_worker.rb @@ -6,7 +6,7 @@ module Database include CronjobQueue # rubocop: disable Scalability/CronWorkerContext sidekiq_options retry: false - feature_category :pods + feature_category :cell data_consistency :sticky idempotent! diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index 339383476be..99704b2a71c 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -21,7 +21,7 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker end def should_perform? - Gitlab::IncomingEmail.enabled? + Gitlab::Email::IncomingEmail.enabled? end private diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb index fb7fb661f4c..8cbbe35dd30 100644 --- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb +++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb @@ -14,12 +14,21 @@ module Gitlab sidekiq_options dead: false, retry: 5 + sidekiq_retries_exhausted do |msg, _| + new.track_gist_import('failed', msg['args'][0]) + end + def perform(user_id, gist_hash, notify_key) gist = ::Gitlab::GithubGistsImport::Representation::Gist.from_json_hash(gist_hash) with_logging(user_id, gist.github_identifiers) do result = importer_class.new(gist, user_id).execute - error(user_id, result.errors, gist.github_identifiers) unless result.success? + if result.success? + track_gist_import('success', user_id) + else + error(user_id, result.errors, gist.github_identifiers) + track_gist_import('failed', user_id) + end JobWaiter.notify(notify_key, jid) end @@ -29,6 +38,18 @@ module Gitlab raise end + def track_gist_import(status, user_id) + user = User.find(user_id) + + Gitlab::Tracking.event( + self.class.name, + 'create', + label: 'github_gist_import', + user: user, + status: status + ) + end + private def importer_class diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb index 6d6dea10e64..73f4ea580c4 100644 --- a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb @@ -15,9 +15,7 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) - info(project.id, - message: "starting importer", - importer: 'Importer::ProtectedBranchesImporter') + info(project.id, message: "starting importer", importer: 'Importer::ProtectedBranchesImporter') waiter = Importer::ProtectedBranchesImporter .new(project, client) .execute diff --git a/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb index 5e675193a8c..a216f3d4ebc 100644 --- a/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb +++ b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb @@ -8,9 +8,11 @@ module Gitlab private def track_metrics(with_jid_count, without_jid_count) - Gitlab::Metrics.add_event(:stuck_jira_import_jobs, - jira_imports_without_jid_count: with_jid_count, - jira_imports_with_jid_count: without_jid_count) + Gitlab::Metrics.add_event( + :stuck_jira_import_jobs, + jira_imports_without_jid_count: with_jid_count, + jira_imports_with_jid_count: without_jid_count + ) end def enqueued_import_states diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb index d5e3a86eac1..7235eb4ef4b 100644 --- a/app/workers/issuable_export_csv_worker.rb +++ b/app/workers/issuable_export_csv_worker.rb @@ -9,7 +9,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker feature_category :team_planning worker_resource_boundary :cpu - loggable_arguments 2 + loggable_arguments 0, 1, 2, 3 def perform(type, current_user_id, project_id, params) user = User.find(current_user_id) diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb index 6576aa9fdf4..a0a56695689 100644 --- a/app/workers/jira_connect/sync_merge_request_worker.rb +++ b/app/workers/jira_connect/sync_merge_request_worker.rb @@ -14,10 +14,13 @@ module JiraConnect def perform(merge_request_id, update_sequence_id) merge_request = MergeRequest.find_by_id(merge_request_id) + project = merge_request&.project - return unless merge_request && merge_request.project + return unless merge_request && project - JiraConnect::SyncService.new(merge_request.project).execute(merge_requests: [merge_request], update_sequence_id: update_sequence_id) + branches = [project.repository.find_branch(merge_request.source_branch)].compact.presence if merge_request.open? + + JiraConnect::SyncService.new(project).execute(merge_requests: [merge_request], branches: branches, update_sequence_id: update_sequence_id) end end end diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb index b0ebaf30e99..aa9784e4abb 100644 --- a/app/workers/jira_connect/sync_project_worker.rb +++ b/app/workers/jira_connect/sync_project_worker.rb @@ -3,6 +3,7 @@ module JiraConnect class SyncProjectWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include SortingTitlesValuesHelper sidekiq_options retry: 3 queue_namespace :jira_connect @@ -12,22 +13,31 @@ module JiraConnect worker_has_external_dependencies! - MERGE_REQUEST_LIMIT = 400 + MAX_RECORDS_LIMIT = 400 def perform(project_id, update_sequence_id) project = Project.find_by_id(project_id) return if project.nil? - JiraConnect::SyncService.new(project).execute(merge_requests: merge_requests_to_sync(project), update_sequence_id: update_sequence_id) + sync_params = { merge_requests: merge_requests_to_sync(project), update_sequence_id: update_sequence_id } + sync_params[:branches] = branches_to_sync(project) if Feature.enabled?(:jira_connect_sync_branches, project) + + JiraConnect::SyncService.new(project).execute(**sync_params) end private # rubocop: disable CodeReuse/ActiveRecord def merge_requests_to_sync(project) - project.merge_requests.with_jira_issue_keys.preload(:author).limit(MERGE_REQUEST_LIMIT).order(id: :desc) + project.merge_requests.with_jira_issue_keys.preload(:author).limit(MAX_RECORDS_LIMIT).order(id: :desc) end # rubocop: enable CodeReuse/ActiveRecord + + def branches_to_sync(project) + project.repository.branches_sorted_by(SORT_UPDATED_RECENT).filter_map do |branch| + branch if branch.name.match(Gitlab::Regex.jira_issue_key_regex) + end.first(MAX_RECORDS_LIMIT) + end end end diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb index 9a0909598bb..e6d0261b7f1 100644 --- a/app/workers/loose_foreign_keys/cleanup_worker.rb +++ b/app/workers/loose_foreign_keys/cleanup_worker.rb @@ -7,7 +7,7 @@ module LooseForeignKeys include CronjobQueue # rubocop: disable Scalability/CronWorkerContext sidekiq_options retry: false - feature_category :pods + feature_category :cell data_consistency :always idempotent! diff --git a/app/workers/metrics/global_metrics_update_worker.rb b/app/workers/metrics/global_metrics_update_worker.rb new file mode 100644 index 00000000000..326403a2f8f --- /dev/null +++ b/app/workers/metrics/global_metrics_update_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Metrics + class GlobalMetricsUpdateWorker + include ApplicationWorker + + idempotent! + data_consistency :sticky + feature_category :metrics + + include ExclusiveLeaseGuard + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + LEASE_TIMEOUT = 2.minutes + + def perform + try_obtain_lease { ::Metrics::GlobalMetricsUpdateService.new.execute } + end + + def lease_timeout + LEASE_TIMEOUT + end + end +end diff --git a/app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb b/app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb new file mode 100644 index 00000000000..b9c75c01f81 --- /dev/null +++ b/app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Ml + module ExperimentTracking + class AssociateMlCandidateToPackageWorker + include Gitlab::EventStore::Subscriber + + data_consistency :always + feature_category :mlops + urgency :low + idempotent! + + def handle_event(event) + return unless (candidate = Ml::Candidate.with_project_id_and_iid(event.data[:project_id], event.data[:version])) + return unless (package = Packages::Package.find_by_id(event.data[:id])) + + candidate.package = package + candidate.save! + end + + def self.handles_event?(event) + event.generic? && Ml::Experiment.package_for_experiment?(event.data[:name]) + end + end + end +end diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb index d0124c69781..112badd08b5 100644 --- a/app/workers/namespaces/process_sync_events_worker.rb +++ b/app/workers/namespaces/process_sync_events_worker.rb @@ -9,7 +9,7 @@ module Namespaces data_consistency :always - feature_category :pods + feature_category :cell urgency :high idempotent! diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb index 02b3468c052..c8527182d35 100644 --- a/app/workers/namespaces/root_statistics_worker.rb +++ b/app/workers/namespaces/root_statistics_worker.rb @@ -16,15 +16,23 @@ module Namespaces def perform(namespace_id) namespace = Namespace.find(namespace_id) + if Feature.enabled?(:remove_aggregation_schedule_lease, namespace) + Namespaces::StatisticsRefresherService.new.execute(namespace) + else + refresh_through_namespace_aggregation_schedule(namespace) + end + + notify_storage_usage(namespace) + rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex + Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id, namespace: namespace&.full_path) + end + + def refresh_through_namespace_aggregation_schedule(namespace) return unless namespace.aggregation_scheduled? Namespaces::StatisticsRefresherService.new.execute(namespace) namespace.aggregation_schedule.destroy - - notify_storage_usage(namespace) - rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex - Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id, namespace: namespace&.full_path) end private diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb index 7cd7f5223d6..bf48eb8180e 100644 --- a/app/workers/namespaces/schedule_aggregation_worker.rb +++ b/app/workers/namespaces/schedule_aggregation_worker.rb @@ -13,16 +13,24 @@ module Namespaces idempotent! def perform(namespace_id) - return unless aggregation_schedules_table_exists? - namespace = Namespace.find(namespace_id) root_ancestor = namespace.root_ancestor + if Feature.enabled?(:remove_aggregation_schedule_lease, root_ancestor) + Namespaces::RootStatisticsWorker.perform_async(root_ancestor.id) + else + schedule_through_aggregation_schedules_table(root_ancestor) + end + rescue ActiveRecord::RecordNotFound => ex + Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id) + end + + def schedule_through_aggregation_schedules_table(root_ancestor) + return unless aggregation_schedules_table_exists? + return if root_ancestor.aggregation_scheduled? Namespace::AggregationSchedule.safe_find_or_create_by!(namespace_id: root_ancestor.id) - rescue ActiveRecord::RecordNotFound => ex - Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id) end private diff --git a/app/workers/packages/debian/cleanup_dangling_package_files_worker.rb b/app/workers/packages/debian/cleanup_dangling_package_files_worker.rb new file mode 100644 index 00000000000..03b272db026 --- /dev/null +++ b/app/workers/packages/debian/cleanup_dangling_package_files_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Packages + module Debian + class CleanupDanglingPackageFilesWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + data_consistency :always + + deduplicate :until_executed + idempotent! + + feature_category :package_registry + + THREE_HOUR = 3.hours.freeze + BATCH_TIMEOUT = 250.seconds.freeze + + def perform + return unless Feature.enabled?(:debian_packages) + + package_files = Packages::PackageFile.with_debian_unknown_since(THREE_HOUR.ago) + .installable + + Packages::MarkPackageFilesForDestructionService.new(package_files) + .execute(batch_deadline: Time.zone.now + BATCH_TIMEOUT) + rescue StandardError => e + Gitlab::ErrorTracking.log_exception(e) + end + end + end +end diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb index e9d6ad57749..8e7f0b3b987 100644 --- a/app/workers/packages/debian/process_package_file_worker.rb +++ b/app/workers/packages/debian/process_package_file_worker.rb @@ -26,7 +26,8 @@ module Packages ::Packages::Debian::ProcessPackageFileService.new(package_file, distribution_name, component_name).execute rescue StandardError => e Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, - distribution_name: @distribution_name, component_name: @component_name) + distribution_name: @distribution_name, component_name: @component_name) + package_file.update_column(:status, :error) package_file.package.update_column(:status, :error) end diff --git a/app/workers/packages/npm/deprecate_package_worker.rb b/app/workers/packages/npm/deprecate_package_worker.rb new file mode 100644 index 00000000000..1fd324b89c3 --- /dev/null +++ b/app/workers/packages/npm/deprecate_package_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Packages + module Npm + class DeprecatePackageWorker + include ApplicationWorker + + data_consistency :sticky + queue_namespace :package_repositories + feature_category :package_registry + deduplicate :until_executed + urgency :low + idempotent! + + def perform(project_id, params) + project = Project.find(project_id) + + ::Packages::Npm::DeprecatePackageService.new(project, params).execute + end + end + end +end diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb index 4bbe1b65e5a..b088aed8fb7 100644 --- a/app/workers/projects/process_sync_events_worker.rb +++ b/app/workers/projects/process_sync_events_worker.rb @@ -9,7 +9,7 @@ module Projects data_consistency :always - feature_category :pods + feature_category :cell urgency :high idempotent! diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index f9e12c5135a..641b2291896 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -12,6 +12,7 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab/-/issues/16812 is solved. sidekiq_options retry: false, dead: false sidekiq_options status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION + worker_resource_boundary :memory # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index 9265449fdf4..598cf9ce567 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -25,10 +25,12 @@ class RepositoryUpdateRemoteMirrorWorker # If the update is already running, wait for it to finish before running again # This will wait for a total of 90 seconds in 3 steps - in_lock(remote_mirror_update_lock(remote_mirror.id), - retries: 3, - ttl: remote_mirror.max_runtime, - sleep_sec: LOCK_WAIT_TIME) do + in_lock( + remote_mirror_update_lock(remote_mirror.id), + retries: 3, + ttl: remote_mirror.max_runtime, + sleep_sec: LOCK_WAIT_TIME + ) do update_mirror(remote_mirror, scheduled_time, tries) end rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index a7037863ef5..4ca366efcad 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -63,15 +63,17 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker end def track_error(schedule, error) - Gitlab::ErrorTracking - .track_and_raise_for_dev_exception(error, - issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231', - schedule_id: schedule.id) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231', + schedule_id: schedule.id + ) end def failed_creation_counter - @failed_creation_counter ||= - Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, - "Counter of failed attempts of pipeline schedule creation") + @failed_creation_counter ||= Gitlab::Metrics.counter( + :pipeline_schedule_creation_failed_total, + "Counter of failed attempts of pipeline schedule creation" + ) end end diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb index b3b36ca2ada..c41ba05abaa 100644 --- a/app/workers/service_desk_email_receiver_worker.rb +++ b/app/workers/service_desk_email_receiver_worker.rb @@ -10,7 +10,7 @@ class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Sca sidekiq_options retry: 3 def should_perform? - ::Gitlab::ServiceDeskEmail.enabled? + ::Gitlab::Email::ServiceDeskEmail.enabled? end def receiver diff --git a/app/workers/stuck_export_jobs_worker.rb b/app/workers/stuck_export_jobs_worker.rb index 486d40c443a..ab06ca3107e 100644 --- a/app/workers/stuck_export_jobs_worker.rb +++ b/app/workers/stuck_export_jobs_worker.rb @@ -20,8 +20,7 @@ class StuckExportJobsWorker def perform failed_jobs_count = mark_stuck_jobs_as_failed! - Gitlab::Metrics.add_event(:stuck_export_jobs, - failed_jobs_count: failed_jobs_count) + Gitlab::Metrics.add_event(:stuck_export_jobs, failed_jobs_count: failed_jobs_count) end private diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb index dccf88e1b1a..ec24ee15895 100644 --- a/app/workers/update_highest_role_worker.rb +++ b/app/workers/update_highest_role_worker.rb @@ -7,7 +7,7 @@ class UpdateHighestRoleWorker sidekiq_options retry: 3 - feature_category :subscription_cost_management + feature_category :seat_cost_management urgency :high weight 2 diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb index c3799480b12..d024109e754 100644 --- a/app/workers/users/deactivate_dormant_users_worker.rb +++ b/app/workers/users/deactivate_dormant_users_worker.rb @@ -8,7 +8,7 @@ module Users include CronjobQueue - feature_category :subscription_cost_management + feature_category :seat_cost_management def perform return if Gitlab.com? diff --git a/app/workers/work_items/import_work_items_csv_worker.rb b/app/workers/work_items/import_work_items_csv_worker.rb new file mode 100644 index 00000000000..be7294866df --- /dev/null +++ b/app/workers/work_items/import_work_items_csv_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module WorkItems + class ImportWorkItemsCsvWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 3 + + idempotent! + feature_category :team_planning + + sidekiq_retries_exhausted do |job| + Upload.find(job['args'][2]).destroy + end + + def perform(current_user_id, project_id, upload_id) + upload = Upload.find(upload_id) + user = User.find(current_user_id) + project = Project.find(project_id) + + WorkItems::ImportCsvService.new(user, project, upload.retrieve_uploader).execute + upload.destroy! + rescue ActiveRecord::RecordNotFound + # Resources have been removed, job should not be retried + end + end +end diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb index cb5bae7ca4e..58084405769 100644 --- a/app/workers/x509_issuer_crl_check_worker.rb +++ b/app/workers/x509_issuer_crl_check_worker.rb @@ -40,14 +40,16 @@ class X509IssuerCrlCheckWorker certs = issuer.x509_certificates.where(serial_number: batch, certificate_status: :good) # rubocop: disable CodeReuse/ActiveRecord certs.find_each do |cert| - logger.info(message: "Certificate revoked", - id: cert.id, - email: cert.email, - subject: cert.subject, - serial_number: cert.serial_number, - issuer: cert.x509_issuer.id, - issuer_subject: cert.x509_issuer.subject, - issuer_crl_url: cert.x509_issuer.crl_url) + logger.info( + message: "Certificate revoked", + id: cert.id, + email: cert.email, + subject: cert.subject, + serial_number: cert.serial_number, + issuer: cert.x509_issuer.id, + issuer_subject: cert.x509_issuer.subject, + issuer_crl_url: cert.x509_issuer.crl_url + ) end certs.update_all(certificate_status: :revoked) @@ -60,19 +62,23 @@ class X509IssuerCrlCheckWorker if response&.code == 200 OpenSSL::X509::CRL.new(response.body) else - logger.warn(message: "Failed to download certificate revocation list", - issuer: issuer.id, - issuer_subject: issuer.subject, - issuer_crl_url: issuer.crl_url) + logger.warn( + message: "Failed to download certificate revocation list", + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url + ) nil end rescue OpenSSL::X509::CRLError - logger.warn(message: "Failed to parse certificate revocation list", - issuer: issuer.id, - issuer_subject: issuer.subject, - issuer_crl_url: issuer.crl_url) + logger.warn( + message: "Failed to parse certificate revocation list", + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url + ) nil end |