diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-20 13:43:29 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-20 13:43:29 +0300 |
commit | 3b1af5cc7ed2666ff18b718ce5d30fa5a2756674 (patch) | |
tree | 3bc4a40e0ee51ec27eabf917c537033c0c5b14d4 /app | |
parent | 9bba14be3f2c211bf79e15769cd9b77bc73a13bc (diff) |
Add latest changes from gitlab-org/gitlab@16-1-stable-eev16.1.0-rc42
Diffstat (limited to 'app')
1487 files changed, 22750 insertions, 10597 deletions
diff --git a/app/assets/images/auth_buttons/shibboleth_64.png b/app/assets/images/auth_buttons/shibboleth_64.png Binary files differnew file mode 100644 index 00000000000..d4c752f9400 --- /dev/null +++ b/app/assets/images/auth_buttons/shibboleth_64.png diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue index 4a7c12e5e51..266950e2769 100644 --- a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue +++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue @@ -60,11 +60,11 @@ export default { }; }, computed: { - drawerOffsetTop() { + getDrawerHeaderHeight() { // avoid calculating this in advance because it causes layout thrashing // https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 if (!this.showDrawer) return '0'; - return getContentWrapperHeight('.content-wrapper'); + return getContentWrapperHeight(); }, }, mounted() { @@ -81,7 +81,7 @@ export default { </script> <template> <gl-drawer - :header-height="drawerOffsetTop" + :header-height="getDrawerHeaderHeight" :z-index="300" :open="showDrawer && mounted" @close="closeDrawer" diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue index a58b6e62254..eb5d1d39142 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue @@ -38,7 +38,7 @@ export default { :class="[ { 'gl-ml-5': !contextCommitsEmpty, - 'gl-mt-5': !commitsEmpty && contextCommitsEmpty, + 'gl-mt-1': !commitsEmpty && contextCommitsEmpty, }, ]" :variant="commitsEmpty ? 'confirm' : 'default'" diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue index 9355c1c788f..1490d7e64f5 100644 --- a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue @@ -1,12 +1,20 @@ <script> +import { GlAlert } from '@gitlab/ui'; import ReportHeader from './report_header.vue'; import UserDetails from './user_details.vue'; import ReportedContent from './reported_content.vue'; import HistoryItems from './history_items.vue'; +const alertDefaults = { + visible: false, + variant: '', + message: '', +}; + export default { name: 'AbuseReportApp', components: { + GlAlert, ReportHeader, UserDetails, ReportedContent, @@ -18,15 +26,34 @@ export default { required: true, }, }, + data() { + return { + alert: { ...alertDefaults }, + }; + }, + methods: { + showAlert(variant, message) { + this.alert.visible = true; + this.alert.variant = variant; + this.alert.message = message; + }, + closeAlert() { + this.alert = { ...alertDefaults }; + }, + }, }; </script> <template> <section> + <gl-alert v-if="alert.visible" :variant="alert.variant" class="gl-mt-4" @dismiss="closeAlert">{{ + alert.message + }}</gl-alert> <report-header v-if="abuseReport.user" :user="abuseReport.user" - :actions="abuseReport.actions" + :report="abuseReport.report" + @showAlert="showAlert" /> <user-details v-if="abuseReport.user" :user="abuseReport.user" /> <reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" /> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue new file mode 100644 index 00000000000..57d5d46ceb4 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue @@ -0,0 +1,206 @@ +<script> +import { + GlForm, + GlFormGroup, + GlFormSelect, + GlFormCheckbox, + GlFormInput, + GlButton, + GlDrawer, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { + ACTIONS_I18N, + NO_ACTION, + USER_ACTION_OPTIONS, + REASON_OPTIONS, + STATUS_OPEN, + SUCCESS_ALERT, + FAILED_ALERT, + ERROR_MESSAGE, +} from '../constants'; + +const formDefaults = { + user_action: '', + close: false, + comment: '', + reason: '', +}; + +export default { + name: 'ReportActions', + components: { + GlForm, + GlFormGroup, + GlFormSelect, + GlFormCheckbox, + GlFormInput, + GlButton, + GlDrawer, + }, + props: { + user: { + type: Object, + required: true, + }, + report: { + type: Object, + required: true, + }, + }, + data() { + return { + showActionsDrawer: false, + validationState: { + reason: true, + action: true, + }, + form: { ...formDefaults }, + }; + }, + computed: { + getDrawerHeaderHeight() { + if (!this.showActionsDrawer || gon.use_new_navigation) return '0'; + return getContentWrapperHeight(); + }, + isFormValid() { + return Object.values(this.validationState).every(Boolean); + }, + isOpen() { + return this.report.status === STATUS_OPEN; + }, + isNotCurrentUser() { + return this.user.username !== gon.current_username; + }, + userActionOptions() { + return this.isNotCurrentUser ? USER_ACTION_OPTIONS : [NO_ACTION]; + }, + }, + methods: { + toggleActionsDrawer() { + this.showActionsDrawer = !this.showActionsDrawer; + }, + validateReason() { + this.validationState.reason = Boolean(this.form.reason?.length); + }, + validateAction() { + this.validationState.action = Boolean(this.form.user_action?.length) || this.form.close; + }, + submitForm() { + this.triggerValidation(); + + if (!this.isFormValid) { + return; + } + + axios + .put(this.report.updatePath, this.form) + .then(this.handleResponse) + .catch(this.handleError); + }, + handleResponse({ data }) { + this.toggleActionsDrawer(); + this.$emit('showAlert', SUCCESS_ALERT, data.message); + if (this.form.close) { + this.$emit('closeReport'); + } + this.resetForm(); + }, + handleError({ response }) { + this.toggleActionsDrawer(); + const message = response?.data?.message || ERROR_MESSAGE; + this.$emit('showAlert', FAILED_ALERT, message); + }, + resetForm() { + this.form = { ...formDefaults }; + }, + triggerValidation() { + this.validateReason(); + this.validateAction(); + }, + }, + i18n: ACTIONS_I18N, + reasonOptions: REASON_OPTIONS, + DRAWER_Z_INDEX, +}; +</script> + +<template> + <div> + <gl-button class="gl-w-full" data-testid="actions-button" @click="toggleActionsDrawer"> + {{ $options.i18n.actions }} + </gl-button> + <gl-drawer + :open="showActionsDrawer" + :header-height="getDrawerHeaderHeight" + :z-index="$options.DRAWER_Z_INDEX" + @close="toggleActionsDrawer" + > + <template #title> + <div class="gl-font-weight-bold gl-font-size-h2">{{ $options.i18n.actions }}</div> + </template> + <template #default> + <gl-form @submit.prevent="submitForm"> + <gl-form-group + data-testid="action" + :label="$options.i18n.action" + label-for="action" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + :state="validationState.action" + > + <gl-form-select + id="action" + v-model="form.user_action" + data-testid="action-select" + :options="userActionOptions" + :state="validationState.action" + @change="validateAction" + /> + </gl-form-group> + <gl-form-group v-if="isOpen"> + <gl-form-checkbox v-model="form.close" data-testid="close" @change="validateAction"> + {{ $options.i18n.closeReport }} + </gl-form-checkbox> + </gl-form-group> + <gl-form-group + data-testid="reason" + :label="$options.i18n.reason" + label-for="reason" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + :state="validationState.reason" + > + <gl-form-select + id="reason" + v-model="form.reason" + data-testid="reason-select" + :options="$options.reasonOptions" + :state="validationState.reason" + @change="validateReason" + /> + </gl-form-group> + <gl-form-group + :optional="true" + optional-text="(optional)" + :label="$options.i18n.comment" + label-for="comment" + > + <gl-form-input id="comment" v-model="form.comment" data-testid="comment" /> + </gl-form-group> + </gl-form> + </template> + <template #footer> + <gl-button + variant="confirm" + block + :disabled="!isFormValid" + data-testid="submit-button" + @click="submitForm" + > + {{ $options.i18n.confirm }} + </gl-button> + </template> + </gl-drawer> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue index 54586041354..624dcd47650 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_header.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue @@ -1,26 +1,55 @@ <script> -import { GlAvatar, GlButton, GlLink } from '@gitlab/ui'; -import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; -import { REPORT_HEADER_I18N } from '../constants'; +import { GlBadge, GlIcon, GlAvatar, GlButton, GlLink } from '@gitlab/ui'; +import { REPORT_HEADER_I18N, STATUS_OPEN, STATUS_CLOSED } from '../constants'; +import ReportActions from './report_actions.vue'; export default { name: 'ReportHeader', components: { + GlBadge, + GlIcon, GlAvatar, GlButton, GlLink, - AbuseReportActions, + ReportActions, }, props: { user: { type: Object, required: true, }, - actions: { + report: { type: Object, required: true, }, }, + data() { + return { + state: this.report.status, + }; + }, + computed: { + isOpen() { + return this.state === STATUS_OPEN; + }, + badgeClass() { + return this.isOpen ? 'issuable-status-badge-open' : 'issuable-status-badge-closed'; + }, + badgeVariant() { + return this.isOpen ? 'success' : 'info'; + }, + badgeText() { + return REPORT_HEADER_I18N[this.state]; + }, + badgeIcon() { + return this.isOpen ? 'issues' : 'issue-closed'; + }, + }, + methods: { + closeReport() { + this.state = STATUS_CLOSED; + }, + }, i18n: REPORT_HEADER_I18N, }; </script> @@ -30,17 +59,34 @@ export default { class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" > <div class="gl-display-flex gl-align-items-center"> + <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> <gl-avatar :size="48" :src="user.avatarUrl" /> <h1 class="gl-font-size-h-display gl-my-0 gl-ml-3"> {{ user.name }} </h1> <gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link> </div> - <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0"> - <gl-button :href="user.adminPath" class="flex-grow-1"> + <nav + class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0 gl-xs-flex-direction-column" + > + <gl-button :href="user.adminPath"> {{ $options.i18n.adminProfile }} </gl-button> - <abuse-report-actions :report="actions" class="gl-sm-ml-3" /> + <report-actions + :user="user" + :report="report" + class="gl-sm-ml-3 gl-mt-3 gl-sm-mt-0" + @closeReport="closeReport" + v-on="$listeners" + /> </nav> </header> </template> diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue index b5ffba26360..f4f0fcac58f 100644 --- a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue +++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue @@ -1,10 +1,9 @@ <script> -import { GlButton, GlModal, GlCard, GlLink, GlAvatar } from '@gitlab/ui'; +import { GlButton, GlModal, GlCard, GlLink, GlAvatar, GlTruncateText } from '@gitlab/ui'; import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue'; import { REPORTED_CONTENT_I18N } from '../constants'; export default { @@ -15,8 +14,8 @@ export default { GlCard, GlLink, GlAvatar, + GlTruncateText, TimeAgoTooltip, - TruncatedText, }, modalId: 'abuse-report-screenshot-modal', directives: { @@ -109,13 +108,13 @@ export default { footer-class="gl-bg-white js-test-card-footer" > <template v-if="report.content" #header> - <truncated-text> + <gl-truncate-text> <div ref="gfmContent" v-safe-html:[$options.safeHtmlConfig]="report.content" class="md" ></div> - </truncated-text> + </gl-truncate-text> </template> {{ $options.i18n.reportedBy }} <template #footer> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index a59e10b5d4a..b290581598a 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -1,9 +1,57 @@ -import { s__, n__ } from '~/locale'; +import { s__, n__, __ } from '~/locale'; + +export const STATUS_OPEN = 'open'; +export const STATUS_CLOSED = 'closed'; + +export const SUCCESS_ALERT = 'success'; +export const FAILED_ALERT = 'danger'; + +export const ERROR_MESSAGE = __('Something went wrong. Please try again.'); export const REPORT_HEADER_I18N = { adminProfile: s__('AbuseReport|Admin profile'), + open: __('Open'), + closed: __('Closed'), +}; + +export const ACTIONS_I18N = { + actions: s__('AbuseReport|Actions'), + confirm: s__('AbuseReport|Confirm'), + action: s__('AbuseReport|Action'), + reason: s__('AbuseReport|Reason'), + comment: s__('AbuseReport|Comment'), + closeReport: s__('AbuseReport|Close report'), + requiredFieldFeedback: __('This field is required.'), }; +export const NO_ACTION = { value: '', text: s__('AbuseReport|No action') }; + +export const USER_ACTION_OPTIONS = [ + NO_ACTION, + { value: 'block_user', text: s__('AbuseReport|Block user') }, + { value: 'ban_user', text: s__('AbuseReport|Ban user') }, + { value: 'delete_user', text: s__('AbuseReport|Delete user') }, +]; + +export const REASON_OPTIONS = [ + { value: '', text: '' }, + { value: 'spam', text: s__('AbuseReport|Confirmed spam') }, + { value: 'offensive', text: s__('AbuseReport|Confirmed offensive or abusive behavior') }, + { value: 'phishing', text: s__('AbuseReport|Confirmed phishing') }, + { value: 'crypto', text: s__('AbuseReport|Confirmed crypto mining') }, + { + value: 'credentials', + text: s__('AbuseReport|Confirmed posting of personal information or credentials'), + }, + { + value: 'copyright', + text: s__('AbuseReport|Confirmed violation of a copyright or a trademark'), + }, + { value: 'malware', text: s__('AbuseReport|Confirmed posting of malware') }, + { value: 'other', text: s__('AbuseReport|Something else') }, + { value: 'unconfirmed', text: s__('AbuseReport|Abuse unconfirmed') }, +]; + export const USER_DETAILS_I18N = { createdAt: s__('AbuseReport|Member since'), email: s__('AbuseReport|Email'), 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 deleted file mode 100644 index 5d42caa75ab..00000000000 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue +++ /dev/null @@ -1,177 +0,0 @@ -<script> -import { GlDisclosureDropdown, GlModal } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { __, sprintf } from '~/locale'; -import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -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', - }, - }, -}; -const BLOCK_ACTION = 'block'; -const REMOVE_USER_AND_REPORT_ACTION = 'removeUserAndReport'; - -export default { - name: 'AbuseReportActions', - components: { - GlDisclosureDropdown, - 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]: { - action: this.blockUser, - confirmText: this.$options.i18n.blockUserConfirm, - }, - [REMOVE_USER_AND_REPORT_ACTION]: { - action: this.removeUserAndReport, - confirmText: this.removeUserAndReportConfirmText, - }, - }; - }, - reportActionsDropdownItems() { - return [ - { - text: this.$options.i18n.removeUserAndReport, - action: () => { - this.showConfirmModal(REMOVE_USER_AND_REPORT_ACTION); - }, - extraAttrs: { class: 'gl-text-red-500!' }, - }, - { - text: this.blockUserButtonText, - action: () => { - this.showConfirmModal(BLOCK_ACTION); - }, - extraAttrs: { - disabled: this.userBlocked, - 'data-testid': 'block-user-button', - }, - }, - { - text: this.$options.i18n.removeReport, - action: () => { - this.removeReport(); - }, - }, - ]; - }, - }, - 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() { - // eslint-disable-next-line import/no-deprecated - if (this.report.redirectPath) redirectTo(this.report.redirectPath); - else refreshCurrentPage(); - }, - 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> - <div> - <gl-disclosure-dropdown - :toggle-text="$options.i18n.actionsToggleText" - text-sr-only - icon="ellipsis_v" - category="tertiary" - no-caret - placement="right" - :items="reportActionsDropdownItems" - /> - <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" - /> - </div> -</template> diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js index 7dd60e9da95..9458aea299e 100644 --- a/app/assets/javascripts/admin/abuse_reports/constants.js +++ b/app/assets/javascripts/admin/abuse_reports/constants.js @@ -78,13 +78,3 @@ 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'), - actionsToggleText: __('Actions'), -}; 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 022f5df9c96..427e6c14327 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -12,7 +12,7 @@ import { GlFormTextarea, } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { createAlert, VARIANT_DANGER } from '~/alert'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; @@ -71,6 +71,11 @@ export default { addError: s__('BroadcastMessages|There was an error adding broadcast message.'), update: s__('BroadcastMessages|Update broadcast message'), updateError: s__('BroadcastMessages|There was an error updating broadcast message.'), + cancel: __('Cancel'), + showInCli: s__('BroadcastMessages|Git remote responses'), + showInCliDescription: s__( + 'BroadcastMessages|Show the broadcast message in a command-line interface as a Git remote response', + ), }, messageThemes: THEMES, messageTypes: TYPES, @@ -96,6 +101,7 @@ export default { startsAt: new Date(this.broadcastMessage.startsAt.getTime()), endsAt: new Date(this.broadcastMessage.endsAt.getTime()), renderedMessage: '', + showInCli: this.broadcastMessage.showInCli, }; }, computed: { @@ -126,6 +132,7 @@ export default { target_access_levels: this.targetAccessLevels, starts_at: this.startsAt.toISOString(), ends_at: this.endsAt.toISOString(), + show_in_cli: this.showInCli, }); }, }, @@ -225,6 +232,17 @@ export default { <span>{{ $options.i18n.dismissableDescription }}</span> </gl-form-checkbox> </gl-form-group> + + <gl-form-group :label="$options.i18n.showInCli" label-for="show-in-cli-checkbox"> + <gl-form-checkbox + id="show-in-cli-checkbox" + v-model="showInCli" + class="gl-mt-3" + data-testid="show-in-cli-checkbox" + > + <span>{{ $options.i18n.showInCliDescription }}</span> + </gl-form-checkbox> + </gl-form-group> </template> <gl-form-group :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes"> @@ -256,9 +274,13 @@ export default { :loading="loading" :disabled="messageBlank" data-testid="submit-button" + class="gl-mr-2" > {{ isAddForm ? $options.i18n.add : $options.i18n.update }} </gl-button> + <gl-button v-if="!isAddForm" :href="messagesPath" data-testid="cancel-button"> + {{ $options.i18n.cancel }} + </gl-button> </div> </gl-form> </template> diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js index 9f64b2dcaa0..ed137181a48 100644 --- a/app/assets/javascripts/admin/broadcast_messages/constants.js +++ b/app/assets/javascripts/admin/broadcast_messages/constants.js @@ -30,4 +30,5 @@ export const NEW_BROADCAST_MESSAGE = { targetAccessLevels: [], startsAt: new Date(), endsAt: new Date(), + showInCli: true, }; diff --git a/app/assets/javascripts/admin/broadcast_messages/edit.js b/app/assets/javascripts/admin/broadcast_messages/edit.js index 91dae949d45..33b3b028c58 100644 --- a/app/assets/javascripts/admin/broadcast_messages/edit.js +++ b/app/assets/javascripts/admin/broadcast_messages/edit.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import MessageForm from './components/message_form.vue'; export default () => { @@ -16,6 +17,7 @@ export default () => { targetPath, startsAt, endsAt, + showInCli, } = el.dataset; return new Vue({ @@ -34,11 +36,12 @@ export default () => { message, broadcastType, theme, - dismissable: dismissable === 'true', + dismissable: parseBoolean(dismissable), targetAccessLevels: JSON.parse(targetAccessLevels), targetPath, startsAt: new Date(startsAt), endsAt: new Date(endsAt), + showInCli: parseBoolean(showInCli), }, }, }); 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 1a586bd1e91..bc4df04cb30 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -159,8 +159,10 @@ export default { </div> <div class="gl-display-table-cell gl-pr-3 gl-vertical-align-middle"> - <div class="right-arrow"> - <i class="right-arrow-head"></i> + <div class="right-arrow gl-relative gl-w-full gl-bg-gray-400"> + <i + class="right-arrow-head gl-absolute gl-border-solid gl-border-gray-400 gl-display-inline-block gl-p-2" + ></i> </div> </div> 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 133513d6c21..33d6eb139f7 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -22,6 +22,7 @@ import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_t import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { MAX_LABELS } from '../constants'; export default { name: 'FilterBar', @@ -70,6 +71,7 @@ export default { symbol: '~', operators: OPERATORS_IS, fetchLabels: this.fetchLabels, + maxSuggestions: MAX_LABELS, }, { icon: 'pencil', @@ -146,6 +148,7 @@ export default { :search-input-placeholder="__('Filter results')" :tokens="tokens" :initial-filter-value="initialFilterValue()" + terms-as-tokens @onFilter="handleFilter" /> <url-sync :query="query" /> 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 b9d1c4b0fe0..0de62013a63 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 @@ -82,6 +82,7 @@ export default { <div> <projects-dropdown-filter v-if="hasProjectFilter" + toggle-classes="gl-max-w-26" class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0" :group-namespace="groupPath" :query-params="projectsQueryParams" diff --git a/app/assets/javascripts/analytics/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js index bea562fb18c..c14f3cfc6c9 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/constants.js +++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js @@ -43,3 +43,4 @@ export const METRICS_REQUESTS = [ export const MILESTONES_ENDPOINT = '/-/milestones.json'; export const LABELS_ENDPOINT = '/-/labels.json'; +export const MAX_LABELS = 100; diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index 98193de4a12..f881c924ae5 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -1,14 +1,5 @@ <script> -import { - GlIcon, - GlLoadingIcon, - GlAvatar, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlIcon, GlAvatar, GlCollapsibleListbox, GlTruncate } from '@gitlab/ui'; import { debounce } from 'lodash'; import { filterBySearchTerm } from '~/analytics/shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -18,17 +9,15 @@ import { n__, s__, __ } from '~/locale'; import getProjects from '../graphql/projects.query.graphql'; const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name)); +const mapItemToListboxFormat = (item) => ({ ...item, value: item.id, text: item.name }); export default { name: 'ProjectsDropdownFilter', components: { + GlButton, GlIcon, - GlLoadingIcon, GlAvatar, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlSearchBoxByType, + GlCollapsibleListbox, GlTruncate, }, props: { @@ -61,6 +50,11 @@ export default { required: false, default: false, }, + toggleClasses: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -94,6 +88,9 @@ export default { selectedProjectIds() { return this.selectedProjects.map((p) => p.id); }, + selectedListBoxItems() { + return this.multiSelect ? this.selectedProjectIds : this.selectedProjectIds[0]; + }, hasSelectedProjects() { return Boolean(this.selectedProjects.length); }, @@ -110,6 +107,28 @@ export default { unselectedItems() { return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id)); }, + selectedGroupOptions() { + return this.selectedItems.map(mapItemToListboxFormat); + }, + unSelectedGroupOptions() { + return this.unselectedItems.map(mapItemToListboxFormat); + }, + listBoxItems() { + if (this.selectedGroupOptions.length === 0) { + return this.unSelectedGroupOptions; + } + + return [ + { + text: __('Selected'), + options: this.selectedGroupOptions, + }, + { + text: __('Unselected'), + options: this.unSelectedGroupOptions, + }, + ]; + }, }, watch: { searchTerm() { @@ -129,32 +148,29 @@ export default { search: debounce(function debouncedSearch() { this.fetchData(); }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getSelectedProjects(selectedProject, isSelected) { - return isSelected - ? this.selectedProjects.concat([selectedProject]) - : this.selectedProjects.filter((project) => project.id !== selectedProject.id); - }, singleSelectedProject(selectedObj, isMarking) { return isMarking ? [selectedObj] : []; }, - setSelectedProjects(project) { + setSelectedProjects(payload) { this.selectedProjects = this.multiSelect - ? this.getSelectedProjects(project, !this.isProjectSelected(project)) - : this.singleSelectedProject(project, !this.isProjectSelected(project)); + ? payload + : this.singleSelectedProject(payload, !this.isProjectSelected(payload)); }, - onClick(project) { + onClick(projectId) { + const project = this.availableProjects.find(({ id }) => id === projectId); this.setSelectedProjects(project); this.handleUpdatedSelectedProjects(); }, - onMultiSelectClick(project) { - this.setSelectedProjects(project); + onMultiSelectClick(projectIds) { + const projects = this.availableProjects.filter(({ id }) => projectIds.includes(id)); + this.setSelectedProjects(projects); this.isDirty = true; }, - onSelected(project) { + onSelected(payload) { if (this.multiSelect) { - this.onMultiSelectClick(project); + this.onMultiSelectClick(payload); } else { - this.onClick(project); + this.onClick(payload); } }, onHide() { @@ -201,97 +217,67 @@ export default { getEntityId(project) { return getIdFromGraphQLId(project.id); }, + setSearchTerm(val) { + this.searchTerm = val; + }, }, AVATAR_SHAPE_OPTION_RECT, }; </script> <template> - <gl-dropdown + <gl-collapsible-listbox ref="projectsDropdown" - class="dropdown dropdown-projects" - toggle-class="gl-shadow-none gl-mb-0" + :header-text="__('Projects')" + :items="listBoxItems" + :reset-button-label="__('Clear All')" :loading="loadingDefaultProjects" - :show-clear-all="hasSelectedProjects" - show-highlighted-items-title - highlighted-items-title-class="gl-p-3" - block - @clear-all.stop="onClearAll" - @hide="onHide" + :multiple="multiSelect" + :no-results-text="__('No matching results')" + :selected="selectedListBoxItems" + :searching="loading" + searchable + @hidden="onHide" + @reset="onClearAll" + @search="setSearchTerm" + @select="onSelected" > - <template #button-content> - <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2 gl-flex-shrink-0" /> - <gl-avatar - v-if="isOnlyOneProjectSelected" - :src="selectedProjects[0].avatarUrl" - :entity-id="getEntityId(selectedProjects[0])" - :entity-name="selectedProjects[0].name" - :size="16" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - :alt="selectedProjects[0].name" - class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0" - /> - <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" /> - <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" /> - </template> - <template #header> - <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> - <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search')" /> - </template> - <template #highlighted-items> - <gl-dropdown-item - v-for="project in selectedItems" - :key="project.id" - is-check-item - :is-checked="isProjectSelected(project)" - @click.native.capture.stop="onSelected(project)" + <template #toggle> + <gl-button + button-text-classes="gl-w-full gl-justify-content-space-between gl-display-flex gl-shadow-none gl-mb-0" + :class="['dropdown-projects', toggleClasses]" > - <div class="gl-display-flex"> - <gl-avatar - class="gl-mr-2 gl-vertical-align-middle" - :alt="project.name" - :size="16" - :entity-id="getEntityId(project)" - :entity-name="project.name" - :src="project.avatarUrl" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - /> - <div> - <div data-testid="project-name">{{ project.name }}</div> - <div class="gl-text-gray-500" data-testid="project-full-path"> - {{ project.fullPath }} - </div> - </div> - </div> - </gl-dropdown-item> + <gl-avatar + v-if="isOnlyOneProjectSelected" + :src="selectedProjects[0].avatarUrl" + :entity-id="getEntityId(selectedProjects[0])" + :entity-name="selectedProjects[0].name" + :size="16" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :alt="selectedProjects[0].name" + class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0" + /> + <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" /> + <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" /> + </gl-button> </template> - <gl-dropdown-item - v-for="project in unselectedItems" - :key="project.id" - @click.native.capture.stop="onSelected(project)" - > + <template #list-item="{ item }"> <div class="gl-display-flex"> <gl-avatar - class="gl-mr-2 vertical-align-middle" - :alt="project.name" + class="gl-mr-2 gl-vertical-align-middle" + :alt="item.name" :size="16" - :entity-id="getEntityId(project)" - :entity-name="project.name" - :src="project.avatarUrl" + :entity-id="getEntityId(item)" + :entity-name="item.name" + :src="item.avatarUrl" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> <div> - <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div> + <div data-testid="project-name" data-qa-selector="project_name">{{ item.name }}</div> <div class="gl-text-gray-500" data-testid="project-full-path"> - {{ project.fullPath }} + {{ item.fullPath }} </div> </div> </div> - </gl-dropdown-item> - <gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{ - __('No matching results') - }}</gl-dropdown-item> - <gl-dropdown-item v-if="loading"> - <gl-loading-icon size="lg" /> - </gl-dropdown-item> - </gl-dropdown> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index c98cf90f406..25699c17b10 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,4 +1,5 @@ -import { masks } from '~/lib/dateformat'; +import dateFormat, { masks } from '~/lib/dateformat'; +import { nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility'; import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -13,12 +14,19 @@ export const dateFormats = { month: 'mmmm', }; +const startOfToday = getStartOfDay(new Date(), { utc: true }); +const last180Days = nDaysBefore(startOfToday, DATE_RANGE_LIMIT, { utc: true }); +const formatDateParam = (d) => dateFormat(d, dateFormats.isoDate, true); + export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details'); -export const KEY_METRICS = { +export const ISSUES_COMPLETED_TYPE = 'issues_completed'; + +export const FLOW_METRICS = { LEAD_TIME: 'lead_time', CYCLE_TIME: 'cycle_time', ISSUES: 'issues', + ISSUES_COMPLETED: ISSUES_COMPLETED_TYPE, COMMITS: 'commits', DEPLOYS: 'deploys', }; @@ -33,7 +41,7 @@ export const DORA_METRICS = { const VSA_FLOW_METRICS_GROUP = { key: 'key_metrics', title: s__('ValueStreamAnalytics|Key metrics'), - keys: Object.values(KEY_METRICS), + keys: Object.values(FLOW_METRICS), }; export const VSA_METRICS_GROUPS = [VSA_FLOW_METRICS_GROUP]; @@ -46,6 +54,12 @@ export const VULNERABILITY_METRICS = { HIGH: VULNERABILITY_HIGH_TYPE, }; +export const MERGE_REQUEST_THROUGHPUT_TYPE = 'merge_request_throughput'; + +export const MERGE_REQUEST_METRICS = { + THROUGHPUT: MERGE_REQUEST_THROUGHPUT_TYPE, +}; + export const METRIC_TOOLTIPS = { [DORA_METRICS.DEPLOYMENT_FREQUENCY]: { description: s__( @@ -79,7 +93,7 @@ export const METRIC_TOOLTIPS = { projectLink: '-/pipelines/charts?chart=change-failure-rate', docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }), }, - [KEY_METRICS.LEAD_TIME]: { + [FLOW_METRICS.LEAD_TIME]: { description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), groupLink: '-/analytics/value_stream_analytics', projectLink: '-/value_stream_analytics', @@ -87,7 +101,7 @@ export const METRIC_TOOLTIPS = { anchor: 'view-the-lead-time-and-cycle-time-for-issues', }), }, - [KEY_METRICS.CYCLE_TIME]: { + [FLOW_METRICS.CYCLE_TIME]: { description: s__( "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", ), @@ -97,13 +111,21 @@ export const METRIC_TOOLTIPS = { anchor: 'view-the-lead-time-and-cycle-time-for-issues', }), }, - [KEY_METRICS.ISSUES]: { + [FLOW_METRICS.ISSUES]: { description: s__('ValueStreamAnalytics|Number of new issues created.'), groupLink: '-/issues_analytics', projectLink: '-/analytics/issues_analytics', docsLink: helpPagePath('user/analytics/issue_analytics'), }, - [KEY_METRICS.DEPLOYS]: { + [FLOW_METRICS.ISSUES_COMPLETED]: { + description: s__('ValueStreamAnalytics|Number of issues closed by month.'), + groupLink: '-/analytics/value_stream_analytics', + projectLink: '-/value_stream_analytics', + docsLink: helpPagePath('user/analytics/value_streams_dashboard', { + anchor: 'dashboard-metrics-and-drill-down-reports', + }), + }, + [FLOW_METRICS.DEPLOYS]: { description: s__('ValueStreamAnalytics|Total number of deploys to production.'), groupLink: '-/analytics/productivity_analytics', projectLink: '-/analytics/merge_request_analytics', @@ -111,15 +133,25 @@ export const METRIC_TOOLTIPS = { }, [VULNERABILITY_METRICS.CRITICAL]: { description: s__('ValueStreamAnalytics|Critical vulnerabilities over time.'), - groupLink: '-/security/vulnerabilities', - projectLink: '-/security/vulnerability_report', - docsLink: helpPagePath('user/application_security/vulnerability_report/index'), + groupLink: '-/security/vulnerabilities?severity=CRITICAL', + projectLink: '-/security/vulnerability_report?severity=CRITICAL', + docsLink: helpPagePath('user/application_security/vulnerabilities/severities.html'), }, [VULNERABILITY_METRICS.HIGH]: { description: s__('ValueStreamAnalytics|High vulnerabilities over time.'), - groupLink: '-/security/vulnerabilities', - projectLink: '-/security/vulnerability_report', - docsLink: helpPagePath('user/application_security/vulnerability_report/index'), + groupLink: '-/security/vulnerabilities?severity=HIGH', + projectLink: '-/security/vulnerability_report?severity=HIGH', + docsLink: helpPagePath('user/application_security/vulnerabilities/severities.html'), + }, + [MERGE_REQUEST_METRICS.THROUGHPUT]: { + description: s__('ValueStreamAnalytics|The number of merge requests merged by month.'), + groupLink: '-/analytics/productivity_analytics', + projectLink: `-/analytics/merge_request_analytics?start_date=${formatDateParam( + last180Days, + )}&end_date=${formatDateParam(startOfToday)}`, + docsLink: helpPagePath('user/analytics/merge_request_analytics', { + anchor: 'view-the-number-of-merge-requests-in-a-date-range', + }), }, }; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 87c74438d00..95da3b3cf49 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -97,6 +97,7 @@ const Api = { secureFilePath: '/api/:version/projects/:project_id/secure_files/:secure_file_id', secureFilesPath: '/api/:version/projects/:project_id/secure_files', dependencyProxyPath: '/api/:version/groups/:id/dependency_proxy/cache', + markdownPath: '/api/:version/markdown', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -1017,6 +1018,12 @@ const Api = { return axios.delete(url, { params: { ...options } }); }, + + markdown(data = {}) { + const url = Api.buildUrl(this.markdownPath); + + return axios.post(url, data); + }, }; export default Api; diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 3ebb07807d2..17ad1a0b31d 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -10,6 +10,7 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects'; const USER_POST_STATUS_PATH = '/api/:version/user/status'; const USER_FOLLOW_PATH = '/api/:version/users/:id/follow'; const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow'; +const USER_FOLLOWERS_PATH = '/api/:version/users/:id/followers'; const USER_ASSOCIATIONS_COUNT_PATH = '/api/:version/users/:id/associations_count'; export function getUsers(query, options) { @@ -71,6 +72,16 @@ export function unfollowUser(userId) { return axios.post(url); } +export function getUserFollowers(userId, params) { + const url = buildApiUrl(USER_FOLLOWERS_PATH).replace(':id', encodeURIComponent(userId)); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...params, + }, + }); +} + export function associationsCount(userId) { const url = buildApiUrl(USER_ASSOCIATIONS_COUNT_PATH).replace(':id', encodeURIComponent(userId)); return axios.get(url); diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js index 531b42bc185..86728f1b586 100644 --- a/app/assets/javascripts/artifacts_settings/index.js +++ b/app/assets/javascripts/artifacts_settings/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import KeepLatestArtifactCheckbox from '~/artifacts_settings/keep_latest_artifact_checkbox.vue'; +import KeepLatestArtifactToggle from '~/artifacts_settings/keep_latest_artifact_toggle.vue'; import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); @@ -26,7 +26,7 @@ export default (containerId = 'js-artifacts-settings-app') => { helpPagePath, }, render(createElement) { - return createElement(KeepLatestArtifactCheckbox); + return createElement(KeepLatestArtifactToggle); }, }); }; diff --git a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_toggle.vue index 8e7ccb80784..db7d1057402 100644 --- a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue +++ b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_toggle.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import { GlAlert, GlToggle, GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; import UpdateKeepLatestArtifactProjectSetting from './graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql'; import GetKeepLatestArtifactProjectSetting from './graphql/queries/get_keep_latest_artifact_project_setting.query.graphql'; @@ -13,12 +13,12 @@ export default { enabledHelpText: __( 'The latest artifacts created by jobs in the most recent successful pipeline will be stored.', ), - helpLinkText: __('More information'), - checkboxText: __('Keep artifacts from most recent successful jobs'), + helpLinkText: __('Learn more.'), + labelText: __('Keep artifacts from most recent successful jobs'), }, components: { GlAlert, - GlFormCheckbox, + GlToggle, GlLink, }, inject: { @@ -95,10 +95,16 @@ export default { @dismiss="isAlertDismissed = true" >{{ errorMessage }}</gl-alert > - <gl-form-checkbox v-model="keepLatestArtifact" @change="updateSetting" - ><strong class="gl-mr-3">{{ $options.i18n.checkboxText }}</strong> - <gl-link :href="helpPagePath">{{ $options.i18n.helpLinkText }}</gl-link> - <template v-if="!$apollo.loading" #help>{{ helpText }}</template> - </gl-form-checkbox> + <gl-toggle + v-model="keepLatestArtifact" + :is-loading="$apollo.loading" + :label="$options.i18n.labelText" + @change="updateSetting" + > + <template #help> + {{ helpText }} + <gl-link :href="helpPagePath">{{ $options.i18n.helpLinkText }}</gl-link> + </template> + </gl-toggle> </div> </template> diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue index fa9a7782b74..6e3af96cf33 100644 --- a/app/assets/javascripts/authentication/password/components/password_input.vue +++ b/app/assets/javascripts/authentication/password/components/password_input.vue @@ -15,27 +15,27 @@ export default { title: { type: String, required: false, - default: '', + default: null, }, id: { type: String, required: false, - default: '', + default: null, }, minimumPasswordLength: { type: String, required: false, - default: '', + default: null, }, qaSelector: { type: String, required: false, - default: '', + default: null, }, testid: { type: String, required: false, - default: '', + default: null, }, autocomplete: { type: String, diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue index 2ebde10c229..74917da6426 100644 --- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue +++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue @@ -15,11 +15,23 @@ export default { type: String, required: true, }, + showPin: { + type: Boolean, + required: false, + default: true, + }, + positionType: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('batchComments', ['draftsForFile']), drafts() { - return this.draftsForFile(this.fileHash); + return this.draftsForFile(this.fileHash).filter( + (f) => f.position?.position_type === this.positionType, + ); }, }, }; @@ -34,6 +46,7 @@ export default { > <div class="notes"> <design-note-pin + v-if="showPin" :label="toggleText(draft, index)" is-draft class="js-diff-notes-index gl-translate-x-n50" diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 798ab301c90..cc52285dd81 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { SET_REVIEW_BAR_RENDERED } from '~/batch_comments/stores/modules/batch_comments/mutation_types'; import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants'; import PreviewDropdown from './preview_dropdown.vue'; import SubmitDropdown from './submit_dropdown.vue'; @@ -23,6 +24,7 @@ export default { }, mounted() { document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME); + this.$store.commit(`batchComments/${SET_REVIEW_BAR_RENDERED}`); }, beforeDestroy() { document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME); @@ -34,7 +36,7 @@ export default { </script> <template> <div> - <nav class="review-bar-component" data-testid="review_bar_component"> + <nav class="review-bar-component js-review-bar" data-testid="review_bar_component"> <div class="review-bar-content d-flex gl-justify-content-end" data-qa-selector="review_bar_content" diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index d2db61e096a..e6c3a0cba58 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -54,7 +54,7 @@ export default { // whenever a item in the autocomplete dropdown is clicked const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler; this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => { - if (!e.target.closest('.atwho-container')) { + if (!e.composedPath().includes(this.$el)) { originalClickOutHandler(e); } }; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index f6eae7c0c83..45e7256a734 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; import { createAlert } from '~/alert'; import { scrollToElement } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; import service from '../../../services/drafts_service'; import * as types from './mutation_types'; @@ -23,12 +24,17 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => }); }); -export const createNewDraft = ({ commit }, { endpoint, data }) => +export const createNewDraft = ({ commit, dispatch }, { endpoint, data }) => service .createNewDraft(endpoint, data) .then((res) => res.data) .then((res) => { commit(types.ADD_NEW_DRAFT, res); + + if (res.position?.position_type === FILE_DIFF_POSITION_TYPE) { + dispatch('diffs/addDraftToFile', { filePath: res.file_path, draft: res }, { root: true }); + } + return res; }) .catch(() => { @@ -56,7 +62,9 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) => .then((data) => commit(types.SET_BATCH_COMMENTS_DRAFTS, data)) .then(() => { state.drafts.forEach((draft) => { - if (!draft.line_code) { + if (draft.position?.position_type === FILE_DIFF_POSITION_TYPE) { + dispatch('diffs/addDraftToFile', { filePath: draft.file_path, draft }, { root: true }); + } else if (!draft.line_code) { dispatch('convertToDiscussion', draft.discussion_id, { root: true }); } }); diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js index 75e4ae63c18..28b9100c5f3 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js @@ -71,7 +71,7 @@ export const draftsForLine = (state, getters) => (diffFileSha, line, side = null const showDraftsForThisSide = showDraftOnSide(line, side); if (showDraftsForThisSide && draftsForFile?.[key]) { - return draftsForFile[key]; + return draftsForFile[key].filter((d) => d.position.position_type === 'text'); } return []; }; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js index 67bcc53ac7d..2000ee69bad 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js @@ -16,3 +16,5 @@ export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS'; export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION'; export const CLEAR_DRAFTS = 'CLEAR_DRAFTS'; + +export const SET_REVIEW_BAR_RENDERED = 'SET_REVIEW_BAR_RENDERED'; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js index 7961cf134be..453dc861702 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js @@ -68,4 +68,7 @@ export default { [types.CLEAR_DRAFTS](state) { state.drafts = []; }, + [types.SET_REVIEW_BAR_RENDERED](state) { + state.reviewBarRendered = true; + }, }; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js index 10033ba17f9..1efc00059d0 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js @@ -5,4 +5,5 @@ export default () => ({ isPublishing: false, currentlyPublishingDrafts: [], shouldAnimateReviewButton: false, + reviewBarRendered: false, }); diff --git a/app/assets/javascripts/behaviors/markdown/utils.js b/app/assets/javascripts/behaviors/markdown/utils.js new file mode 100644 index 00000000000..f02d6c0f813 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/utils.js @@ -0,0 +1,27 @@ +/** + * This method parses raw markdown text in GFM input field and toggles checkboxes + * based on checkboxChecked property. + * + * @param {Object} object containing rawMarkdown, sourcepos, checkboxChecked properties + * @returns String with toggled checkboxes + */ +export const toggleMarkCheckboxes = ({ rawMarkdown, sourcepos, checkboxChecked }) => { + // Extract the description text + const [startRange] = sourcepos.split('-'); + let [startRow] = startRange.split(':'); + startRow = Number(startRow) - 1; + + // Mark/Unmark the checkboxes + return rawMarkdown + .split('\n') + .map((row, index) => { + if (startRow === index) { + if (checkboxChecked) { + return row.replace(/\[ \]/, '[x]'); + } + return row.replace(/\[[x~]\]/i, '[ ]'); + } + return row; + }) + .join('\n'); +}; diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index a88cc1834ac..bd13bcb35fc 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -321,12 +321,6 @@ export const GO_TO_PROJECT_JOBS = { defaultKeys: ['g j'], // eslint-disable-line @gitlab/require-i18n-strings }; -export const GO_TO_PROJECT_METRICS = { - id: 'project.goToMetrics', - description: __('Go to metrics'), - defaultKeys: ['g l'], // eslint-disable-line @gitlab/require-i18n-strings -}; - export const GO_TO_PROJECT_ENVIRONMENTS = { id: 'project.goToEnvironments', description: __('Go to environments'), @@ -506,30 +500,6 @@ const WEB_IDE_COMMIT = { customizable: false, }; -export const METRICS_EXPAND_PANEL = { - id: 'metrics.expandPanel', - description: __('Expand panel'), - defaultKeys: ['e'], -}; - -export const METRICS_DOWNLOAD_CSV = { - id: 'metrics.downloadCSV', - description: __('Download CSV'), - defaultKeys: ['d'], -}; - -export const METRICS_COPY_LINK_TO_CHART = { - id: 'metrics.copyLinkToChart', - description: __('Copy link to chart'), - defaultKeys: ['c'], -}; - -export const METRICS_SHOW_ALERTS = { - id: 'metrics.showAlerts', - description: __('Alerts'), - defaultKeys: ['a'], -}; - // All keybinding groups const GLOBAL_SHORTCUTS_GROUP = { id: 'globalShortcuts', @@ -606,7 +576,6 @@ const PROJECT_SHORTCUTS_GROUP = { GO_TO_PROJECT_MERGE_REQUESTS, GO_TO_PROJECT_PIPELINES, GO_TO_PROJECT_JOBS, - ...(gon.features?.removeMonitorMetrics ? [] : [GO_TO_PROJECT_METRICS]), GO_TO_PROJECT_ENVIRONMENTS, GO_TO_PROJECT_KUBERNETES, GO_TO_PROJECT_SNIPPETS, @@ -670,17 +639,6 @@ const WEB_IDE_SHORTCUTS_GROUP = { keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT], }; -const METRICS_SHORTCUTS_GROUP = { - id: 'metrics', - name: __('Metrics'), - keybindings: [ - METRICS_EXPAND_PANEL, - METRICS_DOWNLOAD_CSV, - METRICS_COPY_LINK_TO_CHART, - METRICS_SHOW_ALERTS, - ], -}; - export const MISC_SHORTCUTS_GROUP = { id: 'misc', name: __('Miscellaneous'), @@ -701,7 +659,6 @@ export const keybindingGroups = [ MR_COMMITS_SHORTCUTS_GROUP, ISSUES_SHORTCUTS_GROUP, WEB_IDE_SHORTCUTS_GROUP, - ...(gon.features?.removeMonitorMetrics ? [] : [METRICS_SHORTCUTS_GROUP]), MISC_SHORTCUTS_GROUP, ]; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index 9e6c9c2e08e..d9dc3aae808 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -17,7 +17,6 @@ import { GO_TO_PROJECT_SNIPPETS, GO_TO_PROJECT_KUBERNETES, GO_TO_PROJECT_ENVIRONMENTS, - GO_TO_PROJECT_METRICS, GO_TO_PROJECT_WEBIDE, NEW_ISSUE, } from './keybindings'; @@ -44,7 +43,6 @@ export default class ShortcutsNavigation extends Shortcuts { [GO_TO_PROJECT_SNIPPETS, () => findAndFollowLink('.shortcuts-snippets')], [GO_TO_PROJECT_KUBERNETES, () => findAndFollowLink('.shortcuts-kubernetes')], [GO_TO_PROJECT_ENVIRONMENTS, () => findAndFollowLink('.shortcuts-environments')], - [GO_TO_PROJECT_METRICS, () => findAndFollowLink('.shortcuts-metrics')], [GO_TO_PROJECT_WEBIDE, ShortcutsNavigation.navigateToWebIDE], [NEW_ISSUE, () => findAndFollowLink('.shortcuts-new-issue')], ]); diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js index 935343cca2e..a88ef1c3e21 100644 --- a/app/assets/javascripts/blame/streaming/index.js +++ b/app/assets/javascripts/blame/streaming/index.js @@ -1,5 +1,6 @@ import { renderHtmlStreams } from '~/streaming/render_html_streams'; import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link'; +import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests'; @@ -11,6 +12,7 @@ export async function renderBlamePageStreams(firstStreamPromise) { if (!element || !firstStreamPromise) return; const stopAnchorObserver = handleStreamedAnchorLink(element); + const relativeTimestampsHandler = handleStreamedRelativeTimestamps(element); const { dataset } = document.querySelector('#blob-content-holder'); const totalExtraPages = parseInt(dataset.totalExtraPages, 10); const { pagesUrl } = dataset; @@ -50,6 +52,8 @@ export async function renderBlamePageStreams(firstStreamPromise) { }); throw error; } finally { + const stopTimestampObserver = await relativeTimestampsHandler; + stopTimestampObserver(); stopAnchorObserver(); document.querySelector('#blame-stream-loading').remove(); } diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue index 28e81b83713..ee8bd23f844 100644 --- a/app/assets/javascripts/blob/components/table_contents.vue +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -42,9 +42,6 @@ export default { } }, methods: { - close() { - this.$refs.disclosureDropdown?.close(); - }, generateHeaders() { const BASE_PADDING = 16; const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')]; @@ -72,10 +69,8 @@ export default { <template> <gl-disclosure-dropdown v-if="!isHidden && items.length" - ref="disclosureDropdown" icon="list-bulleted" class="gl-mr-2" :items="items" - @action="close" /> </template> diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 7ccb66f18a9..e0ecfca75f5 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -10,7 +10,6 @@ import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; import DockerfileSelector from './template_selectors/dockerfile_selector'; import GitignoreSelector from './template_selectors/gitignore_selector'; import LicenseSelector from './template_selectors/license_selector'; -import MetricsDashboardSelector from './template_selectors/metrics_dashboard_selector'; export default class FileTemplateMediator { constructor({ editor, currentAction, projectId }) { @@ -30,7 +29,6 @@ export default class FileTemplateMediator { this.templateSelectors = [ GitignoreSelector, BlobCiYamlSelector, - MetricsDashboardSelector, DockerfileSelector, LicenseSelector, ].map((TemplateSelectorClass) => new TemplateSelectorClass({ mediator: this })); diff --git a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js b/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js deleted file mode 100644 index 8b10b02ae1d..00000000000 --- a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js +++ /dev/null @@ -1,29 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import FileTemplateSelector from '../file_template_selector'; - -export default class MetricsDashboardSelector extends FileTemplateSelector { - constructor({ mediator }) { - super(mediator); - this.config = { - key: 'metrics-dashboard-yaml', - name: '.metrics-dashboard.yml', - pattern: /(.metrics-dashboard.yml)/, - type: 'metrics_dashboard_ymls', - dropdown: '.js-metrics-dashboard-selector', - wrapper: '.js-metrics-dashboard-selector-wrap', - }; - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.$dropdown.data('data'), - filterable: true, - selectable: true, - search: { - fields: ['name'], - }, - clicked: (options) => this.reportSelectionName(options), - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 3a22b06c72e..bf77aa4996c 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,18 +1,18 @@ -import { sortBy, cloneDeep } from 'lodash'; +import { sortBy, cloneDeep, find, inRange } from 'lodash'; import { TYPENAME_BOARD, TYPENAME_ITERATION, TYPENAME_MILESTONE, TYPENAME_USER, } from '~/graphql_shared/constants'; -import { isGid, convertToGraphQLId } from '~/graphql_shared/utils'; +import { isGid, convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType, boardQuery, -} from './constants'; +} from 'ee_else_ce/boards/constants'; export function getMilestone() { return null; @@ -30,6 +30,17 @@ export function updateListPosition(listObj) { return { ...listObj, position }; } +export function calculateNewPosition(listPosition, initialPosition, targetPosition) { + if ( + listPosition === null || + !(inRange(listPosition, initialPosition, targetPosition) || listPosition === targetPosition) + ) { + return listPosition; + } + const offset = initialPosition < targetPosition ? -1 : 1; + return listPosition + offset; +} + export function formatBoardLists(lists) { return lists.nodes.reduce((map, list) => { return { @@ -191,6 +202,38 @@ export function moveItemListHelper(item, fromList, toList) { return updatedItem; } +export function moveItemVariables({ + iid, + epicId, + fromListId, + toListId, + moveBeforeId, + moveAfterId, + isIssue, + boardId, + itemToMove, +}) { + if (isIssue) { + return { + iid, + boardId, + projectPath: itemToMove.referencePath.split(/[#]/)[0], + moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined, + moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined, + fromListId: getIdFromGraphQLId(fromListId), + toListId: getIdFromGraphQLId(toListId), + }; + } + return { + epicId, + boardId, + moveBeforeId, + moveAfterId, + fromListId, + toListId, + }; +} + export function isListDraggable(list) { return list.listType !== ListType.backlog && list.listType !== ListType.closed; } @@ -318,6 +361,13 @@ export function getBoardQuery(boardType) { return boardQuery[boardType].query; } +export function getListByTypeId(lists, type, id) { + // type can be assignee/label/milestone/iteration + if (type && id) return find(lists, (l) => l.listType === ListType[type] && l[type]?.id === id); + + return null; +} + export default { getMilestone, formatIssue, diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index 90f7059da86..985b9798b36 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -1,4 +1,6 @@ <script> +import produce from 'immer'; +import { debounce } from 'lodash'; import { GlTooltipDirective as GlTooltip, GlButton, @@ -6,8 +8,12 @@ import { GlIcon, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; import { __ } from '~/locale'; +import { createListMutations, listsQuery, BoardType, ListType } from 'ee_else_ce/boards/constants'; +import boardLabelsQuery from '../graphql/board_labels.query.graphql'; +import { getListByTypeId } from '../boards_util'; export default { i18n: { @@ -23,60 +29,150 @@ export default { directives: { GlTooltip, }, - inject: ['scopedLabelsAvailable'], + inject: ['scopedLabelsAvailable', 'issuableType', 'fullPath', 'boardType', 'isApolloBoard'], + props: { + listQueryVariables: { + type: Object, + required: true, + }, + boardId: { + type: String, + required: true, + }, + lists: { + type: Object, + required: true, + }, + }, data() { return { selectedId: null, selectedLabel: null, selectedIdValid: true, + labelsApollo: [], + searchTerm: '', }; }, + apollo: { + labelsApollo: { + query: boardLabelsQuery, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.searchTerm, + isGroup: this.boardType === BoardType.group, + isProject: this.boardType === BoardType.project, + }; + }, + update(data) { + return data[this.boardType].labels.nodes; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { ...mapState(['labels', 'labelsLoading']), ...mapGetters(['getListByLabelId']), + labelsToUse() { + return this.isApolloBoard ? this.labelsApollo : this.labels; + }, + isLabelsLoading() { + return this.isApolloBoard ? this.$apollo.queries.labelsApollo.loading : this.labelsLoading; + }, columnForSelected() { + if (this.isApolloBoard) { + return getListByTypeId(this.lists, ListType.label, this.selectedId); + } return this.getListByLabelId(this.selectedId); }, items() { - return ( - this.labels.map((i) => ({ - ...i, - text: i.title, - value: i.id, - })) || [] - ); + return (this.labelsToUse || []).map((i) => ({ + ...i, + text: i.title, + value: i.id, + })); }, }, created() { - this.filterItems(); + if (!this.isApolloBoard) { + this.filterItems(); + } }, methods: { - ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), + ...mapActions(['createList', 'fetchLabels', 'highlightList']), + createListApollo({ labelId }) { + return this.$apollo.mutate({ + mutation: createListMutations[this.issuableType].mutation, + variables: { + labelId, + boardId: this.boardId, + }, + update: ( + store, + { + data: { + boardListCreate: { list }, + }, + }, + ) => { + const sourceData = store.readQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + }); + const data = produce(sourceData, (draftData) => { + draftData[this.boardType].board.lists.nodes.push(list); + }); + store.writeQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + data, + }); + this.$emit('highlight-list', list.id); + }, + }); + }, addList() { if (!this.selectedLabel) { this.selectedIdValid = false; return; } - this.setAddColumnFormVisibility(false); - if (this.columnForSelected) { const listId = this.columnForSelected.id; - this.highlightList(listId); + if (this.isApolloBoard) { + this.$emit('highlight-list', listId); + } else { + this.highlightList(listId); + } return; } - this.createList({ labelId: this.selectedId }); + if (this.isApolloBoard) { + this.createListApollo({ labelId: this.selectedId }); + } else { + this.createList({ labelId: this.selectedId }); + } + + this.$emit('setAddColumnFormVisibility', false); }, filterItems(searchTerm) { this.fetchLabels(searchTerm); }, + onSearch: debounce(function debouncedSearch(searchTerm) { + this.searchTerm = searchTerm; + if (!this.isApolloBoard) { + this.filterItems(searchTerm); + } + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + setSelectedItem(selectedId) { this.selectedId = selectedId; - const label = this.labels.find(({ id }) => id === selectedId); + const label = this.labelsToUse.find(({ id }) => id === selectedId); if (!selectedId || !label) { this.selectedLabel = null; } else { @@ -95,8 +191,8 @@ export default { <template> <board-add-new-column-form :selected-id-valid="selectedIdValid" - @filter-items="filterItems" @add-list="addList" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" > <template #dropdown> <gl-collapsible-listbox @@ -104,11 +200,11 @@ export default { :items="items" searchable :search-placeholder="__('Search labels')" - :searching="labelsLoading" + :searching="isLabelsLoading" :selected="selectedId" :no-results-text="$options.i18n.noResults" @select="setSelectedItem" - @search="filterItems" + @search="onSearch" @hidden="onHide" > <template #toggle> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index 259423df07f..419d0b41d69 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlFormGroup } from '@gitlab/ui'; -import { mapActions } from 'vuex'; import { __ } from '~/locale'; export default { @@ -33,7 +32,6 @@ export default { }; }, methods: { - ...mapActions(['setAddColumnFormVisibility']), onSubmit() { this.$emit('add-list'); }, @@ -83,9 +81,11 @@ export default { @click="onSubmit" >{{ $options.i18n.add }}</gl-button > - <gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{ - $options.i18n.cancel - }}</gl-button> + <gl-button + data-testid="cancelAddNewColumn" + @click="$emit('setAddColumnFormVisibility', false)" + >{{ $options.i18n.cancel }}</gl-button + > </div> </div> </div> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 14c84d3c4e5..d91c8ab4727 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import Tracking from '~/tracking'; @@ -12,16 +11,20 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], + props: { + isNewListShowing: { + type: Boolean, + required: true, + }, + }, computed: { - ...mapState({ isNewListShowing: ({ addColumnForm }) => addColumnForm.visible }), tooltip() { return this.isNewListShowing ? __('The list creation wizard is already open') : ''; }, }, methods: { - ...mapActions(['setAddColumnFormVisibility']), handleClick() { - this.setAddColumnFormVisibility(true); + this.$emit('setAddColumnFormVisibility', true); this.track('click_button', { label: 'create_list' }); }, }, diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 3a247819850..0b9243c07c5 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -35,6 +35,7 @@ export default { activeListId: '', boardId: this.initialBoardId, filterParams: { ...this.initialFilterParams }, + addColumnFormVisible: false, isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), apolloError: null, }; @@ -79,6 +80,7 @@ export default { computed: { ...mapGetters(['isSidebarOpen']), listQueryVariables() { + if (this.filterParams.groupBy) delete this.filterParams.groupBy; return { ...(this.isIssueBoard && { isGroup: this.isGroupBoard, @@ -129,19 +131,24 @@ export default { <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }"> <board-top-bar :board-id="boardId" + :add-column-form-visible="addColumnFormVisible" :is-swimlanes-on="isSwimlanesOn" @switchBoard="switchBoard" @setFilters="setFilters" + @setAddColumnFormVisibility="addColumnFormVisible = $event" @toggleSwimlanes="isShowingEpicsSwimlanes = $event" /> <board-content v-if="!isApolloBoard || boardListsApollo" :board-id="boardId" + :add-column-form-visible="addColumnFormVisible" :is-swimlanes-on="isSwimlanesOn" :filter-params="filterParams" :board-lists-apollo="boardListsApollo" :apollo-error="apolloError" + :list-query-variables="listQueryVariables" @setActiveList="setActiveId" + @setAddColumnFormVisibility="addColumnFormVisible = $event" /> <board-settings-sidebar v-if="!isApolloBoard || activeList" diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index f58f7838576..19eddbfdd68 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -14,6 +14,7 @@ export default { GlDisclosureDropdown, }, mixins: [Tracking.mixin()], + inject: ['isApolloBoard'], props: { item: { type: Object, @@ -83,16 +84,20 @@ export default { }); }, moveToPosition({ positionInList }) { - this.moveItem({ - itemId: this.item.id, - itemIid: this.item.iid, - itemPath: this.item.referencePath, - fromListId: this.list.id, - toListId: this.list.id, - positionInList, - atIndex: this.index, - allItemsLoadedInList: !this.listHasNextPage, - }); + if (this.isApolloBoard) { + this.$emit('moveToPosition', positionInList); + } else { + this.moveItem({ + itemId: this.item.id, + itemIid: this.item.iid, + itemPath: this.item.referencePath, + fromListId: this.list.id, + toListId: this.list.id, + positionInList, + atIndex: this.index, + allItemsLoadedInList: !this.listHasNextPage, + }); + } }, selectMoveAction({ text }) { if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index b2054d76e95..2ee0b4593d6 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -24,12 +24,20 @@ export default { type: Object, required: true, }, + highlightedListsApollo: { + type: Array, + required: false, + default: () => [], + }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), ...mapGetters(['getBoardItemsByList']), + highlightedListsToUse() { + return this.isApolloBoard ? this.highlightedListsApollo : this.highlightedLists; + }, highlighted() { - return this.highlightedLists.includes(this.list.id); + return this.highlightedListsToUse.includes(this.list.id); }, listItems() { return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id); diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 8304dfef527..a51e4ddc8f8 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,12 +1,19 @@ <script> import { GlAlert } from '@gitlab/ui'; import { sortBy } from 'lodash'; +import produce from 'immer'; import Draggable from 'vuedraggable'; import { mapState, mapActions } from 'vuex'; import eventHub from '~/boards/eventhub'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { defaultSortableOptions } from '~/sortable/constants'; -import { DraggableItemTypes } from 'ee_else_ce/boards/constants'; +import { + DraggableItemTypes, + flashAnimationDuration, + listsQuery, + updateListQueries, +} from 'ee_else_ce/boards/constants'; +import { calculateNewPosition } from 'ee_else_ce/boards/boards_util'; import BoardColumn from './board_column.vue'; export default { @@ -20,7 +27,15 @@ export default { EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, }, - inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'], + inject: [ + 'boardType', + 'canAdminList', + 'isIssueBoard', + 'isEpicBoard', + 'disabled', + 'issuableType', + 'isApolloBoard', + ], props: { boardId: { type: String, @@ -44,16 +59,25 @@ export default { required: false, default: null, }, + listQueryVariables: { + type: Object, + required: true, + }, + addColumnFormVisible: { + type: Boolean, + required: true, + }, }, data() { return { boardHeight: null, + highlightedLists: [], }; }, computed: { - ...mapState(['boardLists', 'error', 'addColumnForm']), - addColumnFormVisible() { - return this.addColumnForm?.visible; + ...mapState(['boardLists', 'error']), + boardListsById() { + return this.isApolloBoard ? this.boardListsApollo : this.boardLists; }, boardListsToUse() { const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists; @@ -101,6 +125,90 @@ export default { refetchLists() { this.$apollo.queries.boardListsApollo.refetch(); }, + highlightList(listId) { + this.highlightedLists.push(listId); + + setTimeout(() => { + this.highlightedLists = this.highlightedLists.filter((id) => id !== listId); + }, flashAnimationDuration); + }, + updateListPosition({ + item: { + dataset: { listId: movedListId, draggableItemType }, + }, + newIndex, + to: { children }, + }) { + if (!this.isApolloBoard) { + this.moveList({ + item: { + dataset: { listId: movedListId, draggableItemType }, + }, + newIndex, + to: { children }, + }); + return; + } + + if (draggableItemType !== DraggableItemTypes.list) { + return; + } + + const displacedListId = children[newIndex].dataset.listId; + + if (movedListId === displacedListId) { + return; + } + const initialPosition = this.boardListsById[movedListId].position; + const targetPosition = this.boardListsById[displacedListId].position; + + try { + this.$apollo.mutate({ + mutation: updateListQueries[this.issuableType].mutation, + variables: { + listId: movedListId, + position: targetPosition, + }, + update: (store) => { + const sourceData = store.readQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + }); + const data = produce(sourceData, (draftData) => { + // for current list, new position is already set by Apollo via automatic update + const affectedNodes = draftData[this.boardType].board.lists.nodes.filter( + (node) => node.id !== movedListId, + ); + affectedNodes.forEach((node) => { + // eslint-disable-next-line no-param-reassign + node.position = calculateNewPosition( + node.position, + initialPosition, + targetPosition, + ); + }); + }); + store.writeQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + data, + }); + }, + optimisticResponse: { + updateBoardList: { + __typename: 'UpdateBoardListPayload', + errors: [], + list: { + ...this.boardListsApollo[movedListId], + position: targetPosition, + }, + }, + }, + }); + } catch { + // handle error + } + }, }, }; </script> @@ -120,7 +228,7 @@ export default { ref="list" v-bind="draggableOptions" class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto" - @end="moveList" + @end="updateListPosition" > <board-column v-for="(list, index) in boardListsToUse" @@ -129,13 +237,22 @@ export default { :board-id="boardId" :list="list" :filters="filterParams" + :highlighted-lists-apollo="highlightedLists" :data-draggable-item-type="$options.draggableItemTypes.list" - :class="{ 'gl-xs-display-none!': addColumnFormVisible }" + :class="{ 'gl-display-none! gl-sm-display-inline-block!': addColumnFormVisible }" @setActiveList="$emit('setActiveList', $event)" /> <transition name="slide" @after-enter="afterFormEnters"> - <board-add-new-column v-if="addColumnFormVisible" class="gl-xs-w-full!" /> + <board-add-new-column + v-if="addColumnFormVisible" + class="gl-xs-w-full!" + :board-id="boardId" + :list-query-variables="listQueryVariables" + :lists="boardListsById" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" + @highlight-list="highlightList" + /> </transition> </component> @@ -146,8 +263,21 @@ export default { :lists="boardListsToUse" :can-admin-list="canAdminList" :filters="filterParams" + :highlighted-lists="highlightedLists" @setActiveList="$emit('setActiveList', $event)" - /> + @move-list="updateListPosition" + > + <board-add-new-column + v-if="addColumnFormVisible" + class="gl-sticky gl-top-5" + :filter-params="filterParams" + :list-query-variables="listQueryVariables" + :board-id="boardId" + :lists="boardListsById" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" + @highlight-list="highlightList" + /> + </epics-swimlanes> <board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" /> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 5f082066ad4..af309ba9912 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -9,6 +9,7 @@ import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_BOARD_LIST_ITEMS_SIZE, toggleFormEventPrefix, @@ -16,6 +17,13 @@ import { listIssuablesQueries, ListType, } from 'ee_else_ce/boards/constants'; +import { + addItemToList, + removeItemFromList, + updateEpicsCount, + updateIssueCountAndWeight, +} from '../graphql/cache_updates'; +import { shouldCloneCard, moveItemVariables } from '../boards_util'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -37,7 +45,7 @@ export default { GlIntersectionObserver, BoardCardMoveToPosition, }, - mixins: [Tracking.mixin()], + mixins: [Tracking.mixin(), glFeatureFlagMixin()], inject: [ 'isEpicBoard', 'isGroupBoard', @@ -73,6 +81,8 @@ export default { showEpicForm: false, currentList: null, isLoadingMore: false, + toListId: null, + toList: {}, }; }, apollo: { @@ -111,6 +121,29 @@ export default { isSingleRequest: true, }, }, + toList: { + query() { + return listIssuablesQueries[this.issuableType].query; + }, + variables() { + return { + id: this.toListId, + ...this.listQueryVariables, + }; + }, + skip() { + return !this.toListId; + }, + update(data) { + return data[this.boardType].board.lists.nodes[0]; + }, + context: { + isSingleRequest: true, + }, + error() { + // handle error + }, + }, }, computed: { ...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']), @@ -205,6 +238,9 @@ export default { showMoveToPosition() { return !this.disabled && this.list.listType !== ListType.closed; }, + shouldCloneCard() { + return shouldCloneCard(this.list.listType, this.toList.listType); + }, }, watch: { boardListItems() { @@ -337,14 +373,169 @@ export default { } } - this.moveItem({ - itemId, - itemIid, - itemPath, - fromListId: from.dataset.listId, - toListId: to.dataset.listId, - moveBeforeId, - moveAfterId, + if (this.isApolloBoard) { + this.moveBoardItem( + { + epicId: itemId, + iid: itemIid, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }, + newIndex, + ); + } else { + this.moveItem({ + itemId, + itemIid, + itemPath, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }); + } + }, + isItemInTheList(itemIid) { + const items = this.toList?.[`${this.issuableType}s`]?.nodes || []; + return items.some((item) => item.iid === itemIid); + }, + async moveBoardItem(variables, newIndex) { + const { fromListId, toListId, iid } = variables; + this.toListId = toListId; + await this.$nextTick(); // we need this next tick to retrieve `toList` from Apollo cache + + const itemToMove = this.boardListItems.find((item) => item.iid === iid); + + if (this.shouldCloneCard && this.isItemInTheList(iid)) { + return; + } + + try { + await this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].moveMutation, + variables: { + ...moveItemVariables({ + ...variables, + isIssue: !this.isEpicBoard, + boardId: this.boardId, + itemToMove, + }), + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, + }, + update: (cache, { data: { issuableMoveList } }) => + this.updateCacheAfterMovingItem({ + issuableMoveList, + fromListId, + toListId, + newIndex, + cache, + }), + optimisticResponse: { + issuableMoveList: { + issuable: itemToMove, + errors: [], + }, + }, + }); + } catch { + // handle error + } + }, + updateCacheAfterMovingItem({ issuableMoveList, fromListId, toListId, newIndex, cache }) { + const { issuable } = issuableMoveList; + if (!this.shouldCloneCard) { + removeItemFromList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: fromListId }, + boardType: this.boardType, + id: issuable.id, + issuableType: this.issuableType, + cache, + }); + } + + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: toListId }, + issuable, + newIndex, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + + this.updateCountAndWeight({ fromListId, toListId, issuable, cache }); + }, + updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) { + if (!this.isEpicBoard) { + updateIssueCountAndWeight({ + fromListId, + toListId, + filterParams: this.filterParams, + issuable, + shouldClone: isAddingIssue || this.shouldCloneCard, + cache, + }); + } else { + const { issuableType, filterParams } = this; + updateEpicsCount({ + issuableType, + toListId, + fromListId, + filterParams, + issuable, + shouldClone: this.shouldCloneCard, + cache, + }); + } + }, + moveToPosition(positionInList, oldIndex, item) { + this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].moveMutation, + variables: { + ...moveItemVariables({ + iid: item.iid, + epicId: item.id, + fromListId: this.currentList.id, + toListId: this.currentList.id, + isIssue: !this.isEpicBoard, + boardId: this.boardId, + itemToMove: item, + }), + positionInList, + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, + }, + optimisticResponse: { + issuableMoveList: { + issuable: item, + errors: [], + }, + }, + update: (cache, { data: { issuableMoveList } }) => { + const { issuable } = issuableMoveList; + removeItemFromList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + boardType: this.boardType, + id: issuable.id, + issuableType: this.issuableType, + cache, + }); + if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) { + const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1; + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + issuable, + newIndex, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + } + }, }); }, }, @@ -401,6 +592,7 @@ export default { :index="index" :list="list" :list-items-length="boardListItems.length" + @moveToPosition="moveToPosition($event, index, item)" /> <gl-intersection-observer v-if="isObservableItem(index)" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 1b711feb686..61a9b22bfc5 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -108,6 +108,9 @@ export default { listType() { return this.list.listType; }, + isLabelList() { + return this.listType === ListType.label; + }, itemsCount() { return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount; }, @@ -258,9 +261,6 @@ export default { }, methods: { ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']), - closeListActions() { - this.$refs.headerListActions?.close(); - }, openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -277,8 +277,6 @@ export default { } this.track('click_button', { label: 'list_settings' }); - - this.closeListActions(); }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); @@ -292,13 +290,9 @@ export default { } else { eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); } - - this.closeListActions(); }, showNewEpicForm() { eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`); - - this.closeListActions(); }, toggleExpanded() { const collapsed = !this.list.collapsed; @@ -382,7 +376,8 @@ export default { <header :class="{ 'gl-h-full': list.collapsed, - 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base gl-bg-gray-50': isSwimlanesHeader, + 'board-inner gl-bg-gray-50': isSwimlanesHeader, + 'gl-border-t-solid gl-border-4 gl-rounded-top-left-base gl-rounded-top-right-base': isLabelList, }" :style="headerStyle" class="board-header gl-relative" @@ -532,7 +527,6 @@ export default { </div> <gl-disclosure-dropdown v-if="showListHeaderActions" - ref="headerListActions" v-gl-tooltip.hover.top="{ title: $options.i18n.listActions, boundary: 'viewport', diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 23e0f2510a7..0f43aae3936 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -114,10 +114,10 @@ export default { showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); }, - async deleteBoardList() { + deleteBoardList() { this.track('click_button', { label: 'remove_list' }); if (this.isApolloBoard) { - await this.deleteList(this.activeListId); + this.deleteList(this.activeListId); } else { this.removeList(this.activeId); } @@ -157,7 +157,7 @@ export default { <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append> <gl-drawer v-bind="$attrs" - class="js-board-settings-sidebar gl-absolute" + class="js-board-settings-sidebar gl-absolute boards-sidebar" :open="showSidebar" variant="sidebar" @close="unsetActiveListId" diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index c186346b2ac..fd9043a561f 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -35,6 +35,10 @@ export default { type: String, required: true, }, + addColumnFormVisible: { + type: Boolean, + required: true, + }, isSwimlanesOn: { type: Boolean, required: true, @@ -91,7 +95,7 @@ export default { class="issues-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row row-content-block second-block" > <div - class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full gl-min-w-0" > <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" /> <new-board-button /> @@ -117,7 +121,11 @@ export default { @toggleSwimlanes="$emit('toggleSwimlanes', $event)" /> <config-toggle :board-has-scope="hasScope" /> - <board-add-new-column-trigger v-if="canAdminList" /> + <board-add-new-column-trigger + v-if="canAdminList" + :is-new-list-showing="addColumnFormVisible" + @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)" + /> <toggle-focus /> </div> </div> diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 247910301e7..960c8e472b8 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,15 +1,10 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlIntersectionObserver, - GlLoadingIcon, -} from '@gitlab/ui'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { debounce } from 'lodash'; import { s__ } from '~/locale'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { ListType } from '../constants'; export default { @@ -27,12 +22,7 @@ export default { order_by: 'similarity', }, components: { - GlIntersectionObserver, - GlLoadingIcon, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, inject: ['groupId'], props: { @@ -44,6 +34,7 @@ export default { data() { return { initialLoading: true, + selectedProjectId: '', selectedProject: {}, searchTerm: '', }; @@ -51,6 +42,12 @@ export default { computed: { ...mapState(['groupProjectsFlags']), ...mapGetters(['activeGroupProjects']), + projects() { + return this.activeGroupProjects.map((project) => ({ + value: project.id, + text: project.nameWithNamespace, + })); + }, selectedProjectName() { return this.selectedProject.name || this.$options.i18n.dropdownText; }, @@ -73,26 +70,27 @@ export default { }, }, watch: { - searchTerm() { + searchTerm: debounce(function debouncedSearch() { this.fetchGroupProjects({ search: this.searchTerm }); - }, + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, mounted() { this.fetchGroupProjects({}); - this.initialLoading = false; }, methods: { ...mapActions(['fetchGroupProjects', 'setSelectedProject']), selectProject(projectId) { + this.selectedProjectId = projectId; this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId); this.setSelectedProject(this.selectedProject); }, loadMoreProjects() { + if (!this.hasNextPage) return; this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true }); }, - setFocus() { - this.$refs.search.focusInput(); + onSearch(query) { + this.searchTerm = query; }, }, }; @@ -103,45 +101,23 @@ export default { <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{ $options.i18n.headerTitle }}</label> - <gl-dropdown + <gl-collapsible-listbox + v-model="selectedProjectId" + block + searchable + infinite-scroll data-testid="project-select-dropdown" - :text="selectedProjectName" + :items="projects" + :toggle-text="selectedProjectName" :header-text="$options.i18n.headerTitle" - block - menu-class="gl-w-full!" :loading="initialLoading" - @shown="setFocus" - > - <gl-search-box-by-type - ref="search" - v-model.trim="searchTerm" - debounce="250" - :placeholder="$options.i18n.searchPlaceholder" - /> - <gl-dropdown-item - v-for="project in activeGroupProjects" - v-show="!groupProjectsFlags.isLoading" - :key="project.id" - :name="project.name" - @click="selectProject(project.id)" - > - {{ project.nameWithNamespace }} - </gl-dropdown-item> - <gl-dropdown-text - v-show="groupProjectsFlags.isLoading" - data-testid="dropdown-text-loading-icon" - > - <gl-loading-icon class="gl-mx-auto" size="sm" /> - </gl-dropdown-text> - <gl-dropdown-text - v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading" - data-testid="empty-result-message" - > - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - <gl-intersection-observer v-if="hasNextPage" @appear="loadMoreProjects"> - <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="lg" /> - </gl-intersection-observer> - </gl-dropdown> + :searching="groupProjectsFlags.isLoading" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + :infinite-scroll-loading="groupProjectsFlags.isLoadingMore" + @select="selectProject" + @search="onSearch" + @bottom-reached="loadMoreProjects" + /> </div> </template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 7fe89ffbb52..d4d1bc7804e 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -3,15 +3,18 @@ import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/iss import { s__, __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; +import createBoardListMutation from './graphql/board_list_create.mutation.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; import updateBoardListMutation from './graphql/board_list_update.mutation.graphql'; import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed.mutation.graphql'; import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; +import issueMoveListMutation from './graphql/issue_move_list.mutation.graphql'; import groupBoardQuery from './graphql/group_board.query.graphql'; import projectBoardQuery from './graphql/project_board.query.graphql'; import listIssuesQuery from './graphql/lists_issues.query.graphql'; +import listDeferredQuery from './graphql/board_lists_deferred.query.graphql'; export const BoardType = { project: 'project', @@ -71,6 +74,18 @@ export const listsQuery = { }, }; +export const listsDeferredQuery = { + [TYPE_ISSUE]: { + query: listDeferredQuery, + }, +}; + +export const createListMutations = { + [TYPE_ISSUE]: { + mutation: createBoardListMutation, + }, +}; + export const updateListQueries = { [TYPE_ISSUE]: { mutation: updateBoardListMutation, @@ -110,6 +125,7 @@ export const subscriptionQueries = { export const listIssuablesQueries = { [TYPE_ISSUE]: { query: listIssuesQuery, + moveMutation: issueMoveListMutation, }, }; diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js new file mode 100644 index 00000000000..084809e4e60 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -0,0 +1,118 @@ +import produce from 'immer'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import { listsDeferredQuery } from 'ee_else_ce/boards/constants'; + +export function removeItemFromList({ query, variables, boardType, id, issuableType, cache }) { + cache.updateQuery({ query, variables }, (sourceData) => + produce(sourceData, (draftData) => { + const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`]; + items.splice( + items.findIndex((item) => item.id === id), + 1, + ); + }), + ); +} + +export function addItemToList({ + query, + variables, + boardType, + issuable, + newIndex, + issuableType, + cache, +}) { + cache.updateQuery({ query, variables }, (sourceData) => + produce(sourceData, (draftData) => { + const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`]; + items.splice(newIndex, 0, issuable); + }), + ); +} + +export function updateIssueCountAndWeight({ + fromListId, + toListId, + filterParams, + issuable: issue, + shouldClone, + cache, +}) { + if (!shouldClone) { + cache.updateQuery( + { + query: listQuery, + variables: { id: fromListId, filters: filterParams }, + }, + ({ boardList }) => ({ + boardList: { + ...boardList, + issuesCount: boardList.issuesCount - 1, + totalWeight: boardList.totalWeight - issue.weight, + }, + }), + ); + } + + cache.updateQuery( + { + query: listQuery, + variables: { id: toListId, filters: filterParams }, + }, + ({ boardList }) => ({ + boardList: { + ...boardList, + issuesCount: boardList.issuesCount + 1, + totalWeight: boardList.totalWeight + issue.weight, + }, + }), + ); +} + +export function updateEpicsCount({ + issuableType, + filterParams, + fromListId, + toListId, + issuable: epic, + shouldClone, + cache, +}) { + const epicWeight = epic.descendantWeightSum.openedIssues + epic.descendantWeightSum.closedIssues; + if (!shouldClone) { + cache.updateQuery( + { + query: listsDeferredQuery[issuableType].query, + variables: { id: fromListId, filters: filterParams }, + }, + ({ epicBoardList }) => ({ + epicBoardList: { + ...epicBoardList, + metadata: { + epicsCount: epicBoardList.metadata.epicsCount - 1, + totalWeight: epicBoardList.metadata.totalWeight - epicWeight, + ...epicBoardList.metadata, + }, + }, + }), + ); + } + + cache.updateQuery( + { + query: listsDeferredQuery[issuableType].query, + variables: { id: toListId, filters: filterParams }, + }, + ({ epicBoardList }) => ({ + epicBoardList: { + ...epicBoardList, + metadata: { + epicsCount: epicBoardList.metadata.epicsCount + 1, + totalWeight: epicBoardList.metadata.totalWeight + epicWeight, + ...epicBoardList.metadata, + }, + }, + }), + ); +} diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql index 89670760450..4a46d741a78 100644 --- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql @@ -9,7 +9,7 @@ mutation issueMoveList( $moveBeforeId: ID $moveAfterId: ID ) { - issueMoveList( + issuableMoveList: issueMoveList( input: { projectPath: $projectPath iid: $iid @@ -20,7 +20,7 @@ mutation issueMoveList( moveAfterId: $moveAfterId } ) { - issue { + issuable: issue { ...Issue } errors diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a144054d680..d96d92948be 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -602,8 +602,8 @@ export default { cache, { data: { - issueMoveList: { - issue: { weight }, + issuableMoveList: { + issuable: { weight }, }, }, }, @@ -661,11 +661,11 @@ export default { }, }); - if (data?.issueMoveList?.errors.length || !data.issueMoveList) { + if (data?.issuableMoveList?.errors.length || !data.issuableMoveList) { throw new Error('issueMoveList empty'); } - commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue }); + commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issuableMoveList.issuable }); commit(types.MUTATE_ISSUE_IN_PROGRESS, false); } catch { commit(types.MUTATE_ISSUE_IN_PROGRESS, false); diff --git a/app/assets/javascripts/branches/components/branch_more_actions.vue b/app/assets/javascripts/branches/components/branch_more_actions.vue new file mode 100644 index 00000000000..c646dab2760 --- /dev/null +++ b/app/assets/javascripts/branches/components/branch_more_actions.vue @@ -0,0 +1,114 @@ +<script> +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + name: 'BranchMoreActions', + components: { GlDisclosureDropdown }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + branchName: { + type: String, + required: true, + }, + defaultBranchName: { + type: String, + required: true, + }, + canDeleteBranch: { + type: Boolean, + required: true, + }, + isProtectedBranch: { + type: Boolean, + required: true, + }, + merged: { + type: Boolean, + required: true, + }, + comparePath: { + type: String, + required: true, + }, + deletePath: { + type: String, + required: true, + }, + }, + i18n: { + toggleText: __('More actions'), + compare: s__('Branches|Compare'), + deleteBranch: s__('Branches|Delete branch'), + deleteProtectedBranch: s__('Branches|Delete protected branch'), + }, + computed: { + deleteBranchText() { + return this.isProtectedBranch + ? this.$options.i18n.deleteProtectedBranch + : this.$options.i18n.deleteBranch; + }, + dropdownItems() { + const items = [ + { + text: this.$options.i18n.compare, + href: this.comparePath, + extraAttrs: { + class: 'js-onboarding-compare-branches', + 'data-testid': 'compare-branch-button', + 'data-method': 'post', + }, + }, + ]; + + if (this.canDeleteBranch) { + items.push({ + text: this.deleteBranchText, + action: () => { + this.openModal(); + }, + extraAttrs: { + class: 'js-delete-branch-button gl-text-red-500!', + 'aria-label': this.deleteBranchText, + 'data-testid': 'delete-branch-button', + 'data-qa-selector': 'delete_branch_button', + }, + }); + } + + return items; + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { + branchName: this.branchName, + defaultBranchName: this.defaultBranchName, + deletePath: this.deletePath, + isProtectedBranch: this.isProtectedBranch, + merged: this.merged, + }); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + v-gl-tooltip.hover.top="{ + title: $options.i18n.toggleText, + boundary: 'viewport', + }" + :items="dropdownItems" + :toggle-text="$options.i18n.toggleText" + icon="ellipsis_v" + category="tertiary" + placement="right" + data-testid="branch-more-actions" + text-sr-only + no-caret + /> +</template> diff --git a/app/assets/javascripts/branches/components/delete_branch_button.vue b/app/assets/javascripts/branches/components/delete_branch_button.vue deleted file mode 100644 index 6a6d4d48c52..00000000000 --- a/app/assets/javascripts/branches/components/delete_branch_button.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import eventHub from '../event_hub'; - -export default { - name: 'DeleteBranchButton', - components: { GlButton }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - branchName: { - type: String, - required: false, - default: '', - }, - defaultBranchName: { - type: String, - required: false, - default: '', - }, - deletePath: { - type: String, - required: false, - default: '', - }, - tooltip: { - type: String, - required: false, - default: s__('Branches|Delete branch'), - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - isProtectedBranch: { - type: Boolean, - required: false, - default: false, - }, - merged: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - title() { - if (this.isProtectedBranch && this.disabled) { - return s__('Branches|Only a project maintainer or owner can delete a protected branch'); - } else if (this.isProtectedBranch) { - return s__('Branches|Delete protected branch'); - } - return this.tooltip; - }, - }, - methods: { - openModal() { - eventHub.$emit('openModal', { - branchName: this.branchName, - defaultBranchName: this.defaultBranchName, - deletePath: this.deletePath, - isProtectedBranch: this.isProtectedBranch, - merged: this.merged, - }); - }, - }, -}; -</script> - -<template> - <gl-button - v-gl-tooltip.hover - icon="remove" - class="js-delete-branch-button" - data-qa-selector="delete_branch_button" - :disabled="disabled" - variant="default" - :title="title" - :aria-label="title" - @click="openModal" - /> -</template> diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue index d9d8f1d742d..117c15be907 100644 --- a/app/assets/javascripts/branches/components/delete_merged_branches.vue +++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue @@ -103,8 +103,18 @@ export default { no-caret placement="right" data-qa-selector="delete_merged_branches_dropdown_button" + class="gl-display-none gl-md-display-block!" :items="dropdownItems" /> + <gl-button + data-qa-selector="delete_merged_branches_button" + category="secondary" + variant="danger" + class="gl-display-block gl-md-display-none!" + @click="openModal" + > + {{ $options.i18n.deleteButtonText }} + </gl-button> <gl-modal ref="modal" size="sm" diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index 99c82fc9a5a..4866d506988 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <div class="gl-display-flex"> + <div class="gl-display-flex gl-flex-grow-1"> <gl-search-box-by-click v-model="searchTerm" :placeholder="$options.i18n.searchPlaceholder" diff --git a/app/assets/javascripts/branches/init_delete_branch_button.js b/app/assets/javascripts/branches/init_branch_more_actions.js index 43df5d993a4..62f3c314c43 100644 --- a/app/assets/javascripts/branches/init_delete_branch_button.js +++ b/app/assets/javascripts/branches/init_branch_more_actions.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import DeleteBranchButton from '~/branches/components/delete_branch_button.vue'; +import DeleteBranchButton from '~/branches/components/branch_more_actions.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -export default function initDeleteBranchButton(el) { +export default function initBranchMoreActions(el) { if (!el) { return false; } @@ -10,11 +10,11 @@ export default function initDeleteBranchButton(el) { const { branchName, defaultBranchName, - deletePath, - tooltip, - disabled, + canDeleteBranch, isProtectedBranch, merged, + comparePath, + deletePath, } = el.dataset; return new Vue({ @@ -24,11 +24,11 @@ export default function initDeleteBranchButton(el) { props: { branchName, defaultBranchName, - deletePath, - tooltip, - disabled: parseBoolean(disabled), + canDeleteBranch: parseBoolean(canDeleteBranch), isProtectedBranch: parseBoolean(isProtectedBranch), merged: parseBoolean(merged), + comparePath, + deletePath, }, }), }); diff --git a/app/assets/javascripts/ci/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue index 5b1c322f07a..d4de42b10a8 100644 --- a/app/assets/javascripts/ci/artifacts/components/artifact_row.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue @@ -8,12 +8,10 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE, - BULK_DELETE_FEATURE_FLAG, I18N_BULK_DELETE_MAX_SELECTED, } from '../constants'; @@ -29,7 +27,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], inject: ['canDestroyArtifacts'], props: { artifact: { @@ -66,7 +63,7 @@ export default { return numberToHumanSize(this.artifact.size); }, canBulkDestroyArtifacts() { - return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts; + return this.canDestroyArtifacts; }, }, methods: { diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index 3f6ea56382f..88334488fdd 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -9,12 +9,12 @@ import { GlIcon, GlPagination, GlFormCheckbox, + GlTooltipDirective, } from '@gitlab/ui'; import { createAlert } from '~/alert'; 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'; @@ -38,11 +38,11 @@ import { INITIAL_NEXT_PAGE_CURSOR, 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, SELECTED_ARTIFACTS_MAX_COUNT, + I18N_BULK_DELETE_MAX_SELECTED, } from '../constants'; import JobCheckbox from './job_checkbox.vue'; import ArtifactsBulkDelete from './artifacts_bulk_delete.vue'; @@ -78,7 +78,9 @@ export default { ArtifactsTableRowDetails, FeedbackBanner, }, - mixins: [glFeatureFlagsMixin()], + directives: { + GlTooltip: GlTooltipDirective, + }, inject: ['projectId', 'projectPath', 'canDestroyArtifacts'], apollo: { jobArtifacts: { @@ -156,7 +158,7 @@ export default { return this.selectedArtifacts.length >= SELECTED_ARTIFACTS_MAX_COUNT; }, canBulkDestroyArtifacts() { - return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts; + return this.canDestroyArtifacts; }, isDeletingArtifactsForJob() { return this.jobArtifactsToDelete.length > 0; @@ -164,6 +166,25 @@ export default { artifactsToDelete() { return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts; }, + isAnyVisibleArtifactSelected() { + return this.jobArtifacts.some((job) => + job.artifacts.nodes.some((artifactNode) => + this.selectedArtifacts.includes(artifactNode.id), + ), + ); + }, + areAllVisibleArtifactsSelected() { + return this.jobArtifacts.every((job) => + job.artifacts.nodes.every((artifactNode) => + this.selectedArtifacts.includes(artifactNode.id), + ), + ); + }, + selectAllTooltipText() { + return this.isSelectedArtifactsLimitReached && !this.isAnyVisibleArtifactSelected + ? I18N_BULK_DELETE_MAX_SELECTED + : ''; + }, }, methods: { refetchArtifacts() { @@ -205,11 +226,11 @@ export default { } }, selectArtifact(artifactNode, checked) { - if (checked) { - if (!this.isSelectedArtifactsLimitReached) { - this.selectedArtifacts.push(artifactNode.id); - } - } else { + const isSelected = this.selectedArtifacts.includes(artifactNode.id); + + if (checked && !isSelected && !this.isSelectedArtifactsLimitReached) { + this.selectedArtifacts.push(artifactNode.id); + } else if (isSelected) { this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1); } }, @@ -274,6 +295,11 @@ export default { this.isBulkDeleteModalVisible = false; this.jobArtifactsToDelete = []; }, + handleSelectAllChecked(checked) { + this.jobArtifacts.map((job) => + job.artifacts.nodes.map((artifactNode) => this.selectArtifact(artifactNode, checked)), + ); + }, clearSelectedArtifacts() { this.selectedArtifacts = []; }, @@ -284,7 +310,13 @@ export default { return !job.archive?.downloadPath; }, browseButtonDisabled(job) { - return !job.browseArtifactsPath; + return !job.browseArtifactsPath || !job.hasMetadata; + }, + browseButtonHref(job) { + // make href blank when button is disabled so `cursor: not-allowed` is applied + if (this.browseButtonDisabled(job)) return ''; + + return job.browseArtifactsPath; }, deleteButtonDisabled(job) { return !job.hasArtifacts || !this.canBulkDestroyArtifacts; @@ -369,10 +401,12 @@ export default { </template> <template v-if="canBulkDestroyArtifacts" #head(checkbox)> <gl-form-checkbox - :disabled="!anyArtifactsSelected" - :checked="anyArtifactsSelected" - :indeterminate="anyArtifactsSelected" - @change="clearSelectedArtifacts" + v-gl-tooltip.right + :title="selectAllTooltipText" + :checked="isAnyVisibleArtifactSelected" + :indeterminate="isAnyVisibleArtifactSelected && !areAllVisibleArtifactsSelected" + :disabled="isSelectedArtifactsLimitReached && !isAnyVisibleArtifactSelected" + @change="handleSelectAllChecked" /> </template> <template @@ -469,7 +503,7 @@ export default { <gl-button icon="folder-open" :disabled="browseButtonDisabled(item)" - :href="item.browseArtifactsPath" + :href="browseButtonHref(item)" :title="$options.i18n.browse" :aria-label="$options.i18n.browse" data-testid="job-artifacts-browse-button" diff --git a/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue index 91296bd507e..861278147e9 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue @@ -48,7 +48,7 @@ export default { }, }, methods: { - handleInput(checked) { + handleChange(checked) { if (checked) { this.unselectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, true)); } else { @@ -65,6 +65,6 @@ export default { :disabled="disabled" :checked="checked" :indeterminate="indeterminate" - @input="handleInput" + @change="handleChange" /> </template> diff --git a/app/assets/javascripts/ci/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js index 7ba65e0f98f..2d89b6541f3 100644 --- a/app/assets/javascripts/ci/artifacts/constants.js +++ b/app/assets/javascripts/ci/artifacts/constants.js @@ -54,7 +54,6 @@ export const I18N_FEEDBACK_BANNER_BODY = s__( export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey'); export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8'; -export const BULK_DELETE_FEATURE_FLAG = 'ciJobArtifactBulkDestroy'; export const SELECTED_ARTIFACTS_MAX_COUNT = 50; export const I18N_BULK_DELETE_MAX_SELECTED = s__( 'Artifacts|Maximum selected artifacts limit reached', @@ -104,6 +103,7 @@ export const JOBS_PER_PAGE = 20; export const INITIAL_LAST_PAGE_SIZE = null; export const ARCHIVE_FILE_TYPE = 'ARCHIVE'; +export const METADATA_FILE_TYPE = 'METADATA'; export const ARTIFACT_ROW_HEIGHT = 56; export const ARTIFACTS_SHOWN_WITHOUT_SCROLLING = 4; diff --git a/app/assets/javascripts/ci/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js index ebcf0af8d2a..74ade7d48aa 100644 --- a/app/assets/javascripts/ci/artifacts/utils.js +++ b/app/assets/javascripts/ci/artifacts/utils.js @@ -1,10 +1,10 @@ import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { ARCHIVE_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants'; +import { ARCHIVE_FILE_TYPE, METADATA_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants'; export const totalArtifactsSizeForJob = (job) => numberToHumanSize( job.artifacts.nodes - .map((artifact) => artifact.size) + .map((artifact) => Number(artifact.size)) .reduce((total, artifact) => total + artifact, 0), ); @@ -21,6 +21,9 @@ export const mapBooleansToJobNodes = (jobNode) => { return { succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS, hasArtifacts: jobNode.artifacts.nodes.length > 0, + hasMetadata: jobNode.artifacts.nodes.some( + (artifact) => artifact.fileType === METADATA_FILE_TYPE, + ), ...jobNode, }; }; diff --git a/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue index 49a314e067c..39573b2180b 100644 --- a/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue @@ -108,7 +108,7 @@ export default { @click="lint" >{{ __('Validate') }}</gl-button > - <gl-form-checkbox v-model="dryRun" + <gl-form-checkbox v-model="dryRun" data-testid="ci-lint-dryrun" >{{ __('Simulate a pipeline created for the default branch') }} <gl-link :href="pipelineSimulationHelpPagePath" target="_blank" ><gl-icon class="gl-text-blue-600" name="question-o" /></gl-link 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 b3ecaceba69..41514d2d2f1 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 @@ -125,7 +125,7 @@ export default { return regex.test(this.variable.value); }, canSubmit() { - return this.variableValidationState && this.variable.key !== '' && this.variable.value !== ''; + return this.variableValidationState && this.variable.key !== ''; }, containsVariableReference() { const regex = /\$/; @@ -154,7 +154,9 @@ export default { return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments); }, maskedFeedback() { - return this.displayMaskedError ? __('This variable can not be masked.') : ''; + return this.displayMaskedError + ? __('This variable value does not meet the masking requirements.') + : ''; }, maskedState() { if (this.displayMaskedError) { @@ -190,6 +192,11 @@ export default { variableValidationState() { return this.variable.value === '' || (this.tokenValidationState && this.maskedState); }, + variableValueHelpText() { + return this.variable.masked + ? __('Value must meet regular expression requirements to be masked.') + : ''; + }, }, watch: { variable: { @@ -324,6 +331,7 @@ export default { :label="__('Value')" label-for="ci-variable-value" :state="variableValidationState" + :description="variableValueHelpText" :invalid-feedback="variableValidationFeedback" > <gl-form-textarea @@ -423,17 +431,19 @@ export default { > {{ __('Mask variable') }} <p class="gl-mt-2 text-secondary"> - {{ __('Variable will be masked in job logs.') }} - <span - :class="{ - 'bold text-plain': displayMaskedError, - }" - > - {{ __('Requires values to meet regular expression requirements.') }}</span + <gl-sprintf + :message=" + __( + 'Mask this variable in job logs if it meets %{linkStart}regular expression requirements%{linkEnd}.', + ) + " > - <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ - __('Learn more.') - }}</gl-link> + <template #link="{ content }" + ><gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ + content + }}</gl-link> + </template> + </gl-sprintf> </p> </gl-form-checkbox> <gl-form-checkbox 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 6f6c55e07c7..ec7a921664f 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 @@ -1,10 +1,12 @@ <script> import { GlAlert, + GlBadge, GlButton, GlLoadingIcon, GlModalDirective, GlKeysetPagination, + GlLink, GlTable, GlTooltipDirective, } from '@gitlab/ui'; @@ -15,18 +17,13 @@ import { DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT, EXCEEDS_VARIABLE_LIMIT_TEXT, MAXIMUM_VARIABLE_LIMIT_REACHED, - variableText, + variableTypes, } from '../constants'; import { convertEnvironmentScope } from '../utils'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, - fields: [ - { - key: 'variableType', - label: s__('CiVariables|Type'), - thClass: 'gl-w-10p', - }, + defaultFields: [ { key: 'key', label: s__('CiVariables|Key'), @@ -36,12 +33,11 @@ export default { { key: 'value', label: s__('CiVariables|Value'), - thClass: 'gl-w-15p', }, { - key: 'options', - label: s__('CiVariables|Options'), - thClass: 'gl-w-10p', + key: 'Attributes', + label: s__('CiVariables|Attributes'), + thClass: 'gl-w-40p', }, { key: 'environmentScope', @@ -54,10 +50,31 @@ export default { thClass: 'gl-w-5p', }, ], + inheritedVarsFields: [ + { + key: 'key', + label: s__('CiVariables|Key'), + tdClass: 'text-plain', + }, + { + key: 'Attributes', + label: s__('CiVariables|Attributes'), + }, + { + key: 'environmentScope', + label: s__('CiVariables|Environments'), + }, + { + key: 'group', + label: s__('CiVariables|Group'), + }, + ], components: { GlAlert, + GlBadge, GlButton, GlKeysetPagination, + GlLink, GlLoadingIcon, GlTable, }, @@ -66,6 +83,7 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], + inject: ['isInheritedGroupVars'], props: { entity: { type: String, @@ -112,6 +130,9 @@ export default { showAlert() { return !this.isLoading && this.exceedsVariableLimit; }, + showPagination() { + return this.glFeatures.ciVariablesPages; + }, valuesButtonText() { return this.areValuesHidden ? __('Reveal values') : __('Hide values'); }, @@ -119,12 +140,17 @@ export default { return !this.variables || this.variables.length === 0; }, fields() { - return this.$options.fields; + return this.isInheritedGroupVars + ? this.$options.inheritedVarsFields + : this.$options.defaultFields; + }, + tableDataTestId() { + return this.isInheritedGroupVars ? 'inherited-ci-variable-table' : 'ci-variable-table'; }, - variablesWithOptions() { + variablesWithAttributes() { return this.variables?.map((item, index) => ({ ...item, - options: this.getOptions(item), + attributes: this.getAttributes(item), index, })); }, @@ -133,27 +159,27 @@ export default { convertEnvironmentScopeValue(env) { return convertEnvironmentScope(env); }, - generateTypeText(item) { - return variableText[item.variableType]; - }, toggleHiddenState() { this.areValuesHidden = !this.areValuesHidden; }, setSelectedVariable(index = -1) { this.$emit('set-selected-variable', this.variables[index] ?? null); }, - getOptions(item) { - const options = []; + getAttributes(item) { + const attributes = []; + if (item.variableType === variableTypes.fileType) { + attributes.push(s__('CiVariables|File')); + } if (item.protected) { - options.push(s__('CiVariables|Protected')); + attributes.push(s__('CiVariables|Protected')); } if (item.masked) { - options.push(s__('CiVariables|Masked')); + attributes.push(s__('CiVariables|Masked')); } if (!item.raw) { - options.push(s__('CiVariables|Expanded')); + attributes.push(s__('CiVariables|Expanded')); } - return options.join(', '); + return attributes; }, }, maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED, @@ -161,7 +187,7 @@ export default { </script> <template> - <div class="ci-variable-table" data-testid="ci-variable-table"> + <div class="ci-variable-table" :data-testid="tableDataTestId"> <gl-loading-icon v-if="isLoading" /> <gl-alert v-if="showAlert" @@ -172,7 +198,7 @@ export default { {{ exceedsVariableLimitText }} </gl-alert> <div - v-if="glFeatures.ciVariablesPages" + v-if="showPagination && !isInheritedGroupVars" class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3" > <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button> @@ -191,13 +217,11 @@ export default { <gl-table v-if="!isLoading" :fields="fields" - :items="variablesWithOptions" + :items="variablesWithAttributes" tbody-tr-class="js-ci-variable-row" - data-qa-selector="ci_variable_table_content" sort-by="key" sort-direction="asc" stacked="lg" - table-class="gl-border-t" fixed show-empty sort-icon-left @@ -208,9 +232,6 @@ export default { <template #table-colgroup="scope"> <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> </template> - <template #cell(variableType)="{ item }"> - {{ generateTypeText(item) }} - </template> <template #cell(key)="{ item }"> <div class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" @@ -231,7 +252,7 @@ export default { /> </div> </template> - <template #cell(value)="{ item }"> + <template v-if="!isInheritedGroupVars" #cell(value)="{ item }"> <div class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" > @@ -254,8 +275,18 @@ export default { /> </div> </template> - <template #cell(options)="{ item }"> - <span data-testid="ci-variable-table-row-options">{{ item.options }}</span> + <template #cell(attributes)="{ item }"> + <span data-testid="ci-variable-table-row-attributes"> + <gl-badge + v-for="attribute in item.attributes" + :key="`${item.key}-${attribute}`" + class="gl-mr-2" + variant="info" + size="sm" + > + {{ attribute }} + </gl-badge> + </span> </template> <template #cell(environmentScope)="{ item }"> <div @@ -277,7 +308,21 @@ export default { /> </div> </template> - <template #cell(actions)="{ item }"> + <template v-if="isInheritedGroupVars" #cell(group)="{ item }"> + <div + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + > + <gl-link + :id="`ci-variable-group-${item.id}`" + data-testid="ci-variable-table-row-cicd-path" + class="gl-display-inline-block gl-max-w-full gl-word-break-word" + :href="item.groupCiCdSettingsPath" + > + {{ item.groupName }} + </gl-link> + </div> + </template> + <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }"> <gl-button v-gl-modal-directive="$options.modalId" icon="pencil" @@ -300,28 +345,32 @@ export default { > {{ exceedsVariableLimitText }} </gl-alert> - <div v-if="!glFeatures.ciVariablesPages" class="ci-variable-actions gl-display-flex gl-mt-5"> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mr-3" - data-qa-selector="add_ci_variable_button" - variant="confirm" - category="primary" - :aria-label="__('Add')" - :disabled="exceedsVariableLimit" - @click="setSelectedVariable()" - >{{ __('Add variable') }}</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 - v-bind="pageInfo" - :prev-text="__('Previous')" - :next-text="__('Next')" - @prev="$emit('handle-prev-page')" - @next="$emit('handle-next-page')" - /> + <div v-if="!isInheritedGroupVars"> + <div v-if="!showPagination" class="ci-variable-actions gl-display-flex gl-mt-5"> + <gl-button + v-gl-modal-directive="$options.modalId" + class="gl-mr-3" + data-qa-selector="add_ci_variable_button" + variant="confirm" + category="primary" + :aria-label="__('Add')" + :disabled="exceedsVariableLimit" + @click="setSelectedVariable()" + >{{ __('Add variable') }}</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 + v-bind="pageInfo" + :prev-text="__('Previous')" + :next-text="__('Next')" + @prev="$emit('handle-prev-page')" + @next="$emit('handle-next-page')" + /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index c8f67bd3436..d702dd073ec 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -20,28 +20,14 @@ export const variableTypes = { fileType: 'FILE', }; -// Once REST is removed, we won't need `types` -export const types = { - variableType: 'env_var', - fileType: 'file', -}; - export const allEnvironments = { type: '*', text: __('All (default)'), }; -// Once REST is removed, we won't need `types` key -export const variableText = { - [types.variableType]: __('Variable'), - [types.fileType]: __('File'), - [variableTypes.envType]: __('Variable'), - [variableTypes.fileType]: __('File'), -}; - export const variableOptions = [ - { value: variableTypes.envType, text: variableText[variableTypes.envType] }, - { value: variableTypes.fileType, text: variableText[variableTypes.fileType] }, + { value: variableTypes.envType, text: variableTypes.envType }, + { value: variableTypes.fileType, text: variableTypes.fileType }, ]; export const defaultVariableState = { diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js index 033cdbe864e..e47b41ceae5 100644 --- a/app/assets/javascripts/ci/ci_variable_list/index.js +++ b/app/assets/javascripts/ci/ci_variable_list/index.js @@ -67,6 +67,7 @@ const mountCiVariableListApp = (containerEl) => { groupId, groupPath, isGroup: parsedIsGroup, + isInheritedGroupVars: false, isProject: parsedIsProject, isProtectedByDefault, maskedEnvironmentVariablesLink, diff --git a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue new file mode 100644 index 00000000000..27ee1b794f6 --- /dev/null +++ b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue @@ -0,0 +1,110 @@ +<script> +import { produce } from 'immer'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { reportMessageToSentry } from '~/ci/ci_variable_list/utils'; +import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import getInheritedCiVariables from '../graphql/queries/inherited_ci_variables.query.graphql'; + +export const i18n = { + fetchError: s__('CiVariables|There was an error fetching the inherited CI variables.'), + tooManyCallsError: s__( + 'CiVariables|Maximum number of Inherited Group CI variables loaded (2000)', + ), +}; + +export const VARIABLES_PER_FETCH = 100; +export const FETCH_LIMIT = 20; + +export default { + name: 'InheritedCiVariablesApp', + components: { + CiVariableTable, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['projectPath'], + apollo: { + ciVariables: { + query: getInheritedCiVariables, + variables() { + return { + first: VARIABLES_PER_FETCH, + fullPath: this.projectPath, + }; + }, + update(data) { + return data.project.inheritedCiVariables?.nodes || []; + }, + result({ data }) { + this.pageInfo = data?.project?.inheritedCiVariables?.pageInfo || this.pageInfo; + this.hasNextPage = this.pageInfo?.hasNextPage || false; + if (!this.hasNextPage) { + return; + } + + // The query fetches 100 items at a time. + // Variables are batch loaded up to 20 consecutive API calls. + if (this.loadingCounter < FETCH_LIMIT) { + this.hasNextPage = false; + this.fetchMoreVariables(); + this.loadingCounter += 1; + } else { + createAlert({ message: this.$options.i18n.tooManyCallsError }); + reportMessageToSentry(this.$options.name, this.$options.i18n.tooManyCallsError, {}); + } + }, + error() { + this.showFetchError(); + }, + }, + }, + data() { + return { + ciVariables: [], + hasNextPage: false, + loadingCounter: 1, + pageInfo: {}, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.ciVariables.loading; + }, + }, + methods: { + fetchMoreVariables() { + this.$apollo.queries.ciVariables + .fetchMore({ + variables: { + after: this.pageInfo.endCursor, + }, + updateQuery(previousResult, { fetchMoreResult }) { + const previousVars = previousResult.project.inheritedCiVariables?.nodes; + const newVars = fetchMoreResult.project.inheritedCiVariables?.nodes; + + return produce(fetchMoreResult, (draftData) => { + draftData.project.inheritedCiVariables.nodes = previousVars.concat(newVars); + }); + }, + }) + .catch(this.showFetchError); + }, + showFetchError() { + this.hasNextPage = false; + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + i18n, +}; +</script> + +<template> + <ci-variable-table + entity="project" + :is-loading="isLoading" + :max-variable-limit="0" + :page-info="pageInfo" + :variables="ciVariables" + /> +</template> diff --git a/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql b/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql new file mode 100644 index 00000000000..b25768632e1 --- /dev/null +++ b/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql @@ -0,0 +1,24 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getInheritedCiVariables($after: String, $first: Int, $fullPath: ID!) { + project(fullPath: $fullPath) { + id + inheritedCiVariables(after: $after, first: $first) { + pageInfo { + ...PageInfo + } + nodes { + __typename + id + key + variableType + environmentScope + groupCiCdSettingsPath + groupName + masked + protected + raw + } + } + } +} diff --git a/app/assets/javascripts/ci/inherited_ci_variables/index.js b/app/assets/javascripts/ci/inherited_ci_variables/index.js new file mode 100644 index 00000000000..324aae2a573 --- /dev/null +++ b/app/assets/javascripts/ci/inherited_ci_variables/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { generateCacheConfig, resolvers } from '../ci_variable_list/graphql/settings'; +import InheritedCiVariables from './components/inherited_ci_variables_app.vue'; + +export default (containerId = 'js-inherited-group-ci-variables') => { + const el = document.getElementById(containerId); + + if (!el) { + return; + } + + const { projectPath } = el.dataset; + + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + resolvers, + generateCacheConfig(false), // set to true if we're using key-set pagination + ), + }); + + // eslint-disable-next-line consistent-return + return new Vue({ + el, + apolloProvider, + provide: { + isInheritedGroupVars: true, + projectPath, + }, + render(createElement) { + return createElement(InheritedCiVariables); + }, + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index ea7201efcd9..c2e4c234d2b 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -1,8 +1,9 @@ <script> import { GlDrawer } from '@gitlab/ui'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { __ } from '~/locale'; -import { DRAWER_CONTAINER_CLASS } from '../job_assistant_drawer/constants'; +import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; @@ -31,24 +32,24 @@ export default { zIndex: { type: Number, required: false, - default: 200, + default: DRAWER_Z_INDEX, }, }, computed: { - drawerHeightOffset() { - return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); + getDrawerHeaderHeight() { + return getContentWrapperHeight(); }, }, methods: { closeDrawer() { - this.$emit('close-drawer'); + this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE); }, }, }; </script> <template> <gl-drawer - :header-height="drawerHeightOffset" + :header-height="getDrawerHeaderHeight" :open="isVisible" :z-index="zIndex" @close="closeDrawer" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index eabf4749e9c..6ba8884f9a6 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -3,7 +3,14 @@ import { GlButton } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants'; +import { + EDITOR_APP_DRAWER_AI_ASSISTANT, + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_JOB_ASSISTANT, + EDITOR_APP_DRAWER_NONE, + pipelineEditorTrackingOptions, + TEMPLATE_REPOSITORY_URL, +} from '../../constants'; export default { i18n: { @@ -19,7 +26,7 @@ export default { mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['aiChatAvailable'], props: { - showDrawer: { + showHelpDrawer: { type: Boolean, required: true, }, @@ -38,22 +45,24 @@ export default { }, }, methods: { - toggleDrawer() { - if (this.showDrawer) { - this.$emit('close-drawer'); + toggleHelpDrawer() { + if (this.showHelpDrawer) { + this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE); } else { - this.$emit('open-drawer'); + this.$emit('switch-drawer', EDITOR_APP_DRAWER_HELP); this.trackHelpDrawerClick(); } }, toggleJobAssistantDrawer() { this.$emit( - this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer', + 'switch-drawer', + this.showJobAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_JOB_ASSISTANT, ); }, toggleAiAssistantDrawer() { this.$emit( - this.showAiAssistantDrawer ? 'close-ai-assistant-drawer' : 'open-ai-assistant-drawer', + 'switch-drawer', + this.showAiAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_AI_ASSISTANT, ); }, trackHelpDrawerClick() { @@ -70,7 +79,10 @@ export default { </script> <template> - <div class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <div + class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1 gl-sm-flex-direction-column" + > + <slot></slot> <gl-button :href="$options.TEMPLATE_REPOSITORY_URL" size="small" @@ -87,7 +99,7 @@ export default { size="small" data-testid="drawer-toggle" data-qa-selector="drawer_toggle" - @click="toggleDrawer" + @click="toggleHelpDrawer" > {{ $options.i18n.help }} </gl-button> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index ef9acc1f8f1..a410e4c933c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -229,7 +229,6 @@ export default { <gl-infinite-scroll :fetched-items="availableBranches.length" :max-list-height="250" - data-qa-selector="branch_menu_container" @bottomReached="fetchNextBranches" > <template #items> @@ -238,7 +237,6 @@ export default { :key="branch" :is-checked="currentBranch === branch" is-check-item - data-qa-selector="branch_menu_item_button" @click="selectBranch(branch)" > {{ branch }} diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index a4dfb401f4c..656b1a6c347 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -2,7 +2,7 @@ import { __ } from '~/locale'; import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; -import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '../../constants'; export default { diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 372f04075ab..bb79a4d74da 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -9,7 +9,9 @@ import { getQueryHeaders, toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; @@ -32,11 +34,13 @@ export default { GlLink, GlLoadingIcon, GlSprintf, + GraphqlPipelineMiniGraph, PipelineEditorMiniGraph, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], inject: ['projectFullPath'], props: { commitSha: { @@ -106,6 +110,9 @@ export default { hasPipelineData() { return Boolean(this.pipeline?.id); }, + isUsingPipelineMiniGraphQueries() { + return this.glFeatures.ciGraphqlPipelineMiniGraph; + }, pipelineId() { return getIdFromGraphQLId(this.pipeline.id); }, @@ -171,8 +178,14 @@ export default { </gl-sprintf> </span> </div> - <div class="gl-display-flex gl-flex-wrap"> - <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" /> + <div class="gl-display-flex gl-flex-wrap-wrap"> + <graphql-pipeline-mini-graph + v-if="isUsingPipelineMiniGraphQueries" + :full-path="projectFullPath" + :iid="pipeline.iid" + :pipeline-etag="pipelineEtag" + /> + <pipeline-editor-mini-graph v-else :pipeline="pipeline" v-on="$listeners" /> <gl-button class="gl-ml-3" category="secondary" 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 index 25bbd6b3180..794763e0cd8 100644 --- 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 @@ -1,15 +1,19 @@ <script> -import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; +import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { get, toPath } from 'lodash'; -import { i18n } from '../constants'; +import { i18n, HELP_PATHS } from '../constants'; export default { i18n, + artifactsHelpPath: HELP_PATHS.artifactsHelpPath, + cacheHelpPath: HELP_PATHS.cacheHelpPath, components: { GlFormGroup, GlAccordionItem, GlFormInput, GlButton, + GlLink, + GlSprintf, }, props: { job: { @@ -61,6 +65,16 @@ export default { </script> <template> <gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE"> + <div class="gl-pb-5"> + <gl-sprintf :message="$options.i18n.ARTIFACTS_AND_CACHE_DESCRIPTION"> + <template #artifactsLink="{ content }"> + <gl-link :href="$options.artifactsHelpPath">{{ content }}</gl-link> + </template> + <template #cacheLink="{ content }"> + <gl-link :href="$options.cacheHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> <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> @@ -82,6 +96,7 @@ export default { category="tertiary" icon="remove" :data-testid="entry.generateDeleteButtonDataTestId(index)" + :aria-label="entry.generateDeleteButtonDataTestId(index)" @click="deleteStringArrayItem(`${entry.key}[${index}]`)" /> </div> 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 b4b468987d8..2c27b66f108 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 @@ -1,15 +1,25 @@ <script> -import { GlFormGroup, GlAccordionItem, GlFormInput, GlFormTextarea } from '@gitlab/ui'; -import { i18n } from '../constants'; +import { + GlFormGroup, + GlAccordionItem, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { i18n, HELP_PATHS } from '../constants'; export default { i18n, + helpPath: HELP_PATHS.imageHelpPath, placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT, components: { GlAccordionItem, GlFormInput, GlFormTextarea, GlFormGroup, + GlLink, + GlSprintf, }, props: { job: { @@ -26,6 +36,13 @@ export default { </script> <template> <gl-accordion-item :title="$options.i18n.IMAGE"> + <div class="gl-pb-5"> + <gl-sprintf :message="$options.i18n.IMAGE_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="$options.helpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> <gl-form-group :label="$options.i18n.IMAGE_NAME"> <gl-form-input :value="job.image.name" 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 index d068b370852..d0f206e767f 100644 --- 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 @@ -5,11 +5,14 @@ import { GlFormInput, GlFormSelect, GlFormCheckbox, + GlLink, + GlSprintf, } from '@gitlab/ui'; -import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants'; +import { i18n, HELP_PATHS, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants'; export default { i18n, + helpPath: HELP_PATHS.rulesHelpPath, whenOptions: Object.values(JOB_RULES_WHEN), unitOptions: Object.values(JOB_RULES_START_IN), components: { @@ -18,6 +21,8 @@ export default { GlFormSelect, GlFormCheckbox, GlFormGroup, + GlLink, + GlSprintf, }, props: { job: { @@ -54,6 +59,13 @@ export default { </script> <template> <gl-accordion-item :title="$options.i18n.RULES"> + <div class="gl-pb-5"> + <gl-sprintf :message="$options.i18n.RULES_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="$options.helpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> <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 diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue index 9bada3ef110..0b12d0aedd6 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue @@ -1,9 +1,18 @@ <script> -import { GlAccordionItem, GlFormInput, GlButton, GlFormGroup, GlFormTextarea } from '@gitlab/ui'; -import { i18n } from '../constants'; +import { + GlAccordionItem, + GlFormInput, + GlButton, + GlFormGroup, + GlFormTextarea, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { i18n, HELP_PATHS } from '../constants'; export default { i18n, + helpPath: HELP_PATHS.servicesHelpPath, placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT, components: { GlAccordionItem, @@ -11,6 +20,8 @@ export default { GlFormInput, GlFormTextarea, GlButton, + GlLink, + GlSprintf, }, props: { job: { @@ -45,6 +56,13 @@ export default { </script> <template> <gl-accordion-item :title="$options.i18n.SERVICE"> + <div class="gl-pb-5"> + <gl-sprintf :message="$options.i18n.SERVICES_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="$options.helpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> <div v-for="(service, index) in job.services" :key="index" @@ -56,6 +74,7 @@ export default { category="tertiary" icon="remove" :data-testid="`delete-job-service-button-${index}`" + :aria-label="`delete-job-service-button-${index}`" @click="deleteService(index)" /> <gl-form-group :label="$options.i18n.SERVICE_NAME"> 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 e93a9e84302..087ae992916 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 @@ -1,6 +1,5 @@ import { __, s__ } from '~/locale'; - -export const DRAWER_CONTAINER_CLASS = '.content-wrapper'; +import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; export const JOB_RULES_WHEN = { onSuccess: { @@ -115,4 +114,24 @@ export const i18n = { SERVICE_NAME: s__('JobAssistant|Service name (optional)'), SERVICE_ENTRYPOINT: s__('JobAssistant|Service entrypoint (optional)'), ENTRYPOINT_PLACEHOLDER_TEXT: s__('JobAssistant|Please enter the parameters.'), + IMAGE_DESCRIPTION: s__( + 'JobAssistant|Specify a Docker image that the job runs in. %{linkStart}Learn more%{linkEnd}', + ), + SERVICES_DESCRIPTION: s__( + 'JobAssistant|Specify any additional Docker images that your scripts require to run successfully. %{linkStart}Learn more%{linkEnd}', + ), + ARTIFACTS_AND_CACHE_DESCRIPTION: s__( + 'JobAssistant|Specify the %{artifactsLinkStart}artifacts%{artifactsLinkEnd} and %{cacheLinkStart}cache%{cacheLinkEnd} of the job.', + ), + RULES_DESCRIPTION: s__( + 'JobAssistant|Include or exclude jobs in pipelines. %{linkStart}Learn more%{linkEnd}', + ), +}; + +export const HELP_PATHS = { + artifactsHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#artifacts`, + cacheHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#cache`, + imageHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#image`, + rulesHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#rules`, + servicesHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#services`, }; 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 30746065732..1a58a112e50 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 @@ -2,10 +2,12 @@ import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui'; import { stringify, parse } from 'yaml'; import { get, omit, toPath } from 'lodash'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; +import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants'; import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql'; -import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants'; +import { 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'; @@ -34,7 +36,7 @@ export default { zIndex: { type: Number, required: false, - default: 200, + default: DRAWER_Z_INDEX, }, ciConfigData: { type: Object, @@ -78,8 +80,8 @@ export default { }; }); }, - drawerHeightOffset() { - return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); + getDrawerHeaderHeight() { + return getContentWrapperHeight(); }, isJobValid() { return this.isNameValid && this.isScriptValid && this.isStartValid; @@ -100,7 +102,7 @@ export default { methods: { closeDrawer() { this.clearJob(); - this.$emit('close-job-assistant-drawer'); + this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE); }, addCiConfig() { this.validateJob(); @@ -172,7 +174,7 @@ export default { <template> <gl-drawer class="job-assistant-drawer" - :header-height="drawerHeightOffset" + :header-height="getDrawerHeaderHeight" :open="isVisible" :z-index="zIndex" @close="closeDrawer" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index 403793a255a..a954615ca8a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import CiEditorHeader from 'ee_else_ce/ci/pipeline_editor/components/editor/ci_editor_header.vue'; import { s__, __ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -19,7 +20,6 @@ import { } from '../constants'; import getAppStatus from '../graphql/queries/client/app_status.query.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; -import CiEditorHeader from './editor/ci_editor_header.vue'; import CiValidate from './validate/ci_validate.vue'; import TextEditor from './editor/text_editor.vue'; import EditorTab from './ui/editor_tab.vue'; @@ -87,19 +87,19 @@ export default { type: String, required: true, }, - isNewCiConfigFile: { + showHelpDrawer: { type: Boolean, required: true, }, - showDrawer: { + showJobAssistantDrawer: { type: Boolean, required: true, }, - showJobAssistantDrawer: { + showAiAssistantDrawer: { type: Boolean, required: true, }, - showAiAssistantDrawer: { + isNewCiConfigFile: { type: Boolean, required: true, }, @@ -196,7 +196,7 @@ export default { > <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> <ci-editor-header - :show-drawer="showDrawer" + :show-help-drawer="showHelpDrawer" :show-job-assistant-drawer="showJobAssistantDrawer" :show-ai-assistant-drawer="showAiAssistantDrawer" v-on="$listeners" diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index 912e0fcbff9..e85138e361f 100644 --- a/app/assets/javascripts/ci/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js @@ -1,5 +1,10 @@ import { s__ } from '~/locale'; +export const EDITOR_APP_DRAWER_HELP = 'HELP'; +export const EDITOR_APP_DRAWER_JOB_ASSISTANT = 'JOB_ASSISTANT'; +export const EDITOR_APP_DRAWER_AI_ASSISTANT = 'AI_ASSISTANT'; +export const EDITOR_APP_DRAWER_NONE = ''; + // Values for CI_CONFIG_STATUS_* comes from lint graphQL export const CI_CONFIG_STATUS_INVALID = 'INVALID'; export const CI_CONFIG_STATUS_VALID = 'VALID'; @@ -65,6 +70,7 @@ export const CI_YAML_LINK = 'CI_YAML_LINK'; export const pipelineEditorTrackingOptions = { label: 'pipeline_editor', actions: { + browseCatalog: 'browse_catalog', browseTemplates: 'browse_templates', closeHelpDrawer: 'close_help_drawer', commitCiConfig: 'commit_ci_config', diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js index fa1c70c1994..ed5be66d07a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js @@ -7,30 +7,36 @@ import getPipelineEtag from './queries/client/pipeline_etag.query.graphql'; export const resolvers = { Mutation: { lintCI: (_, { endpoint, content, dry_run }) => { - return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ - valid: data.valid, - errors: data.errors, - warnings: data.warnings, - jobs: data.jobs.map((job) => { - const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; + return axios.post(endpoint, { content, dry_run }).then(({ data }) => { + const { errors, warnings, valid, jobs } = data; - return { - name: job.name, - stage: job.stage, - beforeScript: job.before_script, - script: job.script, - afterScript: job.after_script, - tags: job.tag_list, - environment: job.environment, - when: job.when, - allowFailure: job.allow_failure, - only, - except: job.except, - __typename: 'CiLintJob', - }; - }), - __typename: 'CiLintContent', - })); + return { + valid, + errors, + warnings, + jobs: jobs.map((job) => { + const only = job.only + ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } + : null; + + return { + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tags: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only, + except: job.except, + __typename: 'CiLintJob', + }; + }), + __typename: 'CiLintContent', + }; + }); }, updateAppStatus: (_, { appStatus }, { cache }) => { cache.writeQuery({ diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index b8d6c27435d..bc20e478876 100644 --- a/app/assets/javascripts/ci/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js @@ -1,17 +1,6 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { EDITOR_APP_STATUS_LOADING } from './constants'; -import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; -import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; -import getAppStatus from './graphql/queries/client/app_status.query.graphql'; -import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql'; -import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql'; -import { resolvers } from './graphql/resolvers'; -import typeDefs from './graphql/typedefs.graphql'; -import PipelineEditorApp from './pipeline_editor_app.vue'; +import { createAppOptions } from 'ee_else_ce/ci/pipeline_editor/options'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); @@ -20,129 +9,9 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { return null; } - const { - // Add to apollo cache as it can be updated by future queries - initialBranchName, - pipelineEtag, - // Add to provide/inject API for static values - ciConfigPath, - ciExamplesHelpPagePath, - ciHelpPagePath, - ciLintPath, - ciTroubleshootingPath, - defaultBranch, - emptyStateIllustrationPath, - helpPaths, - includesHelpPagePath, - lintHelpPagePath, - needsHelpPagePath, - newMergeRequestPath, - pipelinePagePath, - projectFullPath, - projectPath, - projectNamespace, - simulatePipelineHelpPagePath, - totalBranches, - usesExternalConfig, - validateTabIllustrationPath, - ymlHelpPagePath, - aiChatAvailable, - } = el.dataset; + const options = createAppOptions(el); - const configurationPaths = Object.fromEntries( - Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [ - source, - el.dataset[datasetKey], - ]), - ); - - Vue.use(VueApollo); - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers, { - typeDefs, - useGet: true, - }), - }); - const { cache } = apolloProvider.clients.defaultClient; - - cache.writeQuery({ - query: getAppStatus, - data: { - app: { - __typename: 'PipelineEditorApp', - status: EDITOR_APP_STATUS_LOADING, - }, - }, - }); - - cache.writeQuery({ - query: getCurrentBranch, - data: { - workBranches: { - __typename: 'BranchList', - current: { - __typename: 'WorkBranch', - name: initialBranchName || defaultBranch, - }, - }, - }, - }); - - cache.writeQuery({ - query: getLastCommitBranch, - data: { - workBranches: { - __typename: 'BranchList', - lastCommit: { - __typename: 'WorkBranch', - name: '', - }, - }, - }, - }); - - cache.writeQuery({ - query: getPipelineEtag, - data: { - etags: { - __typename: 'EtagValues', - pipeline: pipelineEtag, - }, - }, - }); - - return new Vue({ - el, - apolloProvider, - provide: { - aiChatAvailable: parseBoolean(aiChatAvailable), - ciConfigPath, - ciExamplesHelpPagePath, - ciHelpPagePath, - ciLintPath, - ciTroubleshootingPath, - configurationPaths, - dataMethod: 'graphql', - defaultBranch, - emptyStateIllustrationPath, - helpPaths, - includesHelpPagePath, - lintHelpPagePath, - needsHelpPagePath, - newMergeRequestPath, - pipelinePagePath, - projectFullPath, - projectPath, - projectNamespace, - simulatePipelineHelpPagePath, - totalBranches: parseInt(totalBranches, 10), - usesExternalConfig: parseBoolean(usesExternalConfig), - validateTabIllustrationPath, - ymlHelpPagePath, - }, - render(h) { - return h(PipelineEditorApp); - }, - }); + return new Vue(options); }; + +initPipelineEditor(); diff --git a/app/assets/javascripts/ci/pipeline_editor/options.js b/app/assets/javascripts/ci/pipeline_editor/options.js new file mode 100644 index 00000000000..922c8eee8fc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/options.js @@ -0,0 +1,142 @@ +import Vue from 'vue'; + +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { EDITOR_APP_STATUS_LOADING } from './constants'; +import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql'; +import { resolvers } from './graphql/resolvers'; +import typeDefs from './graphql/typedefs.graphql'; +import PipelineEditorApp from './pipeline_editor_app.vue'; + +export const createAppOptions = (el) => { + const { + // Add to apollo cache as it can be updated by future queries + initialBranchName, + pipelineEtag, + // Add to provide/inject API for static values + ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, + ciLintPath, + ciTroubleshootingPath, + defaultBranch, + emptyStateIllustrationPath, + helpPaths, + includesHelpPagePath, + lintHelpPagePath, + needsHelpPagePath, + newMergeRequestPath, + pipelinePagePath, + projectFullPath, + projectPath, + projectNamespace, + simulatePipelineHelpPagePath, + totalBranches, + usesExternalConfig, + validateTabIllustrationPath, + ymlHelpPagePath, + aiChatAvailable, + } = el.dataset; + + const configurationPaths = Object.fromEntries( + Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [ + source, + el.dataset[datasetKey], + ]), + ); + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers, { + typeDefs, + useGet: true, + }), + }); + const { cache } = apolloProvider.clients.defaultClient; + + cache.writeQuery({ + query: getAppStatus, + data: { + app: { + __typename: 'PipelineEditorApp', + status: EDITOR_APP_STATUS_LOADING, + }, + }, + }); + + cache.writeQuery({ + query: getCurrentBranch, + data: { + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: initialBranchName || defaultBranch, + }, + }, + }, + }); + + cache.writeQuery({ + query: getLastCommitBranch, + data: { + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: '', + }, + }, + }, + }); + + cache.writeQuery({ + query: getPipelineEtag, + data: { + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, + }, + }); + + return { + el, + apolloProvider, + provide: { + aiChatAvailable: parseBoolean(aiChatAvailable), + ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, + ciLintPath, + ciTroubleshootingPath, + configurationPaths, + dataMethod: 'graphql', + defaultBranch, + emptyStateIllustrationPath, + helpPaths, + includesHelpPagePath, + lintHelpPagePath, + needsHelpPagePath, + newMergeRequestPath, + pipelinePagePath, + projectFullPath, + projectPath, + projectNamespace, + simulatePipelineHelpPagePath, + totalBranches: parseInt(totalBranches, 10), + usesExternalConfig: parseBoolean(usesExternalConfig), + validateTabIllustrationPath, + ymlHelpPagePath, + }, + render(h) { + return h(PipelineEditorApp); + }, + }; +}; 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 647e33333ce..0495546529a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -1,6 +1,7 @@ <script> import { GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; @@ -9,12 +10,22 @@ import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_na import PipelineEditorFileTree from './components/file_tree/container.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; -import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants'; +import { + CREATE_TAB, + FILE_TREE_DISPLAY_KEY, + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_JOB_ASSISTANT, + EDITOR_APP_DRAWER_AI_ASSISTANT, + EDITOR_APP_DRAWER_NONE, +} from './constants'; const AiAssistantDrawer = () => import('ee_component/ci/pipeline_editor/components/ai_assistant_drawer.vue'); export default { + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_JOB_ASSISTANT, + EDITOR_APP_DRAWER_AI_ASSISTANT, commitSectionRef: 'commitSectionRef', modal: { switchBranch: { @@ -67,15 +78,16 @@ export default { }, data() { return { + currentDrawer: EDITOR_APP_DRAWER_NONE, currentTab: CREATE_TAB, scrollToCommitForm: false, shouldLoadNewBranch: false, - showDrawer: false, - showJobAssistantDrawer: false, - showAiAssistantDrawer: false, - drawerIndex: 200, - jobAssistantIndex: 200, - aiAssistantIndex: 200, + currentDrawerIndex: DRAWER_Z_INDEX, + drawerIndex: { + [EDITOR_APP_DRAWER_HELP]: DRAWER_Z_INDEX, + [EDITOR_APP_DRAWER_JOB_ASSISTANT]: DRAWER_Z_INDEX, + [EDITOR_APP_DRAWER_AI_ASSISTANT]: DRAWER_Z_INDEX, + }, showFileTree: false, showSwitchBranchModal: false, }; @@ -87,6 +99,15 @@ export default { includesFiles() { return this.ciConfigData?.includes || []; }, + showHelpDrawer() { + return this.currentDrawer === EDITOR_APP_DRAWER_HELP; + }, + showJobAssistantDrawer() { + return this.currentDrawer === EDITOR_APP_DRAWER_JOB_ASSISTANT; + }, + showAiAssistantDrawer() { + return this.currentDrawer === EDITOR_APP_DRAWER_AI_ASSISTANT; + }, }, mounted() { this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false; @@ -95,29 +116,15 @@ export default { closeBranchModal() { this.showSwitchBranchModal = false; }, - closeDrawer() { - this.showDrawer = false; - }, - closeJobAssistantDrawer() { - this.showJobAssistantDrawer = false; - }, - closeAiAssistantDrawer() { - this.showAiAssistantDrawer = false; - }, - openAiAssistantDrawer() { - this.showAiAssistantDrawer = true; - this.aiAssistantIndex = this.drawerIndex + 1; - }, handleConfirmSwitchBranch() { this.showSwitchBranchModal = true; }, - openDrawer() { - this.showDrawer = true; - this.drawerIndex = this.jobAssistantIndex + 1; - }, - openJobAssistantDrawer() { - this.showJobAssistantDrawer = true; - this.jobAssistantIndex = this.drawerIndex + 1; + switchDrawer(drawerName) { + this.currentDrawer = drawerName; + if (this.drawerIndex[drawerName]) { + this.currentDrawerIndex += 1; + this.drawerIndex[drawerName] = this.currentDrawerIndex; + } }, toggleFileTree() { this.showFileTree = !this.showFileTree; @@ -180,16 +187,11 @@ export default { :commit-sha="commitSha" :current-tab="currentTab" :is-new-ci-config-file="isNewCiConfigFile" - :show-drawer="showDrawer" + :show-help-drawer="showHelpDrawer" :show-job-assistant-drawer="showJobAssistantDrawer" :show-ai-assistant-drawer="showAiAssistantDrawer" v-on="$listeners" - @open-drawer="openDrawer" - @close-drawer="closeDrawer" - @open-job-assistant-drawer="openJobAssistantDrawer" - @close-job-assistant-drawer="closeJobAssistantDrawer" - @open-ai-assistant-drawer="openAiAssistantDrawer" - @close-ai-assistant-drawer="closeAiAssistantDrawer" + @switch-drawer="switchDrawer" @set-current-tab="setCurrentTab" @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> @@ -207,24 +209,24 @@ export default { v-on="$listeners" /> <pipeline-editor-drawer - :is-visible="showDrawer" - :z-index="drawerIndex" + :is-visible="showHelpDrawer" + :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_HELP]" v-on="$listeners" - @close-drawer="closeDrawer" + @switch-drawer="switchDrawer" /> <job-assistant-drawer :ci-config-data="ciConfigData" :ci-file-content="ciFileContent" :is-visible="showJobAssistantDrawer" - :z-index="jobAssistantIndex" + :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_JOB_ASSISTANT]" v-on="$listeners" - @close-job-assistant-drawer="closeJobAssistantDrawer" + @switch-drawer="switchDrawer" /> <ai-assistant-drawer v-if="glFeatures.aiCiConfigGenerator" :is-visible="showAiAssistantDrawer" - :z-index="aiAssistantIndex" - @close-ai-assistant-drawer="closeAiAssistantDrawer" + :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_AI_ASSISTANT]" + @switch-drawer="switchDrawer" /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue index 429f8e78dbe..cfcc729b5c9 100644 --- a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue @@ -28,6 +28,13 @@ export default { required: false, default: () => ({}), }, + queryParams: { + type: Object, + required: false, + default: () => ({ + sort: 'updated_desc', + }), + }, }, computed: { refShortName() { @@ -51,6 +58,7 @@ export default { :project-id="projectId" :translations="$options.i18n" :use-symbolic-ref-names="true" + :query-params="queryParams" toggle-button-class="gl-w-auto! gl-mb-0!" @input="setRefSelected" /> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue index f633ba053ee..39ac55bb9c5 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -1,5 +1,5 @@ <script> -import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg'; +import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg?raw'; import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; 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 e4d47fba464..f0a41a5949e 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 @@ -1,6 +1,6 @@ <script> import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; @@ -32,7 +32,7 @@ export default { message: s__('Runners|Runner created.'), variant: VARIANT_SUCCESS, }); - redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated + visitUrl(ephemeralRegisterUrl); }, onError(error) { createAlert({ message: error.message }); @@ -60,7 +60,7 @@ export default { <hr aria-hidden="true" /> - <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + <h2 class="gl-font-size-h2 gl-my-5"> {{ s__('Runners|Platform') }} </h2> <runner-platforms-radio-group v-model="platform" /> diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index 668a55d2437..d385d32fd9d 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -2,7 +2,7 @@ import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; @@ -71,7 +71,7 @@ export default { }, onDeleted({ message }) { saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS }); - redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated + visitUrl(this.runnersPath); }, }, }; diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index 4d04b5d4b14..e287e4e17d1 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -20,7 +20,13 @@ export default { }, computed: { paused() { - return !this.runner.active; + return this.runner.paused; + }, + contactedAt() { + return this.runner.contactedAt; + }, + status() { + return this.runner.status; }, }, }; @@ -29,7 +35,8 @@ export default { <template> <div> <runner-status-badge - :runner="runner" + :contacted-at="contactedAt" + :status="status" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> <runner-paused-badge 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 f24fb5575ae..9f4ce14f704 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 @@ -8,6 +8,7 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; +import RunnerManagersBadge from '../runner_managers_badge.vue'; import { formatJobCount } from '../../utils'; import { @@ -29,6 +30,7 @@ export default { RunnerName, RunnerTags, RunnerTypeBadge, + RunnerManagersBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), UserAvatarLink, @@ -44,6 +46,9 @@ export default { }, }, computed: { + managersCount() { + return this.runner.managers?.count || 0; + }, jobCount() { return formatJobCount(this.runner.jobCount); }, @@ -75,6 +80,8 @@ export default { <slot :runner="runner" name="runner-name"> <runner-name :runner="runner" /> </slot> + + <runner-managers-badge :count="managersCount" size="sm" class="gl-vertical-align-middle" /> <gl-icon v-if="runner.locked" v-gl-tooltip @@ -87,7 +94,7 @@ export default { <div class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full"> <template v-if="runner.version"> <div class="gl-flex-shrink-0"> - <runner-upgrade-status-icon :runner="runner" /> + <runner-upgrade-status-icon :upgrade-status="runner.upgradeStatus" /> <gl-sprintf :message="$options.i18n.I18N_VERSION_LABEL"> <template #version>{{ runner.version }}</template> </gl-sprintf> diff --git a/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue index ff182c61ccf..9cf2572c924 100644 --- a/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue +++ b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue @@ -42,8 +42,8 @@ export default { }; }, computed: { - drawerHeightOffset() { - return getContentWrapperHeight('.content-wrapper'); + getDrawerHeaderHeight() { + return getContentWrapperHeight(); }, architectureOptions() { return platformArchitectures({ platform: this.selectedPlatform }); @@ -86,7 +86,7 @@ export default { <template> <gl-drawer :open="open" - :header-height="drawerHeightOffset" + :header-height="getDrawerHeaderHeight" :z-index="$options.DRAWER_Z_INDEX" data-testid="runner-platforms-drawer" @close="onClose" diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue index fe19977f783..6fd4edf5847 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue @@ -1,5 +1,5 @@ <script> -import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg?url'; +import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/rocket-launch-md.svg?url'; import { GlBanner } from '@gitlab/ui'; import { s__ } from '~/locale'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; 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 6107b4dd3ea..1b363174d28 100644 --- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue @@ -9,7 +9,7 @@ import { DEFAULT_ACCESS_LEVEL, PROJECT_TYPE, GROUP_TYPE, - INSTANCE_TYPE, + I18N_CREATE_ERROR, } from '../constants'; export default { @@ -40,11 +40,13 @@ export default { return { saving: false, runner: { + runnerType: this.runnerType, description: '', maintenanceNote: '', paused: false, accessLevel: DEFAULT_ACCESS_LEVEL, runUntagged: false, + locked: false, tagList: '', maximumTimeout: '', }, @@ -57,26 +59,22 @@ export default { if (this.runnerType === GROUP_TYPE) { return { ...input, - runnerType: GROUP_TYPE, groupId: this.groupId, }; } if (this.runnerType === PROJECT_TYPE) { return { ...input, - runnerType: PROJECT_TYPE, projectId: this.projectId, }; } - return { - ...input, - runnerType: INSTANCE_TYPE, - }; + return input; }, }, methods: { async onSubmit() { this.saving = true; + try { const { data: { @@ -90,16 +88,29 @@ export default { }); if (errors?.length) { - this.$emit('error', new Error(errors.join(' '))); - } else { - this.onSuccess(runner); + this.onError(new Error(errors.join(' ')), true); + return; + } + + if (!runner?.ephemeralRegisterUrl) { + // runner is missing information, report issue and + // fail naviation to register page. + this.onError(new Error(I18N_CREATE_ERROR)); + return; } + + this.onSuccess(runner); } catch (error) { + this.onError(error); + } + }, + onError(error, isValidationError = false) { + if (!isValidationError) { captureException({ error, component: this.$options.name }); - this.$emit('error', error); - } finally { - this.saving = false; } + + this.$emit('error', error); + this.saving = false; }, onSuccess(runner) { this.$emit('saved', runner); @@ -111,9 +122,9 @@ export default { <gl-form @submit.prevent="onSubmit"> <runner-form-fields v-model="runner" /> - <div class="gl-display-flex"> + <div class="gl-display-flex gl-mt-6"> <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="saving"> - {{ __('Submit') }} + {{ s__('Runners|Create runner') }} </gl-button> </div> </gl-form> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue index 020487fc727..3560521e8d7 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -45,6 +45,9 @@ export default { runnerName() { return `#${this.runnerId} (${this.runner.shortSha})`; }, + runnerManagersCount() { + return this.runner.managers?.count || 0; + }, runnerDeleteModalId() { return `delete-runner-modal-${this.runnerId}`; }, @@ -150,6 +153,7 @@ export default { <runner-delete-modal :modal-id="runnerDeleteModalId" :runner-name="runnerName" + :managers-count="runnerManagersCount" @primary="onDelete" /> </div> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue index 8be216a7eb5..93f79fd67ea 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue @@ -1,12 +1,9 @@ <script> import { GlModal } from '@gitlab/ui'; -import { __, s__, sprintf } from '~/locale'; +import { __, s__, n__, sprintf } from '~/locale'; const I18N_TITLE = s__('Runners|Delete runner %{name}?'); -const I18N_BODY = s__( - 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', -); -const I18N_PRIMARY = s__('Runners|Delete runner'); +const I18N_TITLE_PLURAL = s__('Runners|Delete %{count} runners?'); const I18N_CANCEL = __('Cancel'); export default { @@ -18,10 +15,40 @@ export default { type: String, required: true, }, + managersCount: { + type: Number, + required: false, + default: 0, + }, }, computed: { + count() { + // Only show count if MORE than 1 manager, for 0 we still + // assume 1 runner that happens to be disconnected. + return this.managersCount > 1 ? this.managersCount : 1; + }, title() { - return sprintf(I18N_TITLE, { name: this.runnerName }); + if (this.count === 1) { + return sprintf(I18N_TITLE, { name: this.runnerName }); + } + return sprintf(I18N_TITLE_PLURAL, { count: this.count }); + }, + body() { + return n__( + 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + 'Runners|%d runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + this.count, + ); + }, + actionPrimary() { + return { + text: n__( + 'Runners|Permanently delete runner', + 'Runners|Permanently delete %d runners', + this.count, + ), + attributes: { variant: 'danger' }, + }; }, }, methods: { @@ -29,9 +56,7 @@ export default { this.$refs.modal.hide(); }, }, - actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } }, - actionCancel: { text: I18N_CANCEL }, - I18N_BODY, + ACTION_CANCEL: { text: I18N_CANCEL }, }; </script> @@ -40,12 +65,12 @@ export default { ref="modal" size="sm" :title="title" - :action-primary="$options.actionPrimary" - :action-cancel="$options.actionCancel" + :action-primary="actionPrimary" + :action-cancel="$options.ACTION_CANCEL" v-bind="$attrs" v-on="$listeners" @primary="onPrimary" > - {{ $options.I18N_BODY }} + {{ body }} </gl-modal> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue index 6eba8f2e49f..8c1280cffb9 100644 --- a/app/assets/javascripts/ci/runner/components/runner_details.vue +++ b/app/assets/javascripts/ci/runner/components/runner_details.vue @@ -1,20 +1,28 @@ <script> -import { GlIntersperse, GlLink } from '@gitlab/ui'; +import { GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; -import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { + ACCESS_LEVEL_REF_PROTECTED, + GROUP_TYPE, + PROJECT_TYPE, + RUNNER_MANAGERS_HELP_URL, + I18N_STATUS_NEVER_CONTACTED, +} from '../constants'; import RunnerDetail from './runner_detail.vue'; import RunnerGroups from './runner_groups.vue'; import RunnerProjects from './runner_projects.vue'; import RunnerTags from './runner_tags.vue'; +import RunnerManagersDetail from './runner_managers_detail.vue'; export default { components: { GlIntersperse, GlLink, + GlSprintf, HelpPopover, RunnerDetail, RunnerMaintenanceNoteDetail: () => @@ -26,6 +34,7 @@ export default { RunnerUpgradeStatusAlert: () => import('ee_component/ci/runner/components/runner_upgrade_status_alert.vue'), RunnerTags, + RunnerManagersDetail, TimeAgo, }, props: { @@ -76,6 +85,8 @@ export default { }, }, ACCESS_LEVEL_REF_PROTECTED, + RUNNER_MANAGERS_HELP_URL, + I18N_STATUS_NEVER_CONTACTED, }; </script> @@ -90,7 +101,7 @@ export default { <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> <runner-detail :label="s__('Runners|Last contact')" - :empty-value="s__('Runners|Never contacted')" + :empty-value="$options.I18N_STATUS_NEVER_CONTACTED" > <template v-if="runner.contactedAt" #value> <time-ago :time="runner.contactedAt" /> @@ -150,6 +161,33 @@ export default { class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid" :value="runner.maintenanceNoteHtml" /> + + <runner-detail> + <template #label> + {{ s__('Runners|Runners') }} + <help-popover> + <gl-sprintf + :message=" + s__( + 'Runners|Runners are grouped when they have the same authentication token. This happens when you re-use a runner configuration in more than one runner manager. %{linkStart}How does this work?%{linkEnd}', + ) + " + > + <template #link="{ content }" + ><gl-link + :href="$options.RUNNER_MANAGERS_HELP_URL" + target="_blank" + class="gl-reset-font-size" + >{{ content }}</gl-link + ></template + > + </gl-sprintf> + </help-popover> + </template> + <template #value> + <runner-managers-detail :runner="runner" /> + </template> + </runner-detail> </dl> </div> diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue index e37ac5e6e26..d090a562ff7 100644 --- a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue +++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue @@ -1,7 +1,16 @@ <script> -import { GlFormGroup, GlFormCheckbox, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlIcon, + GlLink, + GlSprintf, + GlSkeletonLoader, +} from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; export default { name: 'RunnerFormFields', @@ -9,8 +18,10 @@ export default { GlFormGroup, GlFormCheckbox, GlFormInput, + GlIcon, GlLink, GlSprintf, + GlSkeletonLoader, RunnerMaintenanceNoteField: () => import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), }, @@ -20,15 +31,32 @@ export default { default: null, required: false, }, + loading: { + type: Boolean, + default: false, + required: false, + }, }, data() { return { - model: { - ...this.value, - }, + model: null, }; }, + computed: { + canBeLockedToProject() { + return this.value?.runnerType === PROJECT_TYPE; + }, + }, watch: { + value: { + handler(newVal, oldVal) { + // update only when values change, avoids infinite loop + if (!isEqual(newVal, oldVal)) { + this.model = { ...newVal }; + } + }, + immediate: true, + }, model: { handler() { this.$emit('input', this.model); @@ -45,96 +73,122 @@ export default { </script> <template> <div> - <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + <h2 class="gl-font-size-h2 gl-my-5"> + {{ s__('Runners|Tags') }} + </h2> + <gl-skeleton-loader v-if="loading" :lines="12" /> + <template v-else-if="model"> + <gl-form-group :label="__('Tags')" label-for="runner-tags"> + <template #description> + <gl-sprintf + :message=" + s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.') + " + > + <template #example> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>macos, shared</code> + </template> + </gl-sprintf> + </template> + <template #label-description> + <gl-sprintf + :message=" + s__( + 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}', + ) + " + > + <template #helpLink="{ content }"> + <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </template> + <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" /> + </gl-form-group> + <gl-form-checkbox v-model="model.runUntagged" name="run-untagged"> + {{ __('Run untagged jobs') }} + <template #help> + {{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }} + </template> + </gl-form-checkbox> + </template> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-size-h2 gl-my-5"> {{ s__('Runners|Details') }} {{ __('(optional)') }} </h2> - <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description"> - <gl-form-input id="runner-description" v-model="model.description" name="description" /> - </gl-form-group> - <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" /> + <gl-skeleton-loader v-if="loading" :lines="15" /> + <template v-else-if="model"> + <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description"> + <gl-form-input id="runner-description" v-model="model.description" name="description" /> + </gl-form-group> + <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" /> + </template> <hr aria-hidden="true" /> - <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + <h2 class="gl-font-size-h2 gl-my-5"> {{ s__('Runners|Configuration') }} {{ __('(optional)') }} </h2> - <div class="gl-mb-5"> - <gl-form-checkbox v-model="model.paused" name="paused"> - {{ __('Paused') }} - <template #help> - {{ s__('Runners|Stop the runner from accepting new jobs.') }} - </template> - </gl-form-checkbox> - - <gl-form-checkbox - v-model="model.accessLevel" - name="protected" - :value="$options.ACCESS_LEVEL_REF_PROTECTED" - :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" - > - {{ __('Protected') }} - <template #help> - {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} - </template> - </gl-form-checkbox> - - <gl-form-checkbox v-model="model.runUntagged" name="run-untagged"> - {{ __('Run untagged jobs') }} - <template #help> - {{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }} - </template> - </gl-form-checkbox> - </div> + <gl-skeleton-loader v-if="loading" :lines="15" /> + <template v-else-if="model"> + <div class="gl-mb-5"> + <gl-form-checkbox v-model="model.paused" name="paused"> + {{ __('Paused') }} + <template #help> + {{ s__('Runners|Stop the runner from accepting new jobs.') }} + </template> + </gl-form-checkbox> - <gl-form-group :label="__('Tags')" label-for="runner-tags"> - <template #description> - <gl-sprintf - :message=" - s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.') - " + <gl-form-checkbox + v-model="model.accessLevel" + name="protected" + :value="$options.ACCESS_LEVEL_REF_PROTECTED" + :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" > - <template #example> - <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> - <code>macos, shared</code> + {{ __('Protected') }} + <template #help> + {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} </template> - </gl-sprintf> - </template> - <template #label-description> - <gl-sprintf - :message=" - s__( - 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}', - ) - " - > - <template #helpLink="{ content }"> - <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{ content }}</gl-link> + </gl-form-checkbox> + + <gl-form-checkbox v-if="canBeLockedToProject" v-model="model.locked" name="locked"> + {{ __('Lock to current projects') }} <gl-icon name="lock" /> + <template #help> + {{ + s__( + 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', + ) + }} </template> - </gl-sprintf> - </template> - <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" /> - </gl-form-group> + </gl-form-checkbox> + </div> - <gl-form-group - :label="__('Maximum job timeout')" - :label-description=" - s__( - 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.', - ) - " - label-for="runner-max-timeout" - :description="s__('Runners|Enter the number of seconds.')" - > - <gl-form-input - id="runner-max-timeout" - v-model.number="model.maximumTimeout" - name="max-timeout" - type="number" - /> - </gl-form-group> + <gl-form-group + :label="__('Maximum job timeout')" + :label-description=" + s__( + 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.', + ) + " + label-for="runner-max-timeout" + :description="s__('Runners|Enter the number of seconds.')" + > + <gl-form-input + id="runner-max-timeout" + v-model.number="model.maximumTimeout" + name="max-timeout" + type="number" + /> + </gl-form-group> + </template> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue index 874c234ca4c..f46e894bf2e 100644 --- a/app/assets/javascripts/ci/runner/components/runner_header.vue +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -1,9 +1,8 @@ <script> import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; +import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; +import { formatRunnerName } from '../utils'; import RunnerTypeBadge from './runner_type_badge.vue'; import RunnerStatusBadge from './runner_status_badge.vue'; @@ -25,12 +24,8 @@ export default { }, }, computed: { - paused() { - return !this.runner.active; - }, - heading() { - const id = getIdFromGraphQLId(this.runner.id); - return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); + name() { + return formatRunnerName(this.runner); }, }, I18N_LOCKED_RUNNER_DESCRIPTION, @@ -38,16 +33,16 @@ export default { </script> <template> <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-py-5" > - <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap"> - <runner-status-badge :runner="runner" /> - <runner-type-badge v-if="runner" :type="runner.runnerType" /> - <span> - <template v-if="runner.createdAt"> - <gl-sprintf :message="__('%{runner} created %{timeago}')"> - <template #runner> - <strong>{{ heading }}</strong> + <div> + <h1 class="gl-font-size-h-display gl-my-0">{{ name }}</h1> + <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3"> + <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> + <runner-type-badge :type="runner.runnerType" /> + <span v-if="runner.createdAt"> + <gl-sprintf :message="__('%{locked} created %{timeago}')"> + <template #locked> <gl-icon v-if="runner.locked" v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" @@ -59,11 +54,8 @@ export default { <time-ago :time="runner.createdAt" /> </template> </gl-sprintf> - </template> - <template v-else> - <strong>{{ heading }}</strong> - </template> - </span> + </span> + </div> </div> <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div> </div> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue index c30a824120d..4e68c2ea71a 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue @@ -1,5 +1,5 @@ <script> -import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url'; +import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; @@ -19,7 +19,11 @@ export default { </script> <template> - <gl-empty-state :svg-path="$options.EMPTY_STATE_SVG_URL" :title="$options.i18n.title"> + <gl-empty-state + :svg-path="$options.EMPTY_STATE_SVG_URL" + :svg-height="150" + :title="$options.i18n.title" + > <template #description> <p>{{ $options.i18n.description }}</p> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue index 8606c22db34..d2836962a97 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue @@ -1,10 +1,20 @@ <script> -import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url'; -import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url'; +import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; +import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url'; import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import { + I18N_GET_STARTED, + I18N_RUNNERS_ARE_AGENTS, + I18N_CREATE_RUNNER_LINK, + I18N_STILL_USING_REGISTRATION_TOKENS, + I18N_CONTACT_ADMIN_TO_REGISTER, + I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, + I18N_NO_RESULTS, + I18N_EDIT_YOUR_SEARCH, +} from '~/ci/runner/constants'; export default { components: { @@ -38,9 +48,8 @@ export default { shouldShowCreateRunnerWorkflow() { // create_runner_workflow_for_admin or create_runner_workflow_for_namespace return ( - this.newRunnerPath && - (this.glFeatures?.createRunnerWorkflowForAdmin || - this.glFeatures?.createRunnerWorkflowForNamespace) + this.glFeatures?.createRunnerWorkflowForAdmin || + this.glFeatures?.createRunnerWorkflowForNamespace ); }, }, @@ -48,35 +57,59 @@ export default { svgHeight: 145, EMPTY_STATE_SVG_URL, FILTERED_SVG_URL, + + I18N_GET_STARTED, + I18N_RUNNERS_ARE_AGENTS, + I18N_CREATE_RUNNER_LINK, + I18N_STILL_USING_REGISTRATION_TOKENS, + I18N_CONTACT_ADMIN_TO_REGISTER, + I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, + I18N_NO_RESULTS, + I18N_EDIT_YOUR_SEARCH, }; </script> <template> <gl-empty-state v-if="isSearchFiltered" - :title="s__('Runners|No results found')" + :title="$options.I18N_NO_RESULTS" :svg-path="$options.FILTERED_SVG_URL" :svg-height="$options.svgHeight" - :description="s__('Runners|Edit your search and try again')" + :description="$options.I18N_EDIT_YOUR_SEARCH" /> <gl-empty-state v-else - :title="s__('Runners|Get started with runners')" + :title="$options.I18N_GET_STARTED" :svg-path="$options.EMPTY_STATE_SVG_URL" :svg-height="$options.svgHeight" > - <template v-if="registrationToken" #description> + <template #description> + {{ $options.I18N_RUNNERS_ARE_AGENTS }} + <template v-if="shouldShowCreateRunnerWorkflow"> + <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> + <template #link="{ content }"> + <gl-link :href="newRunnerPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + <template v-if="registrationToken"> + <br /> + <gl-link v-gl-modal="$options.modalId">{{ + $options.I18N_STILL_USING_REGISTRATION_TOKENS + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> + </template> + <template v-if="!newRunnerPath && !registrationToken"> + {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} + </template> + </template> <gl-sprintf - :message=" - s__( - 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', - ) - " + v-else-if="registrationToken" + :message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS" > - <template v-if="shouldShowCreateRunnerWorkflow" #link="{ content }"> - <gl-link :href="newRunnerPath">{{ content }}</gl-link> - </template> - <template v-else #link="{ content }"> + <template #link="{ content }"> <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> <runner-instructions-modal :modal-id="$options.modalId" @@ -84,13 +117,9 @@ export default { /> </template> </gl-sprintf> - </template> - <template v-else #description> - {{ - s__( - 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', - ) - }} + <template v-else> + {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} + </template> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_badge.vue b/app/assets/javascripts/ci/runner/components/runner_managers_badge.vue new file mode 100644 index 00000000000..d298d8ded82 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_managers_badge.vue @@ -0,0 +1,47 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { formatNumber, s__, sprintf } from '~/locale'; + +export default { + name: 'RunnerManagersBadge', + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + count: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + shouldShowBadge() { + // runner managers can be grouped, but this information is only shown + // when we have 2 or more. + return this.count >= 2; + }, + formattedCount() { + return formatNumber(this.count); + }, + tooltip() { + return sprintf(s__('Runners|%{count} runners in this group'), { + count: this.formattedCount, + }); + }, + }, +}; +</script> +<template> + <gl-badge + v-if="shouldShowBadge" + v-gl-tooltip="tooltip" + variant="muted" + icon="container-image" + v-bind="$attrs" + > + {{ formattedCount }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_detail.vue b/app/assets/javascripts/ci/runner/components/runner_managers_detail.vue new file mode 100644 index 00000000000..5cc1bbef481 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_managers_detail.vue @@ -0,0 +1,111 @@ +<script> +import { GlCollapse, GlButton, GlIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { __, s__, formatNumber } from '~/locale'; +import { createAlert } from '~/alert'; +import runnerManagersQuery from '../graphql/show/runner_managers.query.graphql'; +import { I18N_FETCH_ERROR } from '../constants'; +import { captureException } from '../sentry_utils'; +import { tableField } from '../utils'; +import RunnerManagersTable from './runner_managers_table.vue'; + +export default { + name: 'RunnerManagersDetail', + components: { + GlCollapse, + GlButton, + GlIcon, + GlSkeletonLoader, + RunnerManagersTable, + }, + props: { + runner: { + type: Object, + required: true, + validator: (runner) => { + return Boolean(runner?.id); + }, + }, + }, + data() { + return { + skip: true, + expanded: false, + managers: [], + }; + }, + apollo: { + managers: { + query: runnerManagersQuery, + skip() { + return this.skip; + }, + variables() { + return { runnerId: this.runner.id }; + }, + update({ runner }) { + return runner?.managers?.nodes || []; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + captureException({ error, component: this.$options.name }); + }, + }, + }, + computed: { + runnerManagersCount() { + return this.runner?.managers?.count || 0; + }, + runnerManagersCountFormatted() { + return formatNumber(this.runnerManagersCount); + }, + icon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + text() { + return this.expanded ? __('Hide details') : __('Show details'); + }, + loading() { + return this.$apollo?.queries.managers.loading; + }, + }, + methods: { + fetchManagers() { + this.skip = false; + }, + toggleExpanded() { + this.expanded = !this.expanded; + }, + }, + fields: [ + tableField({ key: 'systemId', label: s__('Runners|System ID') }), + tableField({ + key: 'contactedAt', + label: s__('Runners|Last contact'), + tdClass: ['gl-text-right'], + thClasses: ['gl-text-right'], + }), + ], +}; +</script> + +<template> + <div> + <gl-icon name="container-image" class="gl-text-secondary" /> + {{ runnerManagersCountFormatted }} + <gl-button + v-if="runnerManagersCount" + variant="link" + @mouseover.once="fetchManagers" + @focus.once="fetchManagers" + @click.once="fetchManagers" + @click="toggleExpanded" + > + <gl-icon :name="icon" /> {{ text }} + </gl-button> + + <gl-collapse :visible="expanded" class="gl-mt-5"> + <gl-skeleton-loader v-if="loading" /> + <runner-managers-table v-else-if="managers.length" :items="managers" /> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_table.vue b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue new file mode 100644 index 00000000000..10790c398b0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue @@ -0,0 +1,75 @@ +<script> +import { GlIntersperse, GlTableLite } from '@gitlab/ui'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { s__ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { tableField } from '../utils'; +import { I18N_STATUS_NEVER_CONTACTED } from '../constants'; +import RunnerStatusBadge from './runner_status_badge.vue'; + +export default { + name: 'RunnerManagersTable', + components: { + GlTableLite, + TimeAgo, + HelpPopover, + GlIntersperse, + RunnerStatusBadge, + RunnerUpgradeStatusIcon: () => + import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), + }, + props: { + items: { + type: Array, + required: false, + default: () => [], + }, + }, + fields: [ + tableField({ key: 'systemId', label: s__('Runners|System ID') }), + tableField({ key: 'status', label: s__('Runners|Status') }), + tableField({ key: 'version', label: s__('Runners|Version') }), + tableField({ key: 'ipAddress', label: s__('Runners|IP Address') }), + tableField({ key: 'executorName', label: s__('Runners|Executor') }), + tableField({ key: 'architecturePlatform', label: s__('Runners|Arch/Platform') }), + tableField({ + key: 'contactedAt', + label: s__('Runners|Last contact'), + tdClass: ['gl-text-right'], + thClasses: ['gl-text-right'], + }), + ], + I18N_STATUS_NEVER_CONTACTED, +}; +</script> + +<template> + <gl-table-lite :fields="$options.fields" :items="items"> + <template #head(systemId)="{ label }"> + {{ label }} + <help-popover> + {{ s__('Runners|The unique ID for each runner that uses this configuration.') }} + </help-popover> + </template> + <template #cell(status)="{ item = {} }"> + <runner-status-badge :contacted-at="item.contactedAt" :status="item.status" /> + </template> + <template #cell(version)="{ item = {} }"> + {{ item.version }} + <template v-if="item.revision">({{ item.revision }})</template> + <runner-upgrade-status-icon :upgrade-status="item.upgradeStatus" /> + </template> + <template #cell(architecturePlatform)="{ item = {} }"> + <gl-intersperse separator="/"> + <span v-if="item.architectureName">{{ item.architectureName }}</span> + <span v-if="item.platformName">{{ item.platformName }}</span> + </gl-intersperse> + </template> + <template #cell(contactedAt)="{ item = {} }"> + <template v-if="item.contactedAt"> + <time-ago :time="item.contactedAt" /> + </template> + <template v-else>{{ $options.I18N_STATUS_NEVER_CONTACTED }}</template> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_name.vue b/app/assets/javascripts/ci/runner/components/runner_name.vue index d4ecfd2d776..a877ff0f06c 100644 --- a/app/assets/javascripts/ci/runner/components/runner_name.vue +++ b/app/assets/javascripts/ci/runner/components/runner_name.vue @@ -1,5 +1,5 @@ <script> -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { formatRunnerName } from '../utils'; export default { props: { @@ -8,13 +8,13 @@ export default { required: true, }, }, - methods: { - getIdFromGraphQLId, + computed: { + name() { + return formatRunnerName(this.runner); + }, }, }; </script> <template> - <span class="gl-font-weight-bold gl-vertical-align-middle" - >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span - > + <span class="gl-font-weight-bold gl-vertical-align-middle">{{ name }}</span> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue index a27af232e97..d16c8f98bad 100644 --- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql'; +import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; import { createAlert } from '~/alert'; import { captureException } from '~/ci/runner/sentry_utils'; import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants'; @@ -31,14 +31,14 @@ export default { }; }, computed: { - isActive() { - return this.runner.active; + isPaused() { + return this.runner.paused; }, icon() { - return this.isActive ? 'pause' : 'play'; + return this.isPaused ? 'play' : 'pause'; }, label() { - return this.isActive ? I18N_PAUSE : I18N_RESUME; + return this.isPaused ? I18N_RESUME : I18N_PAUSE; }, buttonContent() { if (this.compact) { @@ -56,7 +56,7 @@ export default { // Prevent a "sticky" tooltip: If this button is disabled, // mouseout listeners don't run leaving the tooltip stuck if (!this.updating) { - return this.isActive ? I18N_PAUSE_TOOLTIP : I18N_RESUME_TOOLTIP; + return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP; } return ''; }, @@ -67,7 +67,7 @@ export default { try { const input = { id: this.runner.id, - active: !this.isActive, + paused: !this.isPaused, }; const { @@ -75,7 +75,7 @@ export default { runnerUpdate: { errors }, }, } = await this.$apollo.mutate({ - mutation: runnerToggleActiveMutation, + mutation: runnerTogglePausedMutation, variables: { input, }, diff --git a/app/assets/javascripts/ci/runner/components/runner_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue index d084408781e..c2c52bd756a 100644 --- a/app/assets/javascripts/ci/runner/components/runner_status_badge.vue +++ b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue @@ -26,21 +26,27 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - runner: { - required: true, - type: Object, + contactedAt: { + type: String, + required: false, + default: null, + }, + status: { + type: String, + required: false, + default: null, }, }, computed: { contactedAtTimeAgo() { - if (this.runner.contactedAt) { - return getTimeago().format(this.runner.contactedAt); + if (this.contactedAt) { + return getTimeago().format(this.contactedAt); } // Prevent "just now" from being rendered, in case data is missing. return __('never'); }, badge() { - switch (this.runner?.status) { + switch (this.status) { case STATUS_ONLINE: return { icon: 'status-active', @@ -68,7 +74,7 @@ export default { variant: 'warning', label: I18N_STATUS_STALE, // runner may have contacted (or not) and be stale: consider both cases. - tooltip: this.runner.contactedAt + tooltip: this.contactedAt ? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP) : I18N_STALE_NEVER_CONTACTED_TOOLTIP, }; diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue index 2d34c551d6d..6b94e594f1c 100644 --- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue @@ -1,23 +1,16 @@ <script> -import { - GlButton, - GlIcon, - GlForm, - GlFormCheckbox, - GlFormGroup, - GlFormInputGroup, - GlSkeletonLoader, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlButton, GlForm } from '@gitlab/ui'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; + import { modelToUpdateMutationVariables, runnerToModel, } from 'ee_else_ce/ci/runner/runner_update_form_utils'; -import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { __ } from '~/locale'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants'; import runnerUpdateMutation from '../graphql/edit/runner_update.mutation.graphql'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; @@ -25,20 +18,11 @@ export default { name: 'RunnerUpdateForm', components: { GlButton, - GlIcon, GlForm, - GlFormCheckbox, - GlFormGroup, - GlFormInputGroup, - GlSkeletonLoader, - RunnerMaintenanceNoteField: () => - import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), + RunnerFormFields, RunnerUpdateCostFactorFields: () => import('ee_component/ci/runner/components/runner_update_cost_factor_fields.vue'), }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { runner: { type: Object, @@ -59,19 +43,17 @@ export default { data() { return { saving: false, - model: runnerToModel(this.runner), + model: null, }; }, computed: { - canBeLockedToProject() { - return this.runner?.runnerType === PROJECT_TYPE; + runnerType() { + return this.runner?.runnerType; }, }, watch: { - runner(newVal, oldVal) { - if (oldVal === null) { - this.model = runnerToModel(newVal); - } + runner(val) { + this.model = runnerToModel(val); }, }, methods: { @@ -101,7 +83,7 @@ export default { }, onSuccess() { saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); - redirectTo(this.runnerPath); // eslint-disable-line import/no-deprecated + visitUrl(this.runnerPath); }, onError(message) { this.saving = false; @@ -114,99 +96,8 @@ export default { </script> <template> <gl-form @submit.prevent="onSubmit"> - <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Details') }}</h4> - - <gl-skeleton-loader v-if="loading" /> - - <template v-else> - <gl-form-group :label="__('Description')" data-testid="runner-field-description"> - <gl-form-input-group v-model="model.description" /> - </gl-form-group> - <runner-maintenance-note-field v-model="model.maintenanceNote" /> - </template> - - <hr /> - - <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Configuration') }}</h4> - - <template v-if="loading"> - <gl-skeleton-loader v-for="i in 3" :key="i" /> - </template> - <template v-else> - <div class="gl-mb-5"> - <gl-form-checkbox - v-model="model.active" - data-testid="runner-field-paused" - :value="false" - :unchecked-value="true" - > - {{ __('Paused') }} - <template #help> - {{ s__('Runners|Stop the runner from accepting new jobs.') }} - </template> - </gl-form-checkbox> - - <gl-form-checkbox - v-model="model.accessLevel" - data-testid="runner-field-protected" - :value="$options.ACCESS_LEVEL_REF_PROTECTED" - :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" - > - {{ __('Protected') }} - <template #help> - {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} - </template> - </gl-form-checkbox> - - <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> - {{ __('Run untagged jobs') }} - <template #help> - {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }} - </template> - </gl-form-checkbox> - - <gl-form-checkbox - v-if="canBeLockedToProject" - v-model="model.locked" - data-testid="runner-field-locked" - > - {{ __('Lock to current projects') }} <gl-icon name="lock" /> - <template #help> - {{ - s__( - 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', - ) - }} - </template> - </gl-form-checkbox> - </div> - - <gl-form-group - data-testid="runner-field-max-timeout" - :label="__('Maximum job timeout')" - :description=" - s__( - 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.', - ) - " - > - <gl-form-input-group v-model.number="model.maximumTimeout" type="number" /> - </gl-form-group> - - <gl-form-group - data-testid="runner-field-tags" - :label="__('Tags')" - :description=" - __( - 'You can set up jobs to only use runners with specific tags. Separate tags with commas.', - ) - " - > - <gl-form-input-group v-model="model.tagList" /> - </gl-form-group> - - <runner-update-cost-factor-fields v-model="model" /> - </template> + <runner-form-fields v-model="model" :loading="loading" /> + <runner-update-cost-factor-fields v-model="model" :runner-type="runnerType" /> <div class="gl-mt-6"> <gl-button diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 4e36a410a66..40841696ead 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -9,7 +9,9 @@ export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5; export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); -export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); +export const I18N_CREATE_ERROR = s__( + 'Runners|An error occurred while creating the runner. Please try again.', +); export const FILTER_CSS_CLASSES = 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1'; @@ -103,6 +105,26 @@ export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{ava export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited'); export const I18N_ADMIN = s__('Runners|Administrator'); +// No runners registered +export const I18N_GET_STARTED = s__('Runners|Get started with runners'); +export const I18N_RUNNERS_ARE_AGENTS = s__( + 'Runners|Runners are the agents that run your CI/CD jobs.', +); +export const I18N_CREATE_RUNNER_LINK = s__( + 'Runners|%{linkStart}Create a new runner%{linkEnd} to get started.', +); +export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using registration tokens?'); +export const I18N_CONTACT_ADMIN_TO_REGISTER = s__( + 'Runners|To register new runners, contact your administrator.', +); +export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__( + 'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', +); + +// No runners found +export const I18N_NO_RESULTS = s__('Runners|No results found'); +export const I18N_EDIT_YOUR_SEARCH = s__('Runners|Edit your search and try again'); + // Runner details export const JOBS_ROUTE_PATH = '/jobs'; // vue-router route path @@ -256,3 +278,5 @@ export const SERVICE_COMMANDS_HELP_URL = export const CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md'; export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html'; export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html'; +export const RUNNER_MANAGERS_HELP_URL = + 'https://docs.gitlab.com/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities'; diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql index d18b80511fb..41ec9967d90 100644 --- a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql @@ -2,7 +2,7 @@ fragment RunnerFieldsShared on CiRunner { id shortSha runnerType - active + paused accessLevel runUntagged locked 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 4eebcd01be6..c0b888e758b 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 @@ -7,7 +7,7 @@ fragment ListItemShared on CiRunner { shortSha version ipAddress - active + paused locked jobCount tagList @@ -22,6 +22,9 @@ fragment ListItemShared on CiRunner { updateRunner deleteRunner } + managers { + count + } groups(first: 1) { nodes { id diff --git a/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql index 9b15570dbc0..e862a20750f 100644 --- a/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql +++ b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql @@ -1,11 +1,11 @@ # Mutation executed for the pause/resume button in the # runner list and details views. -mutation runnerToggleActive($input: RunnerUpdateInput!) { +mutation runnerTogglePaused($input: RunnerUpdateInput!) { runnerUpdate(input: $input) { runner { id - active + paused } errors } diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql index bd53fb29bd0..1a2ad59650e 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql @@ -2,7 +2,7 @@ fragment RunnerDetailsShared on CiRunner { id shortSha runnerType - active + paused accessLevel runUntagged locked @@ -20,6 +20,9 @@ fragment RunnerDetailsShared on CiRunner { tokenExpiresAt version editAdminUrl + managers { + count + } userPermissions { updateRunner deleteRunner diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql new file mode 100644 index 00000000000..b630786b3d5 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql @@ -0,0 +1,5 @@ +#import "./runner_manager_shared.fragment.graphql" + +fragment CiRunnerManager on CiRunnerManager { + ...CiRunnerManagerShared +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql new file mode 100644 index 00000000000..ead005d1252 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql @@ -0,0 +1,12 @@ +fragment CiRunnerManagerShared on CiRunnerManager { + id + systemId + status + version + revision + executorName + architectureName + platformName + ipAddress + contactedAt +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql new file mode 100644 index 00000000000..cc16267e619 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/ci/runner/graphql/show/runner_manager.fragment.graphql" + +query getRunnerManagers($runnerId: CiRunnerID!) { + runner(id: $runnerId) { + id + managers { + count + nodes { + ...CiRunnerManager + } + } + } +} diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue index 67d29daf66f..2e1706ddae9 100644 --- a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue @@ -1,6 +1,6 @@ <script> import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; @@ -38,7 +38,7 @@ export default { message: s__('Runners|Runner created.'), variant: VARIANT_SUCCESS, }); - redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated + visitUrl(ephemeralRegisterUrl); }, onError(error) { createAlert({ message: error.message }); @@ -66,7 +66,7 @@ export default { <hr aria-hidden="true" /> - <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + <h2 class="gl-font-size-h2 gl-my-5"> {{ s__('Runners|Platform') }} </h2> <runner-platforms-radio-group v-model="platform" /> diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue index 1318bf5a2e6..e885cf45c5a 100644 --- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue @@ -2,7 +2,7 @@ import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; @@ -76,7 +76,7 @@ export default { }, onDeleted({ message }) { saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS }); - redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated + visitUrl(this.runnersPath); }, }, }; diff --git a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue index f0ae54c0232..51f5a9ce8d9 100644 --- a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue @@ -1,6 +1,6 @@ <script> import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; @@ -38,7 +38,7 @@ export default { message: s__('Runners|Runner created.'), variant: VARIANT_SUCCESS, }); - redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated + visitUrl(ephemeralRegisterUrl); }, onError(error) { createAlert({ message: error.message }); @@ -66,7 +66,7 @@ export default { <hr aria-hidden="true" /> - <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + <h2 class="gl-font-size-h2 gl-my-5"> {{ s__('Runners|Platform') }} </h2> <runner-platforms-radio-group v-model="platform" /> diff --git a/app/assets/javascripts/ci/runner/runner_update_form_utils.js b/app/assets/javascripts/ci/runner/runner_update_form_utils.js index 3b519fa7d71..6f6c9f64af0 100644 --- a/app/assets/javascripts/ci/runner/runner_update_form_utils.js +++ b/app/assets/javascripts/ci/runner/runner_update_form_utils.js @@ -4,7 +4,7 @@ export const runnerToModel = (runner) => { description, maximumTimeout, accessLevel, - active, + paused, locked, runUntagged, tagList = [], @@ -15,7 +15,7 @@ export const runnerToModel = (runner) => { description, maximumTimeout, accessLevel, - active, + paused, locked, runUntagged, tagList: tagList.join(', '), diff --git a/app/assets/javascripts/ci/runner/utils.js b/app/assets/javascripts/ci/runner/utils.js index 1ca0a9e86b5..bb1ffca62ee 100644 --- a/app/assets/javascripts/ci/runner/utils.js +++ b/app/assets/javascripts/ci/runner/utils.js @@ -1,4 +1,5 @@ import { formatNumber } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { RUNNER_JOB_COUNT_LIMIT } from './constants'; /** @@ -81,3 +82,13 @@ export const getPaginationVariables = (pagination, pageSize = 10) => { export const parseInterval = (interval) => { return typeof interval === 'string' ? parseInt(interval, 10) : null; }; + +/** + * Creates formatted runner name + * + * @param {Object} runner - Runner object + * @returns Formatted name + */ +export const formatRunnerName = ({ id, shortSha }) => { + return `#${getIdFromGraphQLId(id)} (${shortSha})`; +}; diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index d7e98638a11..529be7169db 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -7,6 +7,8 @@ import { GlTooltip, GlTooltipDirective, GlPopover, + GlBadge, + GlPagination, } from '@gitlab/ui'; import semverLt from 'semver/functions/lt'; import semverInc from 'semver/functions/inc'; @@ -15,7 +17,7 @@ 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 { MAX_LIST_COUNT, AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants'; import { getAgentConfigPath } from '../clusters_util'; import DeleteAgentButton from './delete_agent_button.vue'; @@ -28,6 +30,8 @@ export default { GlSprintf, GlTooltip, GlPopover, + GlBadge, + GlPagination, TimeAgoTooltip, DeleteAgentButton, }, @@ -60,6 +64,12 @@ export default { type: Number, }, }, + data() { + return { + currentPage: 1, + limit: this.maxAgents ?? MAX_LIST_COUNT, + }; + }, computed: { fields() { const tdClass = 'gl-pt-3! gl-pb-4! gl-vertical-align-middle!'; @@ -114,6 +124,16 @@ export default { serverVersion() { return this.kasVersion || this.gitlabVersion; }, + showPagination() { + return !this.maxAgents && this.agents.length > this.limit; + }, + prevPage() { + return Math.max(this.currentPage - 1, 0); + }, + nextPage() { + const nextPage = this.currentPage + 1; + return nextPage > Math.ceil(this.agents.length / this.limit) ? null : nextPage; + }, }, methods: { getStatusCellId(item) { @@ -184,84 +204,105 @@ export default { </script> <template> - <gl-table - :items="agentsList" - :fields="fields" - stacked="md" - class="gl-mb-4!" - data-testid="cluster-agent-list-table" - > - <template #cell(name)="{ item }"> - <gl-link :href="item.webPath" data-testid="cluster-agent-name-link"> - {{ item.name }} - </gl-link> - </template> + <div> + <gl-table + :items="agentsList" + :fields="fields" + :per-page="limit" + :current-page="currentPage" + stacked="md" + class="gl-mb-4!" + data-testid="cluster-agent-list-table" + > + <template #cell(name)="{ item }"> + <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">{{ item.name }}</gl-link + ><gl-badge v-if="item.isShared" class="gl-ml-3">{{ + $options.i18n.sharedBadgeText + }}</gl-badge> + </template> - <template #cell(status)="{ item }"> - <span - :id="getStatusCellId(item)" - class="gl-md-pr-5" - data-testid="cluster-agent-connection-status" - > - <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> - <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span - >{{ $options.AGENT_STATUSES[item.status].name }} - </span> - <gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right"> - <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title" - ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template> - </gl-sprintf> - </gl-tooltip> - <gl-popover - v-else - :target="getStatusCellId(item)" - :title="$options.AGENT_STATUSES[item.status].tooltip.title" - placement="right" - container="viewport" - > - <p> - <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body" - ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf - > - </p> - <p class="gl-mb-0"> - <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm"> - {{ $options.i18n.troubleshootingText }}</gl-link - > - </p> - </gl-popover> - </template> + <template #cell(status)="{ item }"> + <span + :id="getStatusCellId(item)" + class="gl-md-pr-5" + data-testid="cluster-agent-connection-status" + > + <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> + <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span + >{{ $options.AGENT_STATUSES[item.status].name }} + </span> + <gl-tooltip + v-if="item.status === 'active'" + :target="getStatusCellId(item)" + placement="right" + > + <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title" + ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template> + </gl-sprintf> + </gl-tooltip> + <gl-popover + v-else + :target="getStatusCellId(item)" + :title="$options.AGENT_STATUSES[item.status].tooltip.title" + placement="right" + container="viewport" + > + <p> + <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body" + ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf + > + </p> + <p class="gl-mb-0"> + <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm"> + {{ $options.i18n.troubleshootingText }}</gl-link + > + </p> + </gl-popover> + </template> + + <template #cell(lastContact)="{ item }"> + <span data-testid="cluster-agent-last-contact"> + <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" /> + <span v-else>{{ $options.i18n.neverConnectedText }}</span> + </span> + </template> - <template #cell(lastContact)="{ item }"> - <span data-testid="cluster-agent-last-contact"> - <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" /> - <span v-else>{{ $options.i18n.neverConnectedText }}</span> - </span> - </template> + <template #cell(version)="{ item }"> + <span :id="getVersionCellId(item)" data-testid="cluster-agent-version"> + {{ getAgentVersionString(item) }} - <template #cell(version)="{ item }"> - <span :id="getVersionCellId(item)" data-testid="cluster-agent-version"> - {{ getAgentVersionString(item) }} + <gl-icon + v-if="isVersionMismatch(item) || isVersionOutdated(item)" + name="warning" + class="gl-text-orange-500 gl-ml-2" + /> + </span> - <gl-icon + <gl-popover v-if="isVersionMismatch(item) || isVersionOutdated(item)" - name="warning" - class="gl-text-orange-500 gl-ml-2" - /> - </span> + :target="getVersionCellId(item)" + :title="getVersionPopoverTitle(item)" + :data-testid="getPopoverTestId(item)" + placement="right" + container="viewport" + > + <div v-if="isVersionMismatch(item) && isVersionOutdated(item)"> + <p>{{ $options.i18n.versionMismatchText }}</p> - <gl-popover - v-if="isVersionMismatch(item) || isVersionOutdated(item)" - :target="getVersionCellId(item)" - :title="getVersionPopoverTitle(item)" - :data-testid="getPopoverTestId(item)" - placement="right" - container="viewport" - > - <div v-if="isVersionMismatch(item) && isVersionOutdated(item)"> - <p>{{ $options.i18n.versionMismatchText }}</p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.versionOutdatedText"> + <template #version>{{ serverVersion }}</template> + </gl-sprintf> + <gl-link :href="$options.versionUpdateLink" class="gl-font-sm"> + {{ $options.i18n.viewDocsText }}</gl-link + > + </p> + </div> + <p v-else-if="isVersionMismatch(item)" class="gl-mb-0"> + {{ $options.i18n.versionMismatchText }} + </p> - <p class="gl-mb-0"> + <p v-else-if="isVersionOutdated(item)" class="gl-mb-0"> <gl-sprintf :message="$options.i18n.versionOutdatedText"> <template #version>{{ serverVersion }}</template> </gl-sprintf> @@ -269,53 +310,54 @@ export default { {{ $options.i18n.viewDocsText }}</gl-link > </p> - </div> - <p v-else-if="isVersionMismatch(item)" class="gl-mb-0"> - {{ $options.i18n.versionMismatchText }} - </p> + </gl-popover> + </template> - <p v-else-if="isVersionOutdated(item)" class="gl-mb-0"> - <gl-sprintf :message="$options.i18n.versionOutdatedText"> - <template #version>{{ serverVersion }}</template> - </gl-sprintf> - <gl-link :href="$options.versionUpdateLink" class="gl-font-sm"> - {{ $options.i18n.viewDocsText }}</gl-link - > - </p> - </gl-popover> - </template> + <template #cell(agentID)="{ item }"> + <span data-testid="cluster-agent-id"> + {{ getAgentId(item) }} + </span> + </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"> + {{ getAgentConfigPath(item.name) }} + </gl-link> - <template #cell(configuration)="{ item }"> - <span data-testid="cluster-agent-configuration-link"> - <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> - {{ getAgentConfigPath(item.name) }} - </gl-link> + <span v-else-if="item.isShared"> + {{ $options.i18n.externalConfigText }} + </span> - <span v-else - >{{ $options.i18n.defaultConfigText }} - <gl-link - v-gl-tooltip - :href="$options.configHelpLink" - :title="$options.i18n.defaultConfigTooltip" - :aria-label="$options.i18n.defaultConfigTooltip" - class="gl-vertical-align-middle" - ><gl-icon name="question-o" :size="14" /></gl-link - ></span> - </span> - </template> + <span v-else + >{{ $options.i18n.defaultConfigText }} + <gl-link + v-gl-tooltip + :href="$options.configHelpLink" + :title="$options.i18n.defaultConfigTooltip" + :aria-label="$options.i18n.defaultConfigTooltip" + class="gl-vertical-align-middle" + ><gl-icon name="question-o" :size="14" /></gl-link + ></span> + </span> + </template> + + <template #cell(options)="{ item }"> + <delete-agent-button + v-if="!item.isShared" + :agent="item" + :default-branch-name="defaultBranchName" + /> + </template> + </gl-table> - <template #cell(options)="{ item }"> - <delete-agent-button - :agent="item" - :default-branch-name="defaultBranchName" - :max-agents="maxAgents" - /> - </template> - </gl-table> + <gl-pagination + v-if="showPagination" + v-model="currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-5" + /> + </div> </template> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 36f0f8e61ba..b1765d336c8 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -1,9 +1,9 @@ <script> -import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui'; import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { MAX_LIST_COUNT, AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants'; +import { AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import { getAgentLastContact, getAgentStatus } from '../clusters_util'; import AgentEmptyState from './agent_empty_state.vue'; @@ -27,7 +27,6 @@ export default { return { defaultBranchName: this.defaultBranchName, projectPath: this.projectPath, - ...this.cursor, }; }, update(data) { @@ -37,13 +36,15 @@ export default { result() { this.emitAgentsLoaded(); }, + error() { + this.queryErrored = true; + }, }, }, components: { AgentEmptyState, AgentTable, GlAlert, - GlKeysetPagination, GlLoadingIcon, GlBanner, LocalStorageSync, @@ -69,41 +70,41 @@ export default { }, data() { return { - cursor: { - first: this.limit ? this.limit : MAX_LIST_COUNT, - last: null, - }, folderList: {}, feedbackBannerDismissed: false, + queryErrored: false, }; }, computed: { agentList() { - let list = this.agents?.project?.clusterAgents?.nodes; + const localAgents = this.agents?.project?.clusterAgents?.nodes || []; + const sharedAgents = [ + ...(this.agents?.project?.ciAccessAuthorizedAgents?.nodes || []), + ...(this.agents?.project?.userAccessAuthorizedAgents?.nodes || []), + ].map((node) => { + return { + ...node.agent, + isShared: true, + }; + }); - if (list) { - list = list.map((agent) => { + const filteredList = [...localAgents, ...sharedAgents] + .filter((node, index, list) => { + return node && index === list.findIndex((agent) => agent.id === node.id); + }) + .map((agent) => { const configFolder = this.folderList[agent.name]; const lastContact = getAgentLastContact(agent?.tokens?.nodes); const status = getAgentStatus(lastContact); return { ...agent, configFolder, lastContact, status }; - }); - } + }) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt); - return list; - }, - agentPageInfo() { - return this.agents?.project?.clusterAgents?.pageInfo || {}; + return filteredList; }, isLoading() { return this.$apollo.queries.agents.loading; }, - showPagination() { - return !this.limit && (this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage); - }, - treePageInfo() { - return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; - }, feedbackBannerEnabled() { return this.glFeatures.showGitlabAgentFeedback; }, @@ -112,22 +113,6 @@ export default { }, }, methods: { - nextPage() { - this.cursor = { - first: MAX_LIST_COUNT, - last: null, - afterAgent: this.agentPageInfo.endCursor, - afterTree: this.treePageInfo.endCursor, - }; - }, - prevPage() { - this.cursor = { - first: null, - last: MAX_LIST_COUNT, - beforeAgent: this.agentPageInfo.startCursor, - beforeTree: this.treePageInfo.endCursor, - }; - }, updateTreeList(data) { const configFolders = data?.project?.repository?.tree?.trees?.nodes; @@ -138,8 +123,7 @@ export default { } }, emitAgentsLoaded() { - const count = this.agents?.project?.clusterAgents?.count; - this.$emit('onAgentsLoad', count); + this.$emit('onAgentsLoad', this.agentList?.length); }, handleBannerClose() { this.feedbackBannerDismissed = true; @@ -151,7 +135,7 @@ export default { <template> <gl-loading-icon v-if="isLoading" size="lg" /> - <section v-else-if="agentList"> + <section v-else-if="!queryErrored"> <div v-if="agentList.length"> <local-storage-sync v-if="feedbackBannerEnabled" @@ -174,12 +158,8 @@ export default { <agent-table :agents="agentList" :default-branch-name="defaultBranchName" - :max-agents="cursor.first" + :max-agents="limit" /> - - <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" /> - </div> </div> <agent-empty-state v-else /> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index 365e0384d87..75850cbb108 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -58,8 +58,6 @@ export default { selectAgent(agent) { this.$emit('agentSelected', agent); this.selectedAgent = agent; - - this.$refs.dropdown.closeAndFocus(); }, onKeyEnter() { if (!this.searchTerm?.length) { @@ -76,7 +74,6 @@ export default { <template> <div @keydown.enter.stop.prevent="onKeyEnter"> <gl-collapsible-listbox - ref="dropdown" v-model="selectedAgent" class="gl-w-full" toggle-class="select-agent-dropdown" diff --git a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue index 913db87f019..4088d5c79f7 100644 --- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue +++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue @@ -39,11 +39,6 @@ export default { required: false, type: String, }, - maxAgents: { - default: null, - required: false, - type: Number, - }, }, data() { return { @@ -64,8 +59,6 @@ export default { getAgentsQueryVariables() { return { defaultBranchName: this.defaultBranchName, - first: this.maxAgents, - last: null, projectPath: this.projectPath, }; }, diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index 444b9ac2a14..55e62d1c698 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -13,10 +13,9 @@ import { MODAL_TYPE_EMPTY, MODAL_TYPE_REGISTER, } from '../constants'; -import { addAgentToStore, addAgentConfigToStore } from '../graphql/cache_update'; +import { addAgentConfigToStore } from '../graphql/cache_update'; import createAgent from '../graphql/mutations/create_agent.mutation.graphql'; import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql'; -import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql'; import AvailableAgentsDropdown from './available_agents_dropdown.vue'; import AgentToken from './agent_token.vue'; @@ -148,14 +147,6 @@ export default { projectPath: this.projectPath, }, }, - update: (store, { data: { createClusterAgent } }) => { - addAgentToStore( - store, - createClusterAgent, - getAgentsQuery, - this.getAgentsQueryVariables, - ); - }, }) .then(({ data: { createClusterAgent } }) => { return createClusterAgent; diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index fe3fa22fea3..3ce10f7c3a2 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -1,7 +1,7 @@ import { __, s__, sprintf } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; -export const MAX_LIST_COUNT = 25; +export const MAX_LIST_COUNT = 20; export const INSTALL_AGENT_MODAL_ID = 'install-agent'; export const ACTIVE_CONNECTION_TIME = 480000; export const NAME_MAX_LENGTH = 50; @@ -86,6 +86,8 @@ export const I18N_AGENT_TABLE = { viewDocsText: s__('ClusterAgents|How to update an agent?'), defaultConfigText: s__('ClusterAgents|Default configuration'), defaultConfigTooltip: s__('ClusterAgents|What is default configuration?'), + sharedBadgeText: s__('ClusterAgents|shared'), + externalConfigText: s__('ClusterAgents|External project'), }; export const I18N_AGENT_TOKEN = { diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js index e68f6a378c0..1c58652744d 100644 --- a/app/assets/javascripts/clusters_list/graphql/cache_update.js +++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js @@ -2,27 +2,6 @@ import produce from 'immer'; export const hasErrors = ({ errors = [] }) => errors?.length; -export function addAgentToStore(store, createClusterAgent, query, variables) { - if (!hasErrors(createClusterAgent)) { - const { clusterAgent } = createClusterAgent; - const sourceData = store.readQuery({ - query, - variables, - }); - - const data = produce(sourceData, (draftData) => { - draftData.project.clusterAgents.nodes.push(clusterAgent); - draftData.project.clusterAgents.count += 1; - }); - - store.writeQuery({ - query, - variables, - data, - }); - } -} - export function addAgentConfigToStore( store, clusterAgentTokenCreate, @@ -65,7 +44,12 @@ export function removeAgentFromStore(store, deleteClusterAgent, query, variables draftData.project.clusterAgents.nodes = draftData.project.clusterAgents.nodes.filter( ({ id }) => id !== deleteClusterAgent.id, ); - draftData.project.clusterAgents.count -= 1; + draftData.project.ciAccessAuthorizedAgents.nodes = draftData.project.ciAccessAuthorizedAgents.nodes.filter( + ({ agent }) => agent.id !== deleteClusterAgent.id, + ); + draftData.project.userAccessAuthorizedAgents.nodes = draftData.project.userAccessAuthorizedAgents.nodes.filter( + ({ agent }) => agent.id !== deleteClusterAgent.id, + ); }); store.writeQuery({ diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql index 05d2525ab98..31897b50407 100644 --- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql +++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql @@ -2,6 +2,7 @@ fragment ClusterAgentFragment on ClusterAgent { id name webPath + createdAt connections { nodes { metadata { diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql index 76920a0aef4..2a4f7b42eff 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql @@ -1,26 +1,28 @@ -#import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "../fragments/cluster_agent.fragment.graphql" -query getAgents( - $defaultBranchName: String! - $projectPath: ID! - $first: Int - $last: Int - $afterAgent: String - $beforeAgent: String -) { +query getAgents($defaultBranchName: String!, $projectPath: ID!) { project(fullPath: $projectPath) { id - clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) { + clusterAgents { nodes { ...ClusterAgentFragment } + } - pageInfo { - ...PageInfo + ciAccessAuthorizedAgents { + nodes { + agent { + ...ClusterAgentFragment + } } + } - count + userAccessAuthorizedAgents { + nodes { + agent { + ...ClusterAgentFragment + } + } } repository { diff --git a/app/assets/javascripts/code_review/signals.js b/app/assets/javascripts/code_review/signals.js index 101b7996bb5..080879f4e1d 100644 --- a/app/assets/javascripts/code_review/signals.js +++ b/app/assets/javascripts/code_review/signals.js @@ -21,6 +21,11 @@ async function observeMergeRequestFinishingPreparation({ apollo, signaler }) { query: getMr, variables: { projectPath, iid }, }); + + if (!currentStatus.data.project) { + return; + } + const { id: gqlMrId, preparedAt } = currentStatus.data.project.mergeRequest; let preparationObservable; let preparationSubscriber; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index f2dac15a99e..8c8293eb09e 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -41,7 +41,7 @@ export default { viewType: { type: String, required: false, - default: 'child', + default: 'root', }, canCreatePipelineInTargetProject: { type: Boolean, diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue index 7c06417e6b3..ce5b566ba20 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue @@ -43,7 +43,8 @@ export default { this.$emit('hidden', ...args); this.menuVisible = false; }, - appendTo: () => document.body, + strategy: 'fixed', + maxWidth: 'auto', }, }), ); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue new file mode 100644 index 00000000000..900164fe60f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue @@ -0,0 +1,218 @@ +<script> +import { + GlTooltipDirective as GlTooltip, + GlButton, + GlButtonGroup, + GlCollapsibleListbox, +} from '@gitlab/ui'; +import { __ } from '~/locale'; +import Reference from '../../extensions/reference'; +import ReferenceLabel from '../../extensions/reference_label'; +import EditorStateObserver from '../editor_state_observer.vue'; +import BubbleMenu from './bubble_menu.vue'; + +const REFERENCE_NODE_TYPES = [Reference.name, ReferenceLabel.name]; + +export default { + components: { + BubbleMenu, + EditorStateObserver, + GlButton, + GlCollapsibleListbox, + GlButtonGroup, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + nodeType: null, + + referenceType: null, + originalText: null, + + href: null, + text: null, + expandedText: null, + fullyExpandedText: null, + + selectedTextFormat: {}, + + loading: false, + }; + }, + computed: { + isIssue() { + return this.referenceType === 'issue'; + }, + isMergeRequest() { + return this.referenceType === 'merge_request'; + }, + isEpic() { + return this.referenceType === 'epic'; + }, + isExpandable() { + return this.isIssue || this.isMergeRequest || this.isEpic; + }, + textFormats() { + return [ + { + value: '', + text: this.$options.i18n.referenceId[this.referenceType], + matcher: (text) => !text.endsWith('+') && !text.endsWith('+s'), + getText: () => this.text, + shouldShow: true, + }, + { + value: '+', + text: this.$options.i18n.referenceTitle[this.referenceType], + matcher: (text) => text.endsWith('+'), + getText: () => this.expandedText, + shouldShow: true, + }, + { + value: '+s', + text: this.$options.i18n.referenceSummary[this.referenceType], + matcher: (text) => text.endsWith('+s'), + getText: () => this.fullyExpandedText, + shouldShow: this.isIssue || this.isMergeRequest, + }, + ]; + }, + }, + methods: { + shouldShow: ({ editor }) => { + return REFERENCE_NODE_TYPES.some((type) => editor.isActive(type)); + }, + async updateReferenceInfoToState() { + this.nodeType = REFERENCE_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)); + if (!this.nodeType) return; + + const { + referenceType, + href, + originalText, + text: alternateText, + } = this.tiptapEditor.getAttributes(this.nodeType); + + this.href = href; + this.referenceType = referenceType; + this.originalText = originalText || alternateText; + this.selectedTextFormat = this.textFormats.find(({ matcher }) => matcher(this.originalText)); + + this.loading = true; + + const { text, expandedText, fullyExpandedText } = await this.contentEditor.resolveReference( + this.originalText, + ); + + this.text = text; + this.expandedText = expandedText; + this.fullyExpandedText = fullyExpandedText; + + this.loading = false; + }, + removeReference() { + this.tiptapEditor.chain().focus().deleteSelection().run(); + }, + copyReferenceURL() { + navigator.clipboard.writeText(this.href); + }, + applyFormat(value) { + const format = this.textFormats.find((v) => v.value === value); + + this.tiptapEditor + .chain() + .focus() + .updateAttributes(this.nodeType, { + text: format.getText(), + originalText: `${this.originalText.replace(/(\+|\+s)$/, '')}${format.value}`, + }) + .run(); + + this.selectedTextFormat = format; + }, + }, + tippyOptions: { + placement: 'bottom', + }, + i18n: { + referenceId: { + issue: __('Issue ID'), + merge_request: __('Merge request ID'), + epic: __('Epic ID'), + }, + referenceTitle: { + issue: __('Issue title'), + merge_request: __('Merge request title'), + epic: __('Epic title'), + }, + referenceSummary: { + issue: __('Issue summary'), + merge_request: __('Merge request summary'), + epic: __('Epic summary'), + }, + copyURLLabel: { + issue: __('Copy issue URL'), + merge_request: __('Copy merge request URL'), + epic: __('Copy epic URL'), + }, + removeLabel: { + issue: __('Remove issue reference'), + merge_request: __('Remove merge request reference'), + epic: __('Remove epic reference'), + }, + }, +}; +</script> +<template> + <editor-state-observer :debounce="0" @transaction="updateReferenceInfoToState"> + <bubble-menu + v-show="isExpandable" + class="gl-shadow gl-rounded-base gl-bg-white" + plugin-key="bubbleMenuReference" + :should-show="shouldShow" + :tippy-options="$options.tippyOptions" + > + <gl-button-group class="gl-display-flex gl-align-items-center"> + <span class="gl-py-2 gl-px-3 gl-text-secondary gl-white-space-nowrap"> + {{ __('Display as:') }} + </span> + <gl-collapsible-listbox + v-show="!loading" + category="tertiary" + boundary="viewport" + :selected="selectedTextFormat.value" + :items="textFormats" + :loading="loading" + :toggle-text="selectedTextFormat.text" + toggle-class="gl-rounded-0!" + @select="applyFormat" + /> + <gl-button + v-gl-tooltip.bottom + variant="default" + category="tertiary" + size="medium" + data-testid="copy-reference-url" + :aria-label="$options.i18n.copyURLLabel[referenceType]" + :title="$options.i18n.copyURLLabel[referenceType]" + icon="copy-to-clipboard" + @click="copyReferenceURL" + /> + <gl-button + v-gl-tooltip.bottom + variant="default" + category="tertiary" + size="medium" + data-testid="remove-reference" + :aria-label="$options.i18n.removeLabel[referenceType]" + :title="$options.i18n.removeLabel[referenceType]" + icon="remove" + @click="removeReference" + /> + </gl-button-group> + </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 4c5bbca4110..92f3c3fb8fa 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -11,6 +11,7 @@ import EditorStateObserver from './editor_state_observer.vue'; import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue'; +import ReferenceBubbleMenu from './bubble_menus/reference_bubble_menu.vue'; import FormattingToolbar from './formatting_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -27,6 +28,7 @@ export default { LinkBubbleMenu, MediaBubbleMenu, EditorStateObserver, + ReferenceBubbleMenu, }, props: { renderMarkdown: { @@ -88,6 +90,11 @@ export default { required: false, default: () => ({}), }, + disableAttachments: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -221,37 +228,41 @@ export default { class="md-area gl-border-none! gl-shadow-none!" :class="{ 'is-focused': focused }" > - <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" /> - <div class="gl-relative"> - <code-block-bubble-menu /> - <link-bubble-menu /> - <media-bubble-menu /> - <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4"> - {{ placeholder }} - </div> - <tiptap-editor-content - class="md gl-px-5" - data-testid="content_editor_editablebox" - :editor="contentEditor.tiptapEditor" - /> - <loading-indicator v-if="isLoading" /> - <div - v-if="quickActionsDocsPath" - class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary" - > - <div class="gl-w-full gl-line-height-32 gl-font-sm"> - <gl-sprintf :message="$options.i18n.quickActionsText"> - <template #keyboard="{ content }"> - <kbd>{{ content }}</kbd> - </template> - <template #quickActionsDocsLink="{ content }"> - <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </div> - </div> + <formatting-toolbar + ref="toolbar" + :hide-attachment-button="disableAttachments" + @enableMarkdownEditor="$emit('enableMarkdownEditor')" + /> + <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4"> + {{ placeholder }} + </div> + <tiptap-editor-content + class="md gl-px-5" + data-testid="content_editor_editablebox" + :editor="contentEditor.tiptapEditor" + /> + <loading-indicator v-if="isLoading" /> + + <code-block-bubble-menu /> + <link-bubble-menu /> + <media-bubble-menu /> + <reference-bubble-menu /> + </div> + <div + v-if="quickActionsDocsPath" + class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary" + > + <div class="gl-w-full gl-line-height-32 gl-font-sm"> + <gl-sprintf :message="$options.i18n.quickActionsText"> + <template #keyboard="{ content }"> + <kbd>{{ content }}</kbd> + </template> + <template #quickActionsDocsLink="{ content }"> + <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ + content + }}</gl-link> + </template> + </gl-sprintf> </div> </div> </div> diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index fac259cf6a1..c53007b68cf 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -16,6 +16,13 @@ export default { ToolbarMoreDropdown, EditorModeSwitcher, }, + props: { + hideAttachmentButton: { + type: Boolean, + default: false, + required: false, + }, + }, methods: { trackToolbarControlExecution({ contentType, value }) { trackUIControl({ property: contentType, value }); @@ -114,6 +121,7 @@ export default { /> <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> <toolbar-attachment-button + v-if="!hideAttachmentButton" data-testid="attachment" @execute="trackToolbarControlExecution" /> @@ -125,8 +133,3 @@ export default { </div> </div> </template> -<style> -.gl-spinner-container { - text-align: left; -} -</style> 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 bf2740f9864..eb7985f628a 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownForm, - GlButton, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; +import { GlDisclosureDropdown, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { clamp } from '../services/utils'; @@ -19,9 +13,7 @@ const MAX_COLS = 10; export default { components: { GlButton, - GlDropdown, - GlDropdownDivider, - GlDropdownForm, + GlDisclosureDropdown, }, directives: { GlTooltip, @@ -61,45 +53,72 @@ export default { .run(); this.resetState(); + this.$refs.dropdown.close(); this.$emit('execute', { contentType: 'table' }); }, getButtonLabel(rows, cols) { - return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols }); + return sprintf(__('Insert a %{rows}×%{cols} table'), { rows, cols }); + }, + onKeydown(key) { + const delta = { + ArrowUp: { rows: -1, cols: 0 }, + ArrowDown: { rows: 1, cols: 0 }, + ArrowLeft: { rows: 0, cols: -1 }, + ArrowRight: { rows: 0, cols: 1 }, + }[key] || { rows: 0, cols: 0 }; + + const rows = clamp(this.rows + delta.rows, 1, this.maxRows); + const cols = clamp(this.cols + delta.cols, 1, this.maxCols); + + this.setRowsAndCols(rows, cols); + }, + setFocus(row, col) { + this.$refs[`table-${row}-${col}`][0].$el.focus(); }, }, + MAX_COLS, + MAX_ROWS, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown + ref="dropdown" v-gl-tooltip size="small" category="tertiary" icon="table" - :title="__('Insert table')" - :text="__('Insert table')" - class="content-editor-dropdown" - right + :aria-label="__('Insert table')" + :toggle-text="__('Insert table')" + positioning-strategy="fixed" + class="content-editor-table-dropdown" text-sr-only - lazy + :fluid-width="true" + @shown="setFocus(1, 1)" > - <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)" - :key="c" - :data-testid="`table-${r}-${c}`" - :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" - :aria-label="getButtonLabel(r, c)" - class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" - @mouseover="setRowsAndCols(r, c)" - @click="insertTable()" - /> - </div> - <gl-dropdown-divider class="gl-my-3! gl-mx-n3!" /> - <div class="gl-px-1"> - {{ getButtonLabel(rows, cols) }} + <div + class="gl-p-3 gl-pt-2" + role="grid" + :aria-colcount="$options.MAX_COLS" + :aria-rowcount="$options.MAX_ROWS" + > + <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row"> + <div v-for="c of list(maxCols)" :key="c" role="gridcell"> + <gl-button + :ref="`table-${r}-${c}`" + :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" + :aria-label="getButtonLabel(r, c)" + class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" + @mouseover="setRowsAndCols(r, c)" + @focus="setRowsAndCols(r, c)" + @click="insertTable()" + @keydown="onKeydown($event.key)" + /> + </div> </div> - </gl-dropdown-form> - </gl-dropdown> + </div> + <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2"> + {{ getButtonLabel(rows, cols) }} + </div> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue index 4126c65d87f..2b4b9891c77 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/reference.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue @@ -13,6 +13,11 @@ export default { type: Object, required: true, }, + selected: { + type: Boolean, + required: false, + default: false, + }, }, computed: { text() { @@ -31,13 +36,18 @@ export default { }; </script> <template> - <node-view-wrapper class="gl-display-inline-block"> + <node-view-wrapper as="span"> <span v-if="isCommand">{{ text }}</span> <gl-link v-else href="#" - class="gfm" - :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }" + tabindex="-1" + class="gfm gl-cursor-text" + :class="{ + 'gfm-project_member': isMember, + 'current-user': isMember && isCurrentUser, + 'ProseMirror-selectednode': selected, + }" @click.prevent.stop >{{ text }}</gl-link > diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue index 4206c866032..c67e699cf95 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue @@ -14,21 +14,28 @@ export default { type: Object, required: true, }, + selected: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isScopedLabel() { - return isScopedLabel({ title: this.node.attrs.originalText }); + return isScopedLabel({ title: this.node.attrs.originalText || this.node.attrs.text }); }, }, + fallbackLabelBackgroundColor: '#ccc', }; </script> <template> - <node-view-wrapper class="gl-display-inline-block"> + <node-view-wrapper as="span" :class="{ 'ProseMirror-selectednode': selected }"> <gl-label size="sm" :scoped="isScopedLabel" - :background-color="node.attrs.color" + :background-color="node.attrs.color || $options.fallbackLabelBackgroundColor" :title="node.attrs.text" + class="gl-pointer-events-none" /> </node-view-wrapper> </template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index 5624bae34c2..44f5a2895fd 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -1,22 +1,71 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { selectedRect as getSelectedRect } from '@tiptap/pm/tables'; -import { __ } from '~/locale'; +import { __, n__ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; const TABLE_CELL_BODY = 'td'; +function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) { + const totalRows = selectedRect?.map.height; + const totalCols = selectedRect?.map.width; + const isTableBodyCell = cellType === TABLE_CELL_BODY; + const selectedRows = selectedRect ? selectedRect.bottom - selectedRect.top : 0; + const selectedCols = selectedRect ? selectedRect.right - selectedRect.left : 0; + const showSplitCellOption = + selectedRows === rowspan && selectedCols === colspan && (rowspan > 1 || colspan > 1); + const showMergeCellsOption = selectedRows !== rowspan || selectedCols !== colspan; + const numCellsToMerge = (selectedRows - rowspan + 1) * (selectedCols - colspan + 1); + const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell; + const showDeleteColumnOption = totalCols > selectedCols; + + return [ + { + items: [ + { text: __('Insert column before'), value: 'addColumnBefore' }, + { text: __('Insert column after'), value: 'addColumnAfter' }, + isTableBodyCell && { text: __('Insert row before'), value: 'addRowBefore' }, + { text: __('Insert row after'), value: 'addRowAfter' }, + ].filter(Boolean), + }, + { + items: [ + showSplitCellOption && { text: __('Split cell'), value: 'splitCell' }, + showMergeCellsOption && { + text: n__('Merge %d cell', 'Merge %d cells', numCellsToMerge), + value: 'mergeCells', + }, + ].filter(Boolean), + }, + { + items: [ + showDeleteRowOption && { + text: n__('Delete row', 'Delete %d rows', selectedRows), + value: 'deleteRow', + }, + showDeleteColumnOption && { + text: n__('Delete column', 'Delete %d columns', selectedCols), + value: 'deleteColumn', + }, + { text: __('Delete table'), value: 'deleteTable' }, + ].filter(Boolean), + }, + ].filter(({ items }) => items.length); +} + export default { name: 'TableCellBaseWrapper', components: { NodeViewWrapper, NodeViewContent, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, }, props: { + getPos: { + type: Function, + required: true, + }, cellType: { type: String, validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type), @@ -34,19 +83,17 @@ export default { data() { return { displayActionsDropdown: false, - preventHide: true, selectedRect: null, }; }, computed: { - totalRows() { - return this.selectedRect?.map.height; - }, - totalCols() { - return this.selectedRect?.map.width; - }, - isTableBodyCell() { - return this.cellType === TABLE_CELL_BODY; + dropdownItems() { + return getDropdownItems({ + selectedRect: this.selectedRect, + cellType: this.cellType, + rowspan: this.node.attrs.rowspan, + colspan: this.node.attrs.colspan, + }); }, }, mounted() { @@ -61,6 +108,13 @@ export default { const { state } = this.editor; const { $cursor } = state.selection; + try { + this.selectedRect = getSelectedRect(state); + } catch (e) { + // ignore error if the selection is not in a table + return; + } + if (!$cursor) return; this.displayActionsDropdown = false; @@ -71,54 +125,34 @@ export default { break; } } - - if (this.displayActionsDropdown) { - this.selectedRect = getSelectedRect(state); - } }, - runCommand(command) { - this.editor.chain()[command]().run(); + + runCommand({ value: command }) { this.hideDropdown(); + this.editor.chain()[command]().run(); }, - handleHide($event) { - if (this.preventHide) { - $event.preventDefault(); - } - this.preventHide = true; - }, + hideDropdown() { - this.preventHide = false; - this.$refs.dropdown?.hide(); + this.$refs.dropdown?.close(); }, }, - i18n: { - insertColumnBefore: __('Insert column before'), - insertColumnAfter: __('Insert column after'), - insertRowBefore: __('Insert row before'), - insertRowAfter: __('Insert row after'), - deleteRow: __('Delete row'), - deleteColumn: __('Delete column'), - deleteTable: __('Delete table'), - editTableActions: __('Edit table'), - }, - dropdownPopperOpts: { - positionFixed: true, - }, }; </script> <template> <node-view-wrapper - class="gl-relative gl-padding-5 gl-min-w-10" :as="cellType" + :rowspan="node.attrs.rowspan || 1" + :colspan="node.attrs.colspan || 1" dir="auto" + class="gl-m-0! gl-p-0! gl-relative" @click="hideDropdown" > <span v-if="displayActionsDropdown" contenteditable="false" - class="gl-absolute gl-right-0 gl-top-0" + class="gl-absolute gl-right-0 gl-top-0 gl-pr-1 gl-pt-1" > - <gl-dropdown + <gl-disclosure-dropdown ref="dropdown" dropup icon="chevron-down" @@ -127,34 +161,12 @@ export default { boundary="viewport" no-caret text-sr-only - :text="$options.i18n.editTableActions" - :popper-opts="$options.dropdownPopperOpts" - @hide="handleHide($event)" - > - <gl-dropdown-item @click="runCommand('addColumnBefore')"> - {{ $options.i18n.insertColumnBefore }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('addColumnAfter')"> - {{ $options.i18n.insertColumnAfter }} - </gl-dropdown-item> - <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')"> - {{ $options.i18n.insertRowBefore }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('addRowAfter')"> - {{ $options.i18n.insertRowAfter }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')"> - {{ $options.i18n.deleteRow }} - </gl-dropdown-item> - <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')"> - {{ $options.i18n.deleteColumn }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('deleteTable')"> - {{ $options.i18n.deleteTable }} - </gl-dropdown-item> - </gl-dropdown> + :items="dropdownItems" + :toggle-text="__('Edit table')" + positioning-strategy="fixed" + @action="runCommand" + /> </span> - <node-view-content /> + <node-view-content as="div" class="gl-p-5 gl-min-w-10" /> </node-view-wrapper> </template> diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js index 9e1a4bfe361..1aa6568848f 100644 --- a/app/assets/javascripts/content_editor/content_editor.stories.js +++ b/app/assets/javascripts/content_editor/content_editor.stories.js @@ -1,26 +1,33 @@ +import { withGitLabAPIAccess } from 'storybook_addons/gitlab_api_access'; +import Api from '~/api'; import { ContentEditor } from './index'; export default { component: ContentEditor, - title: 'content_editor/content_editor', + title: 'ce/content_editor/content_editor', + decorators: [withGitLabAPIAccess], }; const Template = (_, { argTypes }) => ({ components: { ContentEditor }, props: Object.keys(argTypes), - template: '<content-editor v-bind="$props" @initialized="loadContent" />', - methods: { - loadContent(contentEditor) { - contentEditor.setSerializedContent('Hello content editor'); - }, - }, + template: ` + <content-editor v-bind="$props" /> + `, }); export const Default = Template.bind({}); Default.args = { - renderMarkdown: () => '<p>Hello content editor</p>', + project: 'gitlab-org/gitlab-shell', + renderMarkdown: async (text) => { + const response = await Api.markdown({ text, gfm: true, project: Default.args.project }); + + return response.data.html; + }, + markdown: 'This is **bold text**', uploadsPath: '/uploads/', serializerConfig: {}, extensions: [], + enableAutocomplete: false, }; diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js index 53f6d9b995c..8477c8dbd28 100644 --- a/app/assets/javascripts/content_editor/extensions/code.js +++ b/app/assets/javascripts/content_editor/extensions/code.js @@ -1,12 +1,22 @@ +import { Mark } from '@tiptap/core'; import Code from '@tiptap/extension-code'; import { EXTENSION_PRIORITY_LOWER } from '../constants'; export default Code.extend({ excludes: null, + /** * Reduce the rendering priority of the code mark to * ensure the bold, italic, and strikethrough marks * are rendered first. */ priority: EXTENSION_PRIORITY_LOWER, + + addKeyboardShortcuts() { + return { + ArrowRight: () => { + return Mark.handleExit({ editor: this.editor, mark: this }); + }, + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js index 06fecf8196d..d3fa4bb84bd 100644 --- a/app/assets/javascripts/content_editor/extensions/description_item.js +++ b/app/assets/javascripts/content_editor/extensions/description_item.js @@ -39,9 +39,13 @@ export default Node.create({ addKeyboardShortcuts() { return { Enter: () => { + if (!this.editor.isActive('descriptionItem')) return false; + return this.editor.commands.splitListItem('descriptionItem'); }, Tab: () => { + if (!this.editor.isActive('descriptionItem')) return false; + const { isTerm } = this.editor.getAttributes('descriptionItem'); if (isTerm) return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm }); @@ -49,6 +53,8 @@ export default Node.create({ return false; }, 'Shift-Tab': () => { + if (!this.editor.isActive('descriptionItem')) return false; + const { isTerm } = this.editor.getAttributes('descriptionItem'); if (isTerm) return this.editor.commands.liftListItem('descriptionItem'); diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js index fbe58664a10..61bef0729db 100644 --- a/app/assets/javascripts/content_editor/extensions/details_content.js +++ b/app/assets/javascripts/content_editor/extensions/details_content.js @@ -26,8 +26,16 @@ export default Node.create({ addKeyboardShortcuts() { return { - Enter: () => this.editor.commands.splitListItem('detailsContent'), - 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'), + Enter: () => { + if (!this.editor.isActive('detailsContent')) return false; + + return this.editor.commands.splitListItem('detailsContent'); + }, + 'Shift-Tab': () => { + if (!this.editor.isActive('detailsContent')) return false; + + return this.editor.commands.liftListItem('detailsContent'); + }, }; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js index 8c3012ecf59..0d453919571 100644 --- a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js +++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js @@ -1,7 +1,6 @@ import { create } from '~/drawio/content_editor_facade'; import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -import createAssetResolver from '../services/asset_resolver'; import Image from './image'; export default Image.extend({ @@ -10,7 +9,7 @@ export default Image.extend({ return { ...this.parent?.(), uploadsPath: null, - renderMarkdown: null, + assetResolver: null, }; }, parseHTML() { @@ -32,7 +31,7 @@ export default Image.extend({ tiptapEditor: this.editor, drawioNodeName: this.name, uploadsPath: this.options.uploadsPath, - assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }), + assetResolver: this.options.assetResolver, }), }); }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index b83814103d1..584e7b9e4f7 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -40,7 +40,6 @@ export default Link.extend({ }, addAttributes() { return { - ...this.parent?.(), uploading: { default: false, renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}), diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index 82fa5ce6c1d..db13438de5e 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -1,5 +1,7 @@ +import OrderedMap from 'orderedmap'; import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Schema, DOMParser as ProseMirrorDOMParser, DOMSerializer } from '@tiptap/pm/model'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; @@ -9,47 +11,55 @@ import Diagram from './diagram'; import Frontmatter from './frontmatter'; const TEXT_FORMAT = 'text/plain'; +const GFM_FORMAT = 'text/x-gfm'; const HTML_FORMAT = 'text/html'; const VS_CODE_FORMAT = 'vscode-editor-data'; const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; +function parseHTML(schema, html) { + const parser = new DOMParser(); + const startTag = '<body>'; + const endTag = '</body>'; + const { body } = parser.parseFromString(startTag + html + endTag, 'text/html'); + return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; +} + export default Extension.create({ name: 'pasteMarkdown', priority: EXTENSION_PRIORITY_HIGHEST, addOptions() { return { renderMarkdown: null, + serializer: null, }; }, addCommands() { return { - pasteMarkdown: (markdown) => () => { + pasteContent: (content = '', processMarkdown = true) => async () => { const { editor, options } = this; const { renderMarkdown, eventHub } = options; const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - deserializer - .deserialize({ schema: editor.schema, markdown }) + const pasteSchemaSpec = { ...editor.schema.spec }; + pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span'); + pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre'); + const schema = new Schema(pasteSchemaSpec); + + const promise = processMarkdown + ? deserializer.deserialize({ schema, markdown: content }) + : Promise.resolve(parseHTML(schema, content)); + + promise .then(({ document }) => { - if (!document) { - return; - } + if (!document) return; - const { state, view } = editor; - const { tr, selection } = state; const { firstChild } = document.content; - const content = + const toPaste = 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); - } - - view.dispatch(tr); + editor.commands.insertContent(toPaste.toJSON()); }) .catch(() => { eventHub.$emit(ALERT_EVENT, { @@ -65,24 +75,57 @@ export default Extension.create({ addProseMirrorPlugins() { let pasteRaw = false; + const handleCutAndCopy = (view, event) => { + const slice = view.state.selection.content(); + const gfmContent = this.options.serializer.serialize({ doc: slice.content }); + const documentFragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment( + slice.content, + ); + const div = document.createElement('div'); + div.appendChild(documentFragment); + + event.clipboardData.setData(TEXT_FORMAT, div.innerText); + event.clipboardData.setData(HTML_FORMAT, div.innerHTML); + event.clipboardData.setData(GFM_FORMAT, gfmContent); + + event.preventDefault(); + event.stopPropagation(); + }; + return [ new Plugin({ key: new PluginKey('pasteMarkdown'), props: { + handleDOMEvents: { + copy: handleCutAndCopy, + cut: (view, event) => { + handleCutAndCopy(view, event); + this.editor.commands.deleteSelection(); + }, + }, 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; + + const gfmContent = clipboardData.getData(GFM_FORMAT); + + if (gfmContent) { + return this.editor.commands.pasteContent(gfmContent, true); + } + + const textContent = clipboardData.getData(TEXT_FORMAT); + const htmlContent = clipboardData.getData(HTML_FORMAT); + + const { from, to } = view.state.selection; if (pasteRaw) { - tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to); - view.dispatch(tr); + this.editor.commands.insertContentAt( + { from, to }, + textContent.replace(/^\s+|\s+$/gm, ''), + ); return true; } @@ -91,18 +134,19 @@ export default Extension.create({ const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; const language = vsCodeMeta.mode; - if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) { - return false; - } - // if a code block is active, paste as plain text - if (CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { + if (!textContent || CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { return false; } - this.editor.commands.pasteMarkdown(content); + if (hasVsCode) { + return this.editor.commands.pasteContent( + language === 'markdown' ? textContent : `\`\`\`${language}\n${textContent}\n\`\`\``, + true, + ); + } - return true; + return this.editor.commands.pasteContent(hasHTML ? htmlContent : textContent, !hasHTML); }, }, }), diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index b56aa8596a0..ef69b9bbda6 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,4 @@ -import { Node } from '@tiptap/core'; +import { Node, InputRule } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import ReferenceWrapper from '../components/wrappers/reference.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; @@ -8,6 +8,21 @@ const getAnchor = (element) => { return element.querySelector('a'); }; +const findReference = (editor, reference) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.isText && descendant.text.includes(reference)) { + position = pos + descendant.text.indexOf(reference); + return false; + } + + return true; + }); + + return position; +}; + export default Node.create({ name: 'reference', @@ -17,6 +32,12 @@ export default Node.create({ atom: true, + addOptions() { + return { + assetResolver: null, + }; + }, + addAttributes() { return { className: { @@ -42,6 +63,54 @@ export default Node.create({ }; }, + addInputRules() { + const { editor } = this; + const { assetResolver } = this.options; + const referenceInputRegex = /(?:^|\s)([\w/]*([!&#])\d+(\+?s?))(?:\s|\n)/m; + const referenceTypes = { + '#': 'issue', + '!': 'merge_request', + '&': 'epic', + }; + + return [ + new InputRule({ + find: referenceInputRegex, + handler: async ({ match }) => { + const [, referenceId, referenceSymbol, expansionType] = match; + const referenceType = referenceTypes[referenceSymbol]; + + const { + href, + text, + expandedText, + fullyExpandedText, + } = await assetResolver.resolveReference(referenceId); + + if (!text) return; + + let referenceText = text; + if (expansionType === '+') referenceText = expandedText; + if (expansionType === '+s') referenceText = fullyExpandedText; + + const position = findReference(editor, referenceId); + if (!position) return; + + editor.view.dispatch( + editor.state.tr.replaceWith(position, position + referenceId.length, [ + this.type.create({ + referenceType, + originalText: referenceId, + href, + text: referenceText, + }), + ]), + ); + }, + }), + ]; + }, + parseHTML() { return [ { @@ -51,6 +120,19 @@ export default Node.create({ ]; }, + renderHTML({ node }) { + return [ + 'gl-reference', + { + 'data-reference-type': node.attrs.referenceType, + 'data-original-text': node.attrs.originalText, + href: node.attrs.href, + text: node.attrs.text, + }, + 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 0441f8ef8d2..9cd55a0f87c 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -4,7 +4,7 @@ import LabelWrapper from '../components/wrappers/reference_label.vue'; import Reference from './reference'; export default Reference.extend({ - name: 'reference_label', + name: 'referenceLabel', addAttributes() { return { @@ -20,11 +20,21 @@ export default Reference.extend({ }, color: { default: null, - parseHTML: (element) => element.querySelector('.gl-label-text').style.backgroundColor, + parseHTML: (element) => { + let color = element.querySelector('.gl-label-text').style.backgroundColor; + if (!color || color.startsWith('var')) + color = element.style.getPropertyValue('--label-background-color'); + + return color; + }, }, }; }, + addInputRules() { + return []; + }, + parseHTML() { return [{ tag: 'span.gl-label' }]; }, diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index e72b5c7365c..f29222a5289 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -162,7 +162,7 @@ export default Node.create({ editor: this.editor, char: '~', dataSource: this.options.autocompleteDataSources.labels, - nodeType: 'reference_label', + nodeType: 'referenceLabel', nodeProps: { referenceType: 'label', }, diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js index c0bcddbe58d..0d4396fc176 100644 --- a/app/assets/javascripts/content_editor/services/asset_resolver.js +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -2,23 +2,46 @@ import { memoize } from 'lodash'; const parser = new DOMParser(); -export default ({ renderMarkdown }) => ({ - resolveUrl: memoize(async (canonicalSrc) => { - const html = await renderMarkdown(`[link](${canonicalSrc})`); +export default class AssetResolver { + constructor({ renderMarkdown }) { + this.renderMarkdown = renderMarkdown; + } + + resolveUrl = memoize(async (canonicalSrc) => { + const html = await this.renderMarkdown(`[link](${canonicalSrc})`); if (!html) return canonicalSrc; const { body } = parser.parseFromString(html, 'text/html'); return body.querySelector('a').getAttribute('href'); - }), + }); + + resolveReference = memoize(async (originalText) => { + const text = originalText.replace(/(\+|\+s)$/, ''); + const toRender = `${text} ${text}+ ${text}+s`; + const html = await this.renderMarkdown(toRender); + + if (!html) return {}; + + const { body } = parser.parseFromString(html, 'text/html'); + const a = body.querySelectorAll('a'); + if (!a.length) return {}; + + return { + href: a[0].getAttribute('href'), + text: a[0].textContent, + expandedText: a[1].textContent, + fullyExpandedText: a[2].textContent, + }; + }); - renderDiagram: memoize(async (code, language) => { + renderDiagram = memoize(async (code, language) => { const backticks = '`'.repeat(4); - const html = await renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`); + const html = await this.renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`); const { body } = parser.parseFromString(html, 'text/html'); const img = body.querySelector('img'); if (!img) return ''; return img.dataset.src || img.getAttribute('src'); - }), -}); + }); +} diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index a988e1df2a6..ec0f2f028d9 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -56,6 +56,10 @@ export class ContentEditor { return this._assetResolver.resolveUrl(canonicalSrc); } + resolveReference(originalText) { + return this._assetResolver.resolveReference(originalText); + } + renderDiagram(code, language) { return this._assetResolver.renderDiagram(code, language); } 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 3958f77745a..ee1f706ec7e 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -64,10 +64,10 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { ContentEditor } from './content_editor'; -import createMarkdownSerializer from './markdown_serializer'; +import MarkdownSerializer from './markdown_serializer'; import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from './remark_markdown_deserializer'; -import createAssetResolver from './asset_resolver'; +import AssetResolver from './asset_resolver'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => @@ -96,6 +96,13 @@ export const createContentEditor = ({ } const eventHub = eventHubFactory(); + const assetResolver = new AssetResolver({ renderMarkdown }); + const serializer = new MarkdownSerializer({ serializerConfig }); + const deserializer = window.gon?.features?.preserveUnchangedMarkdown + ? createRemarkMarkdownDeserializer() + : createGlApiMarkdownDeserializer({ + render: renderMarkdown, + }); const builtInContentEditorExtensions = [ Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), @@ -138,8 +145,8 @@ export const createContentEditor = ({ MathInline, OrderedList, Paragraph, - PasteMarkdown.configure({ eventHub, renderMarkdown }), - Reference, + PasteMarkdown.configure({ eventHub, renderMarkdown, serializer }), + Reference.configure({ assetResolver }), ReferenceLabel, ReferenceDefinition, Selection, @@ -162,17 +169,10 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources })); - if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown })); + if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, assetResolver })); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); - const serializer = createMarkdownSerializer({ serializerConfig }); - const deserializer = window.gon?.features?.preserveUnchangedMarkdown - ? createRemarkMarkdownDeserializer() - : createGlApiMarkdownDeserializer({ - render: renderMarkdown, - }); - const assetResolver = createAssetResolver({ renderMarkdown }); return new ContentEditor({ tiptapEditor, diff --git a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js index a0ebbebed4e..5e7c981ace3 100644 --- a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js +++ b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js @@ -47,6 +47,10 @@ export default { 'clojure-repl': () => import(/* webpackChunkName: 'hl-clojure-repl' */ 'highlight.js/lib/languages/clojure-repl'), clojure: () => import(/* webpackChunkName: 'hl-clojure' */ 'highlight.js/lib/languages/clojure'), + codeowners: () => + import( + /* webpackChunkName: 'hl-codeowners' */ '~/vue_shared/components/source_viewer/languages/codeowners' + ), cmake: () => import(/* webpackChunkName: 'hl-cmake' */ 'highlight.js/lib/languages/cmake'), coffeescript: () => import(/* webpackChunkName: 'hl-coffeescript' */ 'highlight.js/lib/languages/coffeescript'), diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 3b77064e903..4dbafd1632d 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -67,6 +67,7 @@ import { renderContent, renderBulletList, renderReference, + renderReferenceLabel, preserveUnchanged, bold, italic, @@ -197,7 +198,7 @@ const defaultSerializerConfig = { [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: renderReference, - [ReferenceLabel.name]: renderReference, + [ReferenceLabel.name]: renderReferenceLabel, [ReferenceDefinition.name]: preserveUnchanged({ render: (state, node, parent, index, same, sourceMarkdown) => { const nextSibling = parent.maybeChild(index + 1); @@ -273,19 +274,22 @@ const createChangeTracker = (doc, pristineDoc) => { return changeTracker; }; -/** - * Converts a ProseMirror document to Markdown. See the - * following documentation to learn how to implement - * custom node and mark serializer functions. - * - * https://github.com/prosemirror/prosemirror-markdown - * - * @param {Object} params.nodes ProseMirror node serializer functions - * @param {Object} params.marks ProseMirror marks serializer config - * - * @returns a markdown serializer - */ -export default ({ serializerConfig = {} } = {}) => ({ +export default class MarkdownSerializer { + /** + * Converts a ProseMirror document to Markdown. See the + * following documentation to learn how to implement + * custom node and mark serializer functions. + * + * https://github.com/prosemirror/prosemirror-markdown + * + * @param {Object} params.nodes ProseMirror node serializer functions + * @param {Object} params.marks ProseMirror marks serializer config + * + * @returns a markdown serializer + */ + constructor({ serializerConfig = {} } = {}) { + this.serializerConfig = serializerConfig; + } /** * Serializes a ProseMirror document as Markdown. If a node contains * sourcemap metadata, the serializer is capable of restoring the @@ -301,22 +305,23 @@ export default ({ serializerConfig = {} } = {}) => ({ * changed. * @returns A String that represents the serialized document as Markdown */ - serialize: ({ doc, pristineDoc }) => { + serialize({ doc, pristineDoc }) { const changeTracker = createChangeTracker(doc, pristineDoc); const serializer = new ProseMirrorMarkdownSerializer( { ...defaultSerializerConfig.nodes, - ...serializerConfig.nodes, + ...this.serializerConfig.nodes, }, { ...defaultSerializerConfig.marks, - ...serializerConfig.marks, + ...this.serializerConfig.marks, }, ); return serializer.serialize(doc, { tightLists: true, changeTracker, + escapeExtraCharacters: /<|>/g, }); - }, -}); + } +} diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 478b87372d7..b2cbc9c3fed 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -53,6 +53,16 @@ function getRowsAndCells(table) { return { rows, cells }; } +// Buffers the output of the given action (fn) and returns the output that was written +// to the prosemirror-markdown serializer state output. +function buffer(state, action = () => {}) { + const buf = state.out; + action(); + const retval = state.out.substring(buf.length); + state.out = buf; + return retval; +} + function getChildren(node) { const children = []; for (let i = 0; i < node.childCount; i += 1) { @@ -147,6 +157,11 @@ function setIsInBlockTable(table, value) { }); } +function ensureSpace(state) { + state.flushClose(); + if (!state.atBlank() && !state.out.endsWith(' ')) state.write(' '); +} + function unsetIsInBlockTable(table) { tableMap.delete(table); @@ -194,7 +209,8 @@ function renderTableRowAsMarkdown(state, node, isHeaderRow = false) { if (i) state.write(' | '); const { length } = state.out; - state.render(cell, node, i); + const cellContent = buffer(state, () => state.render(cell, node, i)); + state.write(cellContent.replace(/\|/g, '\\|')); cellWidths.push(state.out.length - length); }); state.write(' |'); @@ -212,13 +228,20 @@ function renderTableRowAsHTML(state, node) { renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown')); - if (!containsParagraphWithOnlyText(cell)) { - state.closeBlock(node); - state.flushClose(); - } + const buffered = buffer(state, () => { + if (!containsParagraphWithOnlyText(cell)) { + state.closeBlock(node); + state.flushClose(); + } - state.render(cell, node, i); - state.flushClose(1); + state.render(cell, node, i); + state.flushClose(1); + }); + if (buffered.includes('\\') && !buffered.includes('\n')) { + state.out += `\n\n${buffered}\n`; + } else { + state.out += buffered; + } renderTagClose(state, tag); }); @@ -253,7 +276,14 @@ export function renderContent(state, node, forceRenderInline) { export function renderHTMLNode(tagName, forceRenderContentInline = false) { return (state, node) => { renderTagOpen(state, tagName, node.attrs); - renderContent(state, node, forceRenderContentInline); + + const buffered = buffer(state, () => renderContent(state, node, forceRenderContentInline)); + if (buffered.includes('\\') && !buffered.includes('\n')) { + state.out += `\n\n${buffered}\n`; + } else { + state.out += buffered; + } + renderTagClose(state, tagName, false); if (forceRenderContentInline) { @@ -446,9 +476,15 @@ export function renderOrderedList(state, node) { } export function renderReference(state, node) { + ensureSpace(state); state.write(node.attrs.originalText || node.attrs.text); } +export function renderReferenceLabel(state, node) { + ensureSpace(state); + state.write(node.attrs.originalText || `~${state.quote(node.attrs.text)}`); +} + const generateBoldTags = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index ea444b5c146..ab5f01227fb 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -108,27 +108,5 @@ export default class ContextualSidebar { const collapse = parseBoolean(getCookie('sidebar_collapsed')); this.toggleCollapsedSidebar(collapse, true); } - - const modalEl = document.querySelector('.js-invite-members-modal'); - if (modalEl) { - import( - /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal' - ) - .then(({ default: initInviteMembersModal }) => { - initInviteMembersModal(); - }) - .catch(() => {}); - - const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger'); - if (inviteTriggers) { - import( - /* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger' - ) - .then(({ default: initInviteMembersTrigger }) => { - initInviteMembersTrigger(); - }) - .catch(() => {}); - } - } } } diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue new file mode 100644 index 00000000000..a7787ae84bc --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue @@ -0,0 +1,65 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TargetLink from '../target_link.vue'; +import ResourceParentLink from '../resource_parent_link.vue'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventApproved', + i18n: { + message: s__( + 'ContributionEvent|Approved merge request %{targetLink} in %{resourceParentLink}.', + ), + }, + components: { ContributionEventBase, GlSprintf, TargetLink, ResourceParentLink }, + props: { + /** + * Expected format + * { + * created_at: string; + * action: "approved" + * author: { + * id: number; + * username: string; + * name: string; + * state: string; + * avatar_url: string; + * web_url: string; + * }; + * target: { + * id: number; + * type: "MergeRequest" + * title: string; + * reference_link_text: string; + * web_url: string; + * }; + * resource_parent: { + * type: "project"; + * full_name: string; + * full_path: string; + * web_url: string; + * avatar_url: string; + * }; + * }; + */ + event: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" icon-name="approval-solid" icon-class="gl-text-green-500"> + <gl-sprintf :message="$options.i18n.message"> + <template #targetLink> + <target-link :event="event" /> + </template> + <template #resourceParentLink> + <resource-parent-link :event="event" /> + </template> + </gl-sprintf> + </contribution-event-base> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue new file mode 100644 index 00000000000..93ac94a6f4f --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue @@ -0,0 +1,54 @@ +<script> +import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { GlAvatarLabeled, GlAvatarLink, GlIcon, TimeAgoTooltip }, + props: { + event: { + type: Object, + required: true, + }, + iconName: { + type: String, + required: true, + }, + iconClass: { + type: String, + required: false, + default: null, + }, + }, + computed: { + author() { + return this.event.author; + }, + authorUsername() { + return `@${this.author.username}`; + }, + }, +}; +</script> + +<template> + <li class="gl-mt-5 gl-pb-5 gl-border-b gl-relative"> + <time-ago-tooltip :time="event.created_at" class="gl-float-right gl-text-secondary" /> + <gl-avatar-link :href="author.web_url"> + <gl-avatar-labeled + :label="author.name" + :sub-label="authorUsername" + :src="author.avatar_url" + :size="32" + /> + </gl-avatar-link> + <div class="gl-pl-8 gl-mt-2" data-testid="event-body"> + <div class="gl-text-secondary"> + <gl-icon :class="iconClass" :name="iconName" /> + <slot></slot> + </div> + <div v-if="$scopedSlots['additional-info']" class="gl-mt-2"> + <slot name="additional-info"></slot> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue new file mode 100644 index 00000000000..41ec4f5692e --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue @@ -0,0 +1,119 @@ +<script> +import EmptyComponent from '~/vue_shared/components/empty_component'; +import { EVENT_TYPE_APPROVED } from '../constants'; +import ContributionEventApproved from './contribution_event/contribution_event_approved.vue'; + +export default { + props: { + /** + * Expected format + * { + * created_at: string; + * action: + * | "created" + * | "updated" + * | "closed" + * | "reopened" + * | "pushed" + * | "commented" + * | "merged" + * | "joined" + * | "left" + * | "destroyed" + * | "expired" + * | "approved" + * | "private"; + * ref?: { + * type: "branch" | "tag"; + * count: number; + * name: string; + * path: string; + * is_new: boolean; + * is_removed: boolean; + * }; + * commit?: { + * truncated_sha: string; + * path: string; + * title: string; + * count: number; + * create_mr_path: string; + * from_truncated_sha?: string; + * to_truncated_sha?: string; + * compare_path?: string; + * }; + * author: { + * id: number; + * username: string; + * name: string; + * state: string; + * avatar_url: string; + * web_url: string; + * }; + * noteable?: { + * type: string; + * reference_link_text: string; + * web_url: string; + * first_line_in_markdown: string; + * }; + * target?: { + * id: number; + * type: + * | "Issue" + * | "Milestone" + * | "MergeRequest" + * | "Note" + * | "Project" + * | "Snippet" + * | "User" + * | "WikiPage::Meta" + * | "DesignManagement::Design"; + * title: string; + * issue_type?: + * | "issue" + * | "incident" + * | "test_case" + * | "requirement" + * | "task" + * | "objective" + * | "key_result"; + * reference_link_text?: string; + * web_url: string; + * }; + * resource_parent?: { + * type: "project" | "group"; + * full_name: string; + * full_path: string; + * web_url: string; + * avatar_url: string; + * }; + * }[]; + */ + events: { + type: Array, + required: true, + }, + }, + methods: { + eventComponent(action) { + switch (action) { + case EVENT_TYPE_APPROVED: + return ContributionEventApproved; + + default: + return EmptyComponent; + } + }, + }, +}; +</script> + +<template> + <ul class="gl-list-style-none gl-p-0"> + <component + :is="eventComponent(event.action)" + v-for="(event, index) in events" + :key="index" + :event="event" + /> + </ul> +</template> diff --git a/app/assets/javascripts/contribution_events/components/resource_parent_link.vue b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue new file mode 100644 index 00000000000..5add9d788bb --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue @@ -0,0 +1,22 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { GlLink }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + resourceParent() { + return this.event.resource_parent; + }, + }, +}; +</script> + +<template> + <gl-link :href="resourceParent.web_url">{{ resourceParent.full_name }}</gl-link> +</template> diff --git a/app/assets/javascripts/contribution_events/components/target_link.vue b/app/assets/javascripts/contribution_events/components/target_link.vue new file mode 100644 index 00000000000..a661121b2fb --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/target_link.vue @@ -0,0 +1,31 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { GlLink }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + target() { + return this.event.target; + }, + targetLinkText() { + return this.target.reference_link_text; + }, + targetLinkAttributes() { + return { + href: this.target.web_url, + title: this.target.title, + }; + }, + }, +}; +</script> + +<template> + <gl-link v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link> +</template> diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js new file mode 100644 index 00000000000..05f968e7bc4 --- /dev/null +++ b/app/assets/javascripts/contribution_events/constants.js @@ -0,0 +1,14 @@ +// From app/models/event.rb#L16 +export const EVENT_TYPE_CREATED = 'created'; +export const EVENT_TYPE_UPDATED = 'updated'; +export const EVENT_TYPE_CLOSED = 'closed'; +export const EVENT_TYPE_REOPENED = 'reopened'; +export const EVENT_TYPE_PUSHED = 'pushed'; +export const EVENT_TYPE_COMMENTED = 'commented'; +export const EVENT_TYPE_MERGED = 'merged'; +export const EVENT_TYPE_JOINED = 'joined'; +export const EVENT_TYPE_LEFT = 'left'; +export const EVENT_TYPE_DESTROYED = 'destroyed'; +export const EVENT_TYPE_EXPIRED = 'expired'; +export const EVENT_TYPE_APPROVED = 'approved'; +export const EVENT_TYPE_PRIVATE = 'private'; diff --git a/app/assets/javascripts/crm/components/crm_form.vue b/app/assets/javascripts/crm/components/crm_form.vue index ea6a6892bbd..0b61ffa091e 100644 --- a/app/assets/javascripts/crm/components/crm_form.vue +++ b/app/assets/javascripts/crm/components/crm_form.vue @@ -14,6 +14,8 @@ import { MountingPortal } from 'portal-vue'; import { __ } from '~/locale'; import { logError } from '~/lib/logger'; import { getFirstPropertyValue } from '~/lib/utils/common_utils'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { INDEX_ROUTE_NAME } from '../constants'; const MSG_SAVE_CHANGES = __('Save changes'); @@ -241,17 +243,12 @@ export default { return data[keys[0]]; }, getDrawerHeaderHeight() { - const wrapperEl = document.querySelector('.content-wrapper'); - - if (wrapperEl) { - return `${wrapperEl.offsetTop}px`; - } - - return ''; + return getContentWrapperHeight(); }, }, MSG_CANCEL, INDEX_ROUTE_NAME, + DRAWER_Z_INDEX, }; </script> @@ -261,6 +258,7 @@ export default { :header-height="getDrawerHeaderHeight()" class="gl-drawer-responsive" :open="drawerOpen" + :z-index="$options.DRAWER_Z_INDEX" @close="close(false)" > <template #title> diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index a46a8d4affa..08177cd0eac 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -25,6 +25,7 @@ import syntaxHighlight from '~/syntax_highlight'; import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; import * as constants from '~/notes/constants'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { COMMENT_FORM, UPDATE_COMMENT_FORM } from '~/notes/i18n'; import Autosave from './autosave'; import loadAwardsHandler from './awards_handler'; import { defaultAutocompleteConfig } from './gfm_auto_complete'; @@ -687,26 +688,36 @@ export default class Notes { return this.renderNote(note); } - addNoteError($form) { + addNoteError(error, $form) { let formParentTimeline; if ($form.hasClass('js-main-target-form')) { formParentTimeline = $form.parents('.timeline'); } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } + + const serverErrorMessage = error?.response?.data?.errors; + + const alertMessage = serverErrorMessage + ? sprintf(COMMENT_FORM.error, { reason: serverErrorMessage.toLowerCase() }, false) + : COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK; + return this.addAlert({ - message: __( - 'Your comment could not be submitted! Please check your network connection and try again.', - ), + message: alertMessage, parent: formParentTimeline.get(0), }); } - updateNoteError() { - createAlert({ - message: __( - 'Your comment could not be updated! Please check your network connection and try again.', - ), + updateNoteError(error, $editingNote) { + const serverErrorMessage = error?.response?.data?.errors; + + const alertMessage = serverErrorMessage + ? sprintf(UPDATE_COMMENT_FORM.error, { reason: serverErrorMessage }, false) + : UPDATE_COMMENT_FORM.defaultError; + + return this.addAlert({ + message: alertMessage, + parent: $editingNote.get(0), }); } @@ -788,6 +799,8 @@ export default class Notes { const $note = $target.closest('.note'); const $currentlyEditing = $('.note.is-editing:visible'); + this.clearAlertWrapper(); + if ($currentlyEditing.length) { const isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); @@ -1777,7 +1790,7 @@ export default class Notes { $form.trigger('ajax:success', [note]); }) - .catch(() => { + .catch((error) => { // Submission failed, remove placeholder note and show Flash error message $notesContainer.find(`#${noteUniqueId}`).remove(); $submitBtn.prop('disabled', false); @@ -1806,7 +1819,7 @@ export default class Notes { $form.find('.js-note-text').val(formContentOriginal); this.reenableTargetFormSubmitButton(e); - this.addNoteError($form); + this.addNoteError(error, $form); }); } @@ -1854,14 +1867,14 @@ export default class Notes { // Submission successful! render final note element this.updateNote(data, $editingNote); }) - .catch(() => { + .catch((error) => { + $editingNote.addClass('is-editing fade-in-full').removeClass('being-posted fade-in-half'); // Submission failed, revert back to original note - $noteBodyText.html(escape(cachedNoteBodyText)); - $editingNote.removeClass('being-posted fade-in'); + $noteBodyText.html(cachedNoteBodyText); $editingNote.find('.gl-spinner').remove(); // Show Flash message about failure - this.updateNoteError(); + this.updateNoteError(error, $editingNote); }); return $closeBtn.text($closeBtn.data('originalText')); diff --git a/app/assets/javascripts/design_management/components/design_description/description_form.vue b/app/assets/javascripts/design_management/components/design_description/description_form.vue new file mode 100644 index 00000000000..890d7f80f8d --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_description/description_form.vue @@ -0,0 +1,234 @@ +<script> +import { GlButton, GlFormGroup, GlAlert, GlTooltipDirective } from '@gitlab/ui'; + +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +import updateDesignDescriptionMutation from '../../graphql/mutations/update_design_description.mutation.graphql'; +import { UPDATE_DESCRIPTION_ERROR } from '../../utils/error_messages'; + +const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox'); + +export default { + components: { + MarkdownEditor, + GlAlert, + GlButton, + GlFormGroup, + }, + directives: { + SafeHtml, + GlTooltip: GlTooltipDirective, + }, + i18n: { + edit: __('Edit'), + editDescription: s__('DesignManagement|Edit description'), + descriptionLabel: s__('DesignManagement|Design description'), + }, + formFieldProps: { + id: 'design-description', + name: 'design-description', + placeholder: s__('DesignManagement|Write a comment or drag your files here…'), + 'aria-label': s__('DesignManagement|Design description'), + }, + mixins: [glFeaturesFlagMixin()], + markdownDocsPath: helpPagePath('user/markdown'), + quickActionsDocsPath: helpPagePath('user/project/quick_actions'), + props: { + design: { + type: Object, + required: true, + }, + markdownPreviewPath: { + type: String, + required: true, + }, + designVariables: { + type: Object, + required: true, + }, + }, + data() { + return { + descriptionText: this.design.description || '', + showEditor: false, + isSubmitting: false, + errorMessage: '', + autosaveKey: `Issue/${getIdFromGraphQLId(this.design.issue.id)}/Design/${getIdFromGraphQLId( + this.design.id, + )}`, + }; + }, + computed: { + canUpdate() { + return this.design.issue?.userPermissions?.updateDesign && !this.showEditor; + }, + }, + watch: { + 'design.descriptionHtml': { + handler(newDescriptionHtml, oldDescriptionHtml) { + if (newDescriptionHtml !== oldDescriptionHtml) { + this.renderGFM(); + } + }, + immediate: true, + }, + }, + methods: { + startEditing() { + this.showEditor = true; + }, + closeForm() { + this.showEditor = false; + }, + async renderGFM() { + await this.$nextTick(); + renderGFM(this.$refs['gfm-content']); + + if (this.canUpdate) { + const checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox'); + + // enable boxes, disabled by default in markdown + checkboxes.forEach((checkbox) => { + // eslint-disable-next-line no-param-reassign + checkbox.disabled = false; + }); + } + }, + setDescriptionText(newText) { + // Do not update when cmd+enter is executed + if (!this.isSubmitting) { + this.descriptionText = newText; + } + }, + async updateDesignDescription() { + this.isSubmitting = true; + + try { + const designDescriptionInput = { description: this.descriptionText, id: this.design.id }; + + await this.$apollo.mutate({ + mutation: updateDesignDescriptionMutation, + variables: { + input: designDescriptionInput, + }, + }); + + this.closeForm(); + } catch { + this.errorMessage = UPDATE_DESCRIPTION_ERROR; + } finally { + this.isSubmitting = false; + } + }, + toggleCheckboxes(event) { + const { target } = event; + + if (isCheckbox(target)) { + target.disabled = true; + + const { sourcepos } = target.parentElement.dataset; + + if (!sourcepos) return; + + // Toggle checkboxes based on user input + this.descriptionText = toggleMarkCheckboxes({ + rawMarkdown: this.descriptionText, + checkboxChecked: target.checked, + sourcepos, + }); + + // Update the desciption text using mutation + this.updateDesignDescription(); + } + }, + }, +}; +</script> + +<template> + <div class="design-description-container"> + <gl-form-group + v-if="showEditor" + class="design-description-form common-note-form" + :label="$options.i18n.descriptionLabel" + > + <div v-if="errorMessage" class="gl-pb-3"> + <gl-alert variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + </div> + <markdown-editor + :value="descriptionText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.markdownDocsPath" + :form-field-props="$options.formFieldProps" + :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" + :quick-actions-docs-path="$options.quickActionsDocsPath" + :autosave-key="autosaveKey" + enable-autocomplete + :supports-quick-actions="false" + autofocus + @input="setDescriptionText" + @keydown.meta.enter="updateDesignDescription" + @keydown.ctrl.enter="updateDesignDescription" + @keydown.exact.esc.stop="closeForm" + /> + <div class="gl-display-flex gl-mt-3"> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateDesignDescription" + >{{ s__('DesignManagement|Save') }} + </gl-button> + <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="closeForm" + >{{ s__('DesignManagement|Cancel') }} + </gl-button> + </div> + </gl-form-group> + <div v-else class="design-description-view"> + <div + class="design-description-header gl-display-flex gl-justify-content-space-between gl-mb-2" + > + <label class="gl-m-0"> + {{ $options.i18n.descriptionLabel }} + </label> + <gl-button + v-if="canUpdate" + v-gl-tooltip + class="gl-ml-auto" + size="small" + data-testid="edit-description" + :aria-label="$options.i18n.editDescription" + @click="startEditing" + > + {{ $options.i18n.edit }} + </gl-button> + </div> + <div + v-if="!design.descriptionHtml" + data-testid="design-description-none" + class="gl-text-secondary gl-mb-5" + > + {{ s__('DesignManagement|None') }} + </div> + <div v-else class="design-description js-task-list-container"> + <div + ref="gfm-content" + v-safe-html="design.descriptionHtml" + class="md gl-mb-4" + data-testid="design-description-content" + @change="toggleCheckboxes" + ></div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 680a101b118..5affd448419 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLink, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { createAlert } from '~/alert'; import { __, s__ } from '~/locale'; @@ -43,6 +43,7 @@ export default { ReplyPlaceholder, TimeAgoTooltip, ToggleRepliesWidget, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -311,7 +312,6 @@ export default { :loading="isResolving" category="tertiary" data-testid="resolve-button" - size="small" @click.stop="toggleResolvedStatus" /> </template> @@ -372,10 +372,13 @@ export default { @cancel-form="hideForm" > <template v-if="discussion.resolvable" #resolve-checkbox> - <label data-testid="resolve-checkbox"> - <input v-model="shouldChangeResolvedStatus" type="checkbox" /> + <gl-form-checkbox + v-model="shouldChangeResolvedStatus" + class="gl-mt-5 gl-mb-n3" + data-testid="resolve-checkbox" + > {{ resolveCheckboxText }} - </label> + </gl-form-checkbox> </template> </design-reply-form> </template> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index b92a2392948..0eac2cad68d 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -3,8 +3,7 @@ import { GlAvatar, GlAvatarLink, GlButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlLink, GlTooltipDirective, } from '@gitlab/ui'; @@ -29,8 +28,7 @@ export default { GlAvatar, GlAvatarLink, GlButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlLink, TimeAgoTooltip, TimelineEntryItem, @@ -83,15 +81,38 @@ export default { id: this.note.id, }; }, - isEditButtonVisible() { - return !this.isEditing && this.adminPermissions; - }, - isMoreActionsButtonVisible() { + isEditingAndHasPermissions() { return !this.isEditing && this.adminPermissions; }, adminPermissions() { return this.note.userPermissions.adminNote; }, + dropdownItems() { + return [ + { + text: this.$options.i18n.editCommentLabel, + action: () => { + this.isEditing = true; + }, + extraAttrs: { + 'data-testid': 'delete-note-button', + 'data-qa-selector': 'delete_design_note_button', + class: 'gl-sm-display-none!', + }, + }, + { + text: this.$options.i18n.deleteCommentText, + action: () => { + this.$emit('delete-note', this.note); + }, + extraAttrs: { + 'data-testid': 'delete-note-button', + 'data-qa-selector': 'delete_design_note_button', + class: 'gl-text-red-500!', + }, + }, + ]; + }, }, methods: { hideForm() { @@ -131,50 +152,41 @@ export default { <span class="note-headline-light note-headline-meta"> <span class="system-note-message"> <slot></slot> </span> <gl-link - class="note-timestamp system-note-separator gl-display-block gl-mb-2" + class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm" :href="`#note_${noteAnchorId}`" > <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> </gl-link> </span> </div> - <div class="gl-display-flex gl-align-items-baseline"> + <div class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2"> <slot name="resolve-discussion"></slot> <gl-button - v-if="isEditButtonVisible" + v-if="isEditingAndHasPermissions" v-gl-tooltip + class="gl-display-none gl-sm-display-inline-flex!" :aria-label="$options.i18n.editCommentLabel" :title="$options.i18n.editCommentLabel" category="tertiary" data-testid="note-edit" icon="pencil" - size="small" @click="isEditing = true" /> - <gl-dropdown - v-if="isMoreActionsButtonVisible" + <gl-disclosure-dropdown + v-if="isEditingAndHasPermissions" v-gl-tooltip.hover - class="gl-display-none gl-sm-display-inline-flex! gl-ml-3" + toggle-class="btn-sm" icon="ellipsis_v" category="tertiary" data-qa-selector="design_discussion_actions_ellipsis_dropdown" data-testid="more-actions-dropdown" - :text="$options.i18n.moreActionsLabel" text-sr-only :title="$options.i18n.moreActionsLabel" :aria-label="$options.i18n.moreActionsLabel" no-caret left - > - <gl-dropdown-item - variant="danger" - data-qa-selector="delete_design_note_button" - data-testid="delete-note-button" - @click="$emit('delete-note', note)" - > - {{ $options.i18n.deleteCommentText }} - </gl-dropdown-item> - </gl-dropdown> + :items="dropdownItems" + /> </div> </div> <template v-if="!isEditing"> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 4fd90130284..7474f8f3298 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -105,7 +105,7 @@ export default { */ this.$nextTick(() => { if (!this.noteUpdateDirty) { - this.autosaveDiscussion.reset(); + this.autosaveDiscussion?.reset(); } }); }, diff --git a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue index 2e366282de3..189ddda525b 100644 --- a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue +++ b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue @@ -39,31 +39,31 @@ export default { <template> <li - class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3" + class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3 gl-min-h-8" :class="{ expanded: !collapsed }" data-testid="toggle-comments-wrapper" > <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" /> <gl-button variant="link" - class="toggle-comments-button gl-ml-2 gl-mr-2" + class="toggle-comments-button gl-ml-2 gl-mr-2 gl-font-sm!" @click.stop="$emit('toggle')" > {{ toggleText }} </gl-button> <template v-if="collapsed"> - <span class="gl-text-gray-500">{{ __('Last reply by') }}</span> + <span class="gl-text-gray-500 gl-font-sm">{{ __('Last reply by') }}</span> <gl-link :href="lastReply.author.webUrl" target="_blank" - class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" + class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-font-sm gl-ml-2 gl-mr-2" > {{ lastReply.author.name }} </gl-link> <time-ago-tooltip :time="lastReply.createdAt" tooltip-placement="bottom" - class="gl-text-gray-500" + class="gl-text-gray-500 gl-font-sm" /> </template> </li> diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index c34d5cea0c2..9a8685f4c86 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -9,6 +9,7 @@ import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; import DesignDiscussion from './design_notes/design_discussion.vue'; +import DescriptionForm from './design_description/description_form.vue'; import DesignNoteSignedOut from './design_notes/design_note_signed_out.vue'; import DesignTodoButton from './design_todo_button.vue'; @@ -21,6 +22,7 @@ export default { GlAccordionItem, GlSkeletonLoader, DesignTodoButton, + DescriptionForm, }, mixins: [glFeatureFlagsMixin()], inject: { @@ -54,6 +56,10 @@ export default { type: Boolean, required: true, }, + designVariables: { + type: Object, + required: true, + }, }, data() { return { @@ -143,6 +149,12 @@ export default { :href="issue.webUrl" >{{ issue.webPath }}</a > + <description-form + v-if="!isLoading" + :design="design" + :design-variables="designVariables" + :markdown-preview-path="markdownPreviewPath" + /> <participants :participants="discussionParticipants" :show-participant-label="false" diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 9c1bcf5bf90..8339034fae9 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -128,10 +128,10 @@ export default { params: { id: filename }, query: $route.query, }" - class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0" > <div - class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base" > <div v-if="icon.name" diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql index 9bd70e7e886..575201a7635 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql @@ -5,6 +5,8 @@ fragment DesignListItem on Design { notesCount image imageV432x230 + description + descriptionHtml currentUserTodos(state: pending) { nodes { id diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql new file mode 100644 index 00000000000..78b66477747 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateDesignDescriptionMutation($input: DesignManagementUpdateInput!) { + designManagementUpdate(input: $input) { + errors + design { + id + image + description + descriptionHtml + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 730467c33f6..c6eda2797d5 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -25,6 +25,10 @@ query getDesign( ...Author } } + userPermissions { + createDesign + updateDesign + } } } } diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index b182e68260a..5e520f791ad 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -43,7 +43,7 @@ export default { }); this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); } - if (this.designCollection.copyState === 'ERROR') { + if (this.designCollection?.copyState === 'ERROR') { createAlert({ message: s__( 'DesignManagement|There was an error moving your designs. Please upload your designs below.', diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index eeb36e59b89..65e04b1ff98 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -385,6 +385,7 @@ export default { </div> <design-sidebar :design="design" + :design-variables="designVariables" :resolved-discussions-expanded="resolvedDiscussionsExpanded" :markdown-preview-path="markdownPreviewPath" :is-loading="isLoading" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index dcc65c957fe..e7308aad785 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -38,6 +38,11 @@ import { } from '../utils/error_messages'; import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking'; +export const i18n = { + dropzoneDescriptionText: __('Drag your designs here or %{linkStart}click to upload%{linkEnd}.'), + designLoadingError: __('An error occurred while loading designs. Please try again.'), +}; + export default { components: { GlLoadingIcon, @@ -346,9 +351,7 @@ export default { animation: 200, ghostClass: 'gl-visibility-hidden', }, - i18n: { - dropzoneDescriptionText: __('Drag your designs here or %{linkStart}click to upload%{linkEnd}.'), - }, + i18n, }; </script> @@ -370,7 +373,7 @@ export default { </gl-alert> <header v-if="showToolbar" - class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!" + class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-top-base" data-testid="design-toolbar-wrapper" > <div @@ -427,7 +430,7 @@ export default { <div :class="designContentWrapperClass"> <gl-loading-icon v-if="isLoading" size="lg" /> <gl-alert v-else-if="error" variant="danger" :dismissible="false"> - {{ __('An error occurred while loading designs. Please try again.') }} + {{ $options.i18n.designLoadingError }} </gl-alert> <header v-else-if="isDesignCollectionCopying" @@ -503,7 +506,7 @@ export default { > <design-dropzone :enable-drag-behavior="isDraggingDesign" - :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" + :class="{ 'design-list-item': !isDesignListEmpty }" :display-as-card="hasDesigns" v-bind="$options.dropzoneProps" data-qa-selector="design_dropzone_content" diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index 7470f3d259b..2db34ea7103 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -61,6 +61,8 @@ export const designUploadOptimisticResponse = (files) => { id: -uniqueId(), image: '', imageV432x230: '', + description: '', + descriptionHtml: '', filename: file.name, fullPath: '', notesCount: 0, diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js index 1ed054abe22..2b5d04959b4 100644 --- a/app/assets/javascripts/design_management/utils/error_messages.js +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -138,3 +138,7 @@ export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf( upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, }, ); + +export const UPDATE_DESCRIPTION_ERROR = s__( + 'DesignManagement|Could not update description. Please try again.', +); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 02307150e2f..c0a9643e59e 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -90,22 +90,6 @@ export default { ALERT_COLLAPSED_FILES, }, props: { - endpoint: { - type: String, - required: true, - }, - endpointMetadata: { - type: String, - required: true, - }, - endpointBatch: { - type: String, - required: true, - }, - endpointDiffForPath: { - type: String, - required: true, - }, endpointCoverage: { type: String, required: false, @@ -116,15 +100,6 @@ export default { required: false, default: '', }, - endpointUpdateUser: { - type: String, - required: false, - default: '', - }, - projectPath: { - type: String, - required: true, - }, shouldShow: { type: Boolean, required: false, @@ -144,51 +119,6 @@ export default { required: false, default: '', }, - isFluidLayout: { - type: Boolean, - required: false, - default: false, - }, - dismissEndpoint: { - type: String, - required: false, - default: '', - }, - showSuggestPopover: { - type: Boolean, - required: false, - default: false, - }, - fileByFileUserPreference: { - type: Boolean, - required: false, - default: false, - }, - defaultSuggestionCommitMessage: { - type: String, - required: false, - default: '', - }, - rehydratedMrReviews: { - type: Object, - required: false, - default: () => ({}), - }, - sourceProjectDefaultUrl: { - type: String, - required: false, - default: '', - }, - sourceProjectFullPath: { - type: String, - required: false, - default: '', - }, - isForked: { - type: Boolean, - required: false, - default: false, - }, }, data() { const treeWidth = @@ -325,7 +255,7 @@ export default { this.adjustView(); }, viewDiffsFileByFile(newViewFileByFile) { - if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) { + if (!newViewFileByFile && this.diffsIncomplete) { this.refetchDiffData({ refetchMeta: false }); } }, @@ -343,21 +273,6 @@ export default { renderFileTree: 'adjustView', }, mounted() { - this.setBaseConfig({ - endpoint: this.endpoint, - endpointMetadata: this.endpointMetadata, - endpointBatch: this.endpointBatch, - endpointDiffForPath: this.endpointDiffForPath, - endpointCoverage: this.endpointCoverage, - endpointUpdateUser: this.endpointUpdateUser, - projectPath: this.projectPath, - dismissEndpoint: this.dismissEndpoint, - showSuggestPopover: this.showSuggestPopover, - viewDiffsFileByFile: this.fileByFileUserPreference || false, - defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage, - mrReviews: this.rehydratedMrReviews, - }); - if (this.endpointCodequality) { this.setCodequalityEndpoint(this.endpointCodequality); } @@ -467,26 +382,19 @@ export default { 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); - } + notesEventHub.$on('fetchedNotesData', this.rereadNoteHash); + diffsEventHub.$on('diffFilesModified', this.setDiscussions); diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData); }, unsubscribeFromEvents() { diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData); - if (this.glFeatures.singleFileFileByFile) { - notesEventHub.$off('fetchedNotesData', this.rereadNoteHash); - diffsEventHub.$off('diffFilesModified', this.setDiscussions); - } + diffsEventHub.$off('diffFilesModified', this.setDiscussions); + notesEventHub.$off('fetchedNotesData', this.rereadNoteHash); notesEventHub.$off('refetchDiffData', this.refetchDiffData); notesEventHub.$off('fetchDiffData', this.fetchData); }, navigateToDiffFileNumber(number) { - this.navigateToDiffFileIndex({ - index: number - 1, - singleFile: this.glFeatures.singleFileFileByFile, - }); + this.navigateToDiffFileIndex(number - 1); }, refetchDiffData({ refetchMeta = true } = {}) { this.fetchData({ toggleTree: false, fetchMeta: refetchMeta }); @@ -506,7 +414,7 @@ export default { if (data) { realSize = data.real_size; - if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) { + if (this.viewDiffsFileByFile) { this.fetchFileByFile(); } } @@ -527,7 +435,7 @@ export default { }); } - if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) { + if (!this.viewDiffsFileByFile) { this.fetchDiffFilesBatch() .then(() => { if (toggleTree) this.setTreeDisplay(); @@ -618,10 +526,7 @@ export default { jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) { - this.goToFile({ - path: this.flatBlobsList[targetIndex].path, - singleFile: this.glFeatures.singleFileFileByFile, - }); + this.goToFile({ path: this.flatBlobsList[targetIndex].path }); } }, setTreeDisplay() { diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index d7b63d205dc..4d02fd80ba8 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; @@ -21,6 +21,7 @@ import ImageDiffOverlay from './image_diff_overlay.vue'; export default { components: { GlLoadingIcon, + GlButton, DiffView, DiffViewer, NoteForm, @@ -59,7 +60,10 @@ export default { return this.diffFile.viewer.name; }, isTextFile() { - return this.diffViewerMode === diffViewerModes.text; + return this.diffViewerMode === diffViewerModes.text && !this.diffFile.viewer.whitespace_only; + }, + isWhitespaceOnly() { + return this.diffFile.viewer.whitespace_only; }, noPreview() { return this.diffViewerMode === diffViewerModes.no_preview; @@ -71,7 +75,10 @@ export default { return this.getCommentFormForDiffFile(this.diffFileHash); }, showNotesContainer() { - return this.imageDiscussions.length || this.diffFileCommentForm; + return ( + this.diffViewerMode === diffViewerModes.image && + (this.imageDiscussionsWithDrafts.length || this.diffFileCommentForm) + ); }, diffFileHash() { return this.diffFile.file_hash; @@ -83,6 +90,11 @@ export default { // TODO: Do this data generation when we receive a response to save a computed property being created return this.diffLines(this.diffFile).map(mapParallel(this)) || []; }, + imageDiscussions() { + return this.diffFile.discussions.filter( + (f) => f.position?.position_type === IMAGE_DIFF_POSITION_TYPE, + ); + }, }, updated() { this.$nextTick(() => { @@ -107,6 +119,7 @@ export default { }); }, }, + IMAGE_DIFF_POSITION_TYPE, }; </script> @@ -122,6 +135,23 @@ export default { /> <gl-loading-icon v-if="diffFile.renderingLines" size="lg" class="mt-3" /> </template> + <div + v-else-if="isWhitespaceOnly" + class="gl-bg-gray-10 gl--flex-center gl-h-13" + data-testid="diff-whitespace-only-state" + > + {{ __('Contains only whitespace changes.') }} + <gl-button + category="tertiary" + variant="info" + size="small" + class="gl-ml-3" + data-testid="diff-load-file-button" + @click="$emit('load-file', { w: '0' })" + > + {{ __('Show changes') }} + </gl-button> + </div> <not-diffable-viewer v-else-if="notDiffable" /> <no-preview-viewer v-else-if="noPreview" /> <diff-viewer @@ -160,13 +190,17 @@ export default { class="d-none d-sm-block new-comment" /> <diff-discussions - v-if="diffFile.discussions.length" + v-if="imageDiscussions.length" class="diff-file-discussions" - :discussions="diffFile.discussions" + :discussions="imageDiscussions" should-collapse-discussions render-avatar-badge /> - <diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" /> + <diff-file-drafts + :file-hash="diffFileHash" + :position-type="$options.IMAGE_DIFF_POSITION_TYPE" + class="diff-file-discussions" + /> <note-form v-if="diffFileCommentForm" ref="noteForm" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 4c2cb83ffb3..8e1c6cecbd1 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -12,6 +12,9 @@ import { scrollToElement } from '~/lib/utils/common_utils'; import { sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '~/notes/event_hub'; +import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; +import NoteForm from '~/notes/components/note_form.vue'; +import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, @@ -19,10 +22,12 @@ import { EVT_EXPAND_ALL_FILES, EVT_PERF_MARK_DIFF_FILES_END, EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, + FILE_DIFF_POSITION_TYPE, } from '../constants'; import eventHub from '../event_hub'; import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n'; import { collapsedType, getShortShaFromFile } from '../utils/diff_file'; +import DiffDiscussions from './diff_discussions.vue'; import DiffFileHeader from './diff_file_header.vue'; export default { @@ -33,11 +38,18 @@ export default { GlLoadingIcon, GlSprintf, GlAlert, + DiffFileDrafts, + NoteForm, + DiffDiscussions, }, directives: { SafeHtml, }, - mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.file.file_hash })], + mixins: [ + glFeatureFlagsMixin(), + IdState({ idProp: (vm) => vm.file.file_hash }), + diffLineNoteFormMixin, + ], props: { file: { type: Object, @@ -101,7 +113,7 @@ export default { 'conflictResolutionPath', 'canMerge', ]), - ...mapGetters(['isNotesFetched']), + ...mapGetters(['isNotesFetched', 'getNoteableData', 'noteableType']), ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']), viewBlobHref() { return escape(this.file.view_path); @@ -175,6 +187,21 @@ export default { return this.file.viewer?.manuallyCollapsed; }, + fileDiscussions() { + return this.file.discussions.filter( + (f) => f.position?.position_type === FILE_DIFF_POSITION_TYPE, + ); + }, + showFileDiscussions() { + return ( + this.glFeatures.commentOnFiles && + !this.file.viewer?.manuallyCollapsed && + (this.fileDiscussions.length || this.file.drafts.length || this.file.hasCommentForm) + ); + }, + diffFileHash() { + return this.file.file_hash; + }, }, watch: { 'file.id': { @@ -187,6 +214,9 @@ export default { 'file.file_hash': { handler: function hashChangeWatch(newHash, oldHash) { if ( + this.viewDiffsFileByFile && + !this.isCollapsed && + !this.glFeatures.singleFileFileByFile && newHash && oldHash && !this.hasDiff && @@ -209,12 +239,6 @@ export default { if (this.hasDiff) { this.postRender(); - } else if ( - this.viewDiffsFileByFile && - !this.isCollapsed && - !this.glFeatures.singleFileFileByFile - ) { - this.requestDiff(); } this.manageViewedEffects(); @@ -230,6 +254,8 @@ export default { 'assignDiscussionsToDiff', 'setRenderIt', 'setFileCollapsedByUser', + 'saveDiffDiscussion', + 'toggleFileCommentForm', ]), manageViewedEffects() { if ( @@ -281,12 +307,12 @@ export default { this.requestDiff(); } }, - requestDiff() { + requestDiff(params = {}) { const { idState, file } = this; idState.isLoadingCollapsedDiff = true; - this.loadCollapsedDiff(file) + this.loadCollapsedDiff({ file, params }) .then(() => { idState.isLoadingCollapsedDiff = false; idState.hasLoadedCollapsedDiff = true; @@ -319,8 +345,20 @@ export default { hideForkMessage() { this.idState.forkMessageVisible = false; }, + handleSaveNote(note) { + this.saveDiffDiscussion({ + note, + formData: { + noteableData: this.getNoteableData, + noteableType: this.noteableType, + diffFile: this.file, + positionType: FILE_DIFF_POSITION_TYPE, + }, + }); + }, }, CONFLICT_TEXT, + FILE_DIFF_POSITION_TYPE, }; </script> @@ -425,6 +463,35 @@ export default { </template> </gl-sprintf> </gl-alert> + <div v-if="showFileDiscussions" class="gl-border-b" data-testid="file-discussions"> + <div class="diff-file-discussions-wrapper"> + <diff-discussions + v-if="fileDiscussions.length" + class="diff-file-discussions" + data-testid="diff-file-discussions" + :discussions="fileDiscussions" + /> + <diff-file-drafts + :file-hash="file.file_hash" + :show-pin="false" + :position-type="$options.FILE_DIFF_POSITION_TYPE" + class="diff-file-discussions" + /> + <note-form + v-if="file.hasCommentForm" + :save-button-title="__('Comment')" + :diff-file="file" + autofocus + class="gl-py-3 gl-px-5" + data-testid="file-note-form" + @handleFormUpdate="handleSaveNote" + @handleFormUpdateAddToReview=" + (note) => addToReview(note, $options.FILE_DIFF_POSITION_TYPE) + " + @cancelForm="toggleFileCommentForm(file.file_path)" + /> + </div> + </div> <gl-loading-icon v-if="showLoadingIcon" size="sm" @@ -464,6 +531,7 @@ export default { :class="hasBodyClasses.content" :diff-file="file" :help-page-path="helpPagePath" + @load-file="requestDiff" /> </template> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 792be3de1e5..494a20045f7 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -115,6 +115,7 @@ export default { computed: { ...mapState('diffs', ['latestDiff']), ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), + ...mapGetters(['getNoteableData']), diffContentIDSelector() { return `#diff-content-${this.diffFile.file_hash}`; }, @@ -210,6 +211,9 @@ export default { labelToggleFile() { return this.expanded ? __('Hide file contents') : __('Show file contents'); }, + showCommentButton() { + return this.getNoteableData.current_user.can_create_note && this.glFeatures.commentOnFiles; + }, }, watch: { 'idState.moreActionsShown': { @@ -233,6 +237,7 @@ export default { 'reviewFile', 'setFileCollapsedByUser', 'setGenerateTestFilePath', + 'toggleFileCommentForm', ]), handleToggleFile() { this.$emit('toggleFile'); @@ -389,6 +394,18 @@ export default { > {{ $options.i18n.fileReviewLabel }} </gl-form-checkbox> + <gl-button + v-if="showCommentButton" + v-gl-tooltip.hover + :title="__('Comment on this file')" + :aria-label="__('Comment on this file')" + icon="comment" + category="tertiary" + size="small" + class="gl-mr-3 btn-icon" + data-testid="comment-files-button" + @click="toggleFileCommentForm(diffFile.file_path)" + /> <gl-button-group class="gl-pt-0!"> <gl-button v-if="diffFile.external_url" @@ -445,7 +462,10 @@ export default { v-if="showGenerateTestFileButton" @click="setGenerateTestFilePath(diffFile.new_path)" > - {{ __('Generate test with AI') }} + <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + {{ __('Suggest test cases') }} + <gl-icon name="tanuki-ai" class="gl-text-purple-600 gl-mr-n3" /> + </span> </gl-dropdown-item> <gl-dropdown-item v-if="diffFile.replaced_view_path" 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 43ba527dad8..9ddf5b51c9a 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -240,6 +240,7 @@ export default { :show-suggest-popover="showSuggestPopover" :save-button-title="__('Comment')" :autosave-key="autosaveKey" + :autofocus="false" class="diff-comment-form gl-mt-3" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 348d6d1d78d..7c87ea1cbf2 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,5 +1,6 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; +import { throttle } from 'lodash'; import { IdState } from 'vendor/vue-virtual-scroller'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; @@ -77,6 +78,9 @@ export default { return this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0; }, }, + created() { + this.onDragOverThrottled = throttle((line) => this.onDragOver(line), 100, { leading: true }); + }, methods: { ...mapActions(['setSelectedCommentPosition']), ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), @@ -255,7 +259,7 @@ export default { ({ lineCode, expanded }) => toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded }) " - @enterdragging="onDragOver" + @enterdragging="onDragOverThrottled" @startdragging="onStartDragging" @stopdragging="onStopDragging" /> diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue index da880c6f3ca..2cffe928d7b 100644 --- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -30,8 +30,8 @@ export default { ALLOWED_ATTR: ['href', 'rel'], }, computed: { - drawerOffsetTop() { - return getContentWrapperHeight('.content-wrapper'); + getDrawerHeaderHeight() { + return getContentWrapperHeight(); }, }, DRAWER_Z_INDEX, @@ -47,7 +47,7 @@ export default { </script> <template> <gl-drawer - :header-height="drawerOffsetTop" + :header-height="getDrawerHeaderHeight" :z-index="$options.DRAWER_Z_INDEX" class="findings-drawer" :open="Object.keys(drawer).length !== 0" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 4f1875e9175..b9bfceee6b4 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -5,11 +5,13 @@ 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 { contentTop } from '~/lib/utils/common_utils'; import DiffFileRow from './diff_file_row.vue'; const MODIFIER_KEY = getModifierKey(); +const MAX_ITEMS_ON_NARROW_SCREEN = 8; +const BOTTOM_MARGIN = 16; export default { directives: { @@ -20,7 +22,6 @@ export default { DiffFileRow, RecycleScroller, }, - mixins: [glFeatureFlagsMixin()], props: { hideFileStats: { type: Boolean, @@ -31,13 +32,16 @@ export default { return { search: '', scrollerHeight: 0, - resizeObserver: null, rowHeight: 0, debouncedHeightCalc: null, + reviewBarHeight: 0, + largeBreakpointSize: 0, }; }, computed: { ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']), + ...mapState('batchComments', ['reviewBarRendered']), + ...mapGetters('batchComments', ['draftsCount']), ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { let search = this.search.toLowerCase().trim(); @@ -90,21 +94,44 @@ export default { return result; }, + reviewBarEnabled() { + return this.draftsCount > 0; + }, + }, + watch: { + reviewBarEnabled() { + this.debouncedHeightCalc(); + }, + calculateReviewBarHeight() { + this.debouncedHeightCalc(); + }, }, created() { this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50); }, mounted() { const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height'); + const breakpointProp = getComputedStyle(window.document.body).getPropertyValue( + '--breakpoint-lg', + ); + this.largeBreakpointSize = parseInt(breakpointProp, 10); this.rowHeight = parseInt(heightProp, 10); this.calculateScrollerHeight(); - this.resizeObserver = new ResizeObserver(() => { - this.debouncedHeightCalc(); - }); - this.resizeObserver.observe(this.$refs.scrollRoot); + let stop; + // eslint-disable-next-line prefer-const + stop = this.$watch( + () => this.reviewBarRendered, + (enabled) => { + if (!enabled) return; + this.calculateReviewBarHeight(); + stop(); + }, + { immediate: true }, + ); + window.addEventListener('resize', this.debouncedHeightCalc, { passive: true }); }, beforeDestroy() { - this.resizeObserver.disconnect(); + window.removeEventListener('resize', this.debouncedHeightCalc, { passive: true }); }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), @@ -112,7 +139,20 @@ export default { this.search = ''; }, calculateScrollerHeight() { - this.scrollerHeight = this.$refs.scrollRoot.clientHeight; + if (window.matchMedia(`(max-width: ${this.largeBreakpointSize - 1}px)`).matches) { + this.calculateMobileScrollerHeight(); + } else { + let clipping = BOTTOM_MARGIN; + if (this.reviewBarEnabled) clipping += this.reviewBarHeight; + this.scrollerHeight = this.$refs.scrollRoot.clientHeight - clipping; + } + }, + calculateMobileScrollerHeight() { + const maxItems = Math.min(MAX_ITEMS_ON_NARROW_SCREEN, this.flatFilteredTreeList.length); + this.scrollerHeight = Math.min(maxItems * this.rowHeight, window.innerHeight - contentTop()); + }, + calculateReviewBarHeight() { + this.reviewBarHeight = document.querySelector('.js-review-bar')?.offsetHeight || 0; }, }, searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), { @@ -130,7 +170,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="gl-absolute gl-top-5 tree-list-icon" /> + <gl-icon name="search" class="gl-absolute gl-top-3 gl-left-3 tree-list-icon" /> <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label> <input id="diff-tree-search" @@ -149,7 +189,7 @@ export default { class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" @click="clearSearch" > - <gl-icon name="close" /> + <gl-icon name="close" class="gl-absolute gl-top-3 gl-right-1 tree-list-icon" /> </button> </div> </div> @@ -177,7 +217,7 @@ export default { :class="{ 'tree-list-parent': item.level > 0 }" class="gl-relative" @toggleTreeOpen="toggleTreeOpen" - @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })" + @clickFile="(path) => goToFile({ path })" /> </template> <template #after> @@ -196,13 +236,6 @@ export default { margin-left: 12px; } -.diff-tree-search-shortcut { - top: 50%; - right: 10px; - transform: translateY(-50%); - pointer-events: none; -} - .tree-list-icon:not(button) { pointer-events: none; } diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 063e36fa7fb..575cd05ceb8 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -12,6 +12,7 @@ export const NEW_LINE_TYPE = 'new'; export const OLD_LINE_TYPE = 'old'; export const TEXT_DIFF_POSITION_TYPE = 'text'; export const IMAGE_DIFF_POSITION_TYPE = 'image'; +export const FILE_DIFF_POSITION_TYPE = 'file'; export const LINE_POSITION_LEFT = 'left'; export const LINE_POSITION_RIGHT = 'right'; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 53c27632c4f..29cf90dcbe2 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -9,8 +9,6 @@ import eventHub from '../notes/event_hub'; import DiffsApp from './components/app.vue'; import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants'; -import { getReviewsForMergeRequest } from './utils/file_reviews'; -import { getDerivedMergeRequestInformation } from './utils/merge_request'; export default function initDiffsApp(store = notesStore) { const el = document.getElementById('js-diffs-app'); @@ -32,26 +30,13 @@ export default function initDiffsApp(store = notesStore) { }, data() { return { - endpoint: dataset.endpoint, - endpointMetadata: dataset.endpointMetadata || '', - endpointBatch: dataset.endpointBatch || '', - endpointDiffForPath: dataset.endpointDiffForPath || '', endpointCoverage: dataset.endpointCoverage || '', endpointCodequality: dataset.endpointCodequality || '', - endpointUpdateUser: dataset.updateCurrentUserPath, - projectPath: dataset.projectPath, helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, - isFluidLayout: parseBoolean(dataset.isFluidLayout), dismissEndpoint: dataset.dismissEndpoint, - showSuggestPopover: parseBoolean(dataset.showSuggestPopover), showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault), - viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault), - defaultSuggestionCommitMessage: dataset.defaultSuggestionCommitMessage, - sourceProjectDefaultUrl: dataset.sourceProjectDefaultUrl, - sourceProjectFullPath: dataset.sourceProjectFullPath, - isForked: parseBoolean(dataset.isForked), }; }, computed: { @@ -90,31 +75,14 @@ export default function initDiffsApp(store = notesStore) { ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), }, render(createElement) { - const { mrPath } = getDerivedMergeRequestInformation({ endpoint: this.endpoint }); - return createElement('diffs-app', { props: { - endpoint: this.endpoint, - endpointMetadata: this.endpointMetadata, - endpointBatch: this.endpointBatch, - endpointDiffForPath: this.endpointDiffForPath, endpointCoverage: this.endpointCoverage, endpointCodequality: this.endpointCodequality, - endpointUpdateUser: this.endpointUpdateUser, currentUser: this.currentUser, - projectPath: this.projectPath, helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, - isFluidLayout: this.isFluidLayout, - dismissEndpoint: this.dismissEndpoint, - showSuggestPopover: this.showSuggestPopover, - fileByFileUserPreference: this.viewDiffsFileByFile, - defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage, - rehydratedMrReviews: getReviewsForMergeRequest(mrPath), - sourceProjectDefaultUrl: this.sourceProjectDefaultUrl, - sourceProjectFullPath: this.sourceProjectFullPath, - isForked: this.isForked, }, }); }, diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js index d41bb160e96..5ca9ade668c 100644 --- a/app/assets/javascripts/diffs/mixins/draft_comments.js +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -1,4 +1,5 @@ import { mapGetters } from 'vuex'; +import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; export default { computed: { @@ -10,8 +11,10 @@ export default { 'hasParallelDraftLeft', 'hasParallelDraftRight', ]), - imageDiscussions() { - return this.diffFile.discussions.concat(this.draftsForFile(this.diffFile.file_hash)); + imageDiscussionsWithDrafts() { + return this.diffFile.discussions + .filter((f) => f.position?.position_type === IMAGE_DIFF_POSITION_TYPE) + .concat(this.draftsForFile(this.diffFile.file_hash)); }, }, }; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 0668551902a..029be6ebad9 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -50,6 +50,7 @@ import { TRACKING_SINGLE_FILE_MODE, TRACKING_MULTIPLE_FILES_MODE, EVT_MR_PREPARED, + FILE_DIFF_POSITION_TYPE, } from '../constants'; import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n'; import eventHub from '../event_hub'; @@ -461,6 +462,26 @@ export const setParallelDiffViewType = ({ commit }) => { export const showCommentForm = ({ commit }, { lineCode, fileHash }) => { commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: true }); + + // The comment form for diffs gets focussed differently due to the way the virtual scroller + // works. If we focus the comment form on mount and the comment form gets removed and then + // added again the page will scroll in unexpected ways + setTimeout(() => { + const el = document.querySelector(`[data-line-code="${lineCode}"] textarea`); + + if (!el) return; + + const { bottom } = el.getBoundingClientRect(); + const overflowBottom = bottom - window.innerHeight; + + // Prevent the browser scrolling for us + // We handle the scrolling to not break the diffs virtual scroller + el.focus({ preventScroll: true }); + + if (overflowBottom > 0) { + window.scrollBy(0, Math.floor(Math.abs(overflowBottom)) + 150); + } + }); }; export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => { @@ -505,11 +526,12 @@ export const scrollToLineIfNeededParallel = (_, line) => { } }; -export const loadCollapsedDiff = ({ commit, getters, state }, file) => { +export const loadCollapsedDiff = ({ commit, getters, state }, { file, params = {} }) => { const versionPath = state.mergeRequestDiff?.version_path; const loadParams = { commit_id: getters.commitId, w: state.showWhitespace ? '0' : '1', + ...params, }; if (versionPath) { @@ -577,6 +599,7 @@ export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData } const postData = getNoteFormData({ commit: state.commit, note, + showWhitespace: state.showWhitespace, ...formData, }); @@ -592,6 +615,11 @@ export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData } .then((discussion) => dispatch('assignDiscussionsToDiff', [discussion])) .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) + .then(() => { + if (formData.positionType === FILE_DIFF_POSITION_TYPE) { + dispatch('toggleFileCommentForm', formData.diffFile.file_path); + } + }) .catch(() => createAlert({ message: s__('MergeRequests|Saving the comment failed'), @@ -607,8 +635,8 @@ 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) { +export const goToFile = ({ state, commit, dispatch, getters }, { path }) => { + if (!state.viewDiffsFileByFile) { dispatch('scrollToFile', { path }); } else { if (!state.treeEntries[path]) return; @@ -809,7 +837,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) = commit(types.REQUEST_FULL_DIFF, filePath); if (file.isShowingFullFile) { - dispatch('loadCollapsedDiff', file) + dispatch('loadCollapsedDiff', { file }) .then(() => dispatch('assignDiscussionsToDiff', getters.getDiffFileDiscussions(file))) .catch(() => dispatch('receiveFullDiffError', filePath)); } else { @@ -942,16 +970,13 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n } }; -export const navigateToDiffFileIndex = ( - { state, getters, commit, dispatch }, - { index, singleFile }, -) => { +export const navigateToDiffFileIndex = ({ state, getters, commit, dispatch }, index) => { const { fileHash } = getters.flatBlobsList[index]; document.location.hash = fileHash; commit(types.SET_CURRENT_DIFF_FILE, fileHash); - if (state.viewDiffsFileByFile && singleFile) { + if (state.viewDiffsFileByFile) { dispatch('fetchFileByFile'); } }; @@ -993,3 +1018,9 @@ export function reviewFile({ commit, state }, { file, reviewed = true }) { } export const disableVirtualScroller = ({ commit }) => commit(types.DISABLE_VIRTUAL_SCROLLING); + +export const toggleFileCommentForm = ({ commit }, filePath) => + commit(types.TOGGLE_FILE_COMMENT_FORM, filePath); + +export const addDraftToFile = ({ commit }, { filePath, draft }) => + commit(types.ADD_DRAFT_TO_FILE, { filePath, draft }); diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 10a6a872fe4..a8a831fb269 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -63,9 +63,12 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => (diff) => { * @returns {Boolean} */ export const diffHasExpandedDiscussions = () => (diff) => { - return diff[INLINE_DIFF_LINES_KEY].filter((l) => l.discussions.length >= 1).some( - (l) => l.discussionsExpanded, - ); + const diffLineDiscussionsExpanded = diff[INLINE_DIFF_LINES_KEY].filter( + (l) => l.discussions.length >= 1, + ).some((l) => l.discussionsExpanded); + const diffFileDiscussionsExpanded = diff.discussions?.some((d) => d.expanded); + + return diffFileDiscussionsExpanded || diffLineDiscussionsExpanded; }; /** @@ -74,7 +77,10 @@ export const diffHasExpandedDiscussions = () => (diff) => { * @returns {Boolean} */ export const diffHasDiscussions = () => (diff) => { - return diff[INLINE_DIFF_LINES_KEY].some((l) => l.discussions.length >= 1); + return ( + diff.discussions?.length >= 1 || + diff[INLINE_DIFF_LINES_KEY].some((l) => l.discussions.length >= 1) + ); }; /** diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 51c21c1bfc4..c32d82faad0 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -49,3 +49,6 @@ export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS'; export const DISABLE_VIRTUAL_SCROLLING = 'DISABLE_VIRTUAL_SCROLLING'; + +export const TOGGLE_FILE_COMMENT_FORM = 'TOGGLE_FILE_COMMENT_FORM'; +export const ADD_DRAFT_TO_FILE = 'ADD_DRAFT_TO_FILE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 5e7fe8b5cd8..2786e971f4b 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -5,6 +5,7 @@ import { DIFF_FILE_AUTOMATIC_COLLAPSE, INLINE_DIFF_LINES_KEY, EXPANDED_LINE_TYPE, + FILE_DIFF_POSITION_TYPE, } from '../constants'; import * as types from './mutation_types'; import { @@ -168,6 +169,7 @@ export default { const { latestDiff } = state; const originalStartLineCode = discussion.original_position?.line_range?.start?.line_code; + const positionType = discussion.position?.position_type; const discussionLineCodes = [ discussion.line_code, originalStartLineCode, @@ -212,16 +214,7 @@ export default { state.diffFiles.forEach((file) => { if (file.file_hash === fileHash) { - if (file[INLINE_DIFF_LINES_KEY].length) { - file[INLINE_DIFF_LINES_KEY].forEach((line) => { - Object.assign( - line, - setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), - ); - }); - } - - if (!file[INLINE_DIFF_LINES_KEY].length) { + if (positionType === FILE_DIFF_POSITION_TYPE) { const newDiscussions = (file.discussions || []) .filter((d) => d.id !== discussion.id) .concat(discussion); @@ -229,6 +222,25 @@ export default { Object.assign(file, { discussions: newDiscussions, }); + } else { + if (file[INLINE_DIFF_LINES_KEY].length) { + file[INLINE_DIFF_LINES_KEY].forEach((line) => { + Object.assign( + line, + setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), + ); + }); + } + + if (!file[INLINE_DIFF_LINES_KEY].length) { + const newDiscussions = (file.discussions || []) + .filter((d) => d.id !== discussion.id) + .concat(discussion); + + Object.assign(file, { + discussions: newDiscussions, + }); + } } } }); @@ -378,4 +390,14 @@ export default { [types.DISABLE_VIRTUAL_SCROLLING](state) { state.disableVirtualScroller = true; }, + [types.TOGGLE_FILE_COMMENT_FORM](state, filePath) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file.hasCommentForm = !file.hasCommentForm; + }, + [types.ADD_DRAFT_TO_FILE](state, { filePath, draft }) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file?.drafts.push(draft); + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 4ca353333b7..68536d36ac0 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -140,6 +140,7 @@ export function getFormData(params) { linePosition, positionType, lineRange, + showWhitespace, } = params; const position = JSON.stringify({ @@ -156,6 +157,7 @@ export function getFormData(params) { width: params.width, height: params.height, line_range: lineRange, + ignore_whitespace_change: !showWhitespace, }); const postData = { @@ -486,9 +488,8 @@ export const getDiffMode = (diffFile) => { const diffModeKey = Object.keys(diffModes).find((key) => diffFile[`${key}_file`]); return ( diffModes[diffModeKey] || - (diffFile.viewer && - diffFile.viewer.name === diffViewerModes.mode_changed && - diffViewerModes.mode_changed) || + (diffFile.viewer?.name === diffViewerModes.mode_changed && diffViewerModes.mode_changed) || + (diffFile.viewer?.name === diffViewerModes.no_preview && diffViewerModes.no_preview) || diffModes.replaced ); }; diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index e2fb24f7b57..f2a3224d332 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -53,6 +53,9 @@ export const isNotDiffable = (file) => file?.viewer?.name === viewerModes.not_di export function prepareRawDiffFile({ file, allFiles, meta = false, index = -1 }) { const additionalProperties = { brokenSymlink: fileSymlinkInformation(file, allFiles), + hasCommentForm: false, + discussions: file.discussions || [], + drafts: [], viewer: { ...file.viewer, ...collapsed(file), diff --git a/app/assets/javascripts/drawio/constants.js b/app/assets/javascripts/drawio/constants.js index 2e1e074db3b..2f1870087f9 100644 --- a/app/assets/javascripts/drawio/constants.js +++ b/app/assets/javascripts/drawio/constants.js @@ -1,9 +1,18 @@ /* * TODO: Make this URL configurable */ -export const DRAWIO_EDITOR_URL = - 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; // TODO Make it configurable - +export const DRAWIO_PARAMS = { + ui: 'sketch', + noSaveBtn: 1, + saveAndExit: 1, + keepmodified: 1, + spin: 1, + embed: 1, + libraries: 1, + configure: 1, + proto: 'json', + toSvg: 1, +}; export const DRAWIO_FRAME_ID = 'drawio-frame'; export const DARK_BACKGROUND_COLOR = '#202020'; diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js index 38d1cadcc63..3c411d8093c 100644 --- a/app/assets/javascripts/drawio/drawio_editor.js +++ b/app/assets/javascripts/drawio/drawio_editor.js @@ -4,8 +4,8 @@ import { darkModeEnabled } from '~/lib/utils/color_utils'; import { __ } from '~/locale'; import { setAttributes } from '~/lib/utils/dom_utils'; import { + DRAWIO_PARAMS, DARK_BACKGROUND_COLOR, - DRAWIO_EDITOR_URL, DRAWIO_FRAME_ID, DIAGRAM_BACKGROUND_COLOR, DRAWIO_IFRAME_TIMEOUT, @@ -17,7 +17,7 @@ function updateDrawioEditorState(drawIOEditorState, data) { } function postMessageToDrawioEditor(drawIOEditorState, message) { - const { origin } = new URL(DRAWIO_EDITOR_URL); + const { origin } = new URL(drawIOEditorState.drawioUrl); drawIOEditorState.iframe.contentWindow.postMessage(JSON.stringify(message), origin); } @@ -222,7 +222,7 @@ function createEditorIFrame(drawIOEditorState) { setAttributes(iframe, { id: DRAWIO_FRAME_ID, - src: DRAWIO_EDITOR_URL, + src: drawIOEditorState.drawioUrl, class: 'drawio-editor', }); @@ -256,7 +256,7 @@ function attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade) { }); } -const createDrawioEditorState = ({ filename = null }) => ({ +const createDrawioEditorState = ({ filename = null, drawioUrl }) => ({ newDiagram: true, filename, diagramSvg: null, @@ -266,10 +266,17 @@ const createDrawioEditorState = ({ filename = null }) => ({ initialized: false, dark: darkModeEnabled(), disposeEventListener: null, + drawioUrl, }); -export function launchDrawioEditor({ editorFacade, filename }) { - const drawIOEditorState = createDrawioEditorState({ filename }); +export function launchDrawioEditor({ editorFacade, filename, drawioUrl = gon.diagramsnet_url }) { + const url = new URL(drawioUrl); + + for (const [key, value] of Object.entries(DRAWIO_PARAMS)) { + url.searchParams.set(key, value); + } + + const drawIOEditorState = createDrawioEditorState({ filename, drawioUrl: url.href }); // The execution order of these two functions matter attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade); diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue index 0afee7bebe0..f2550d753d6 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -1,6 +1,5 @@ <script> import { isEmpty } from 'lodash'; -import { GlButtonGroup } from '@gitlab/ui'; import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; import { EDITOR_TOOLBAR_BUTTON_GROUPS } from '~/editor/constants'; import SourceEditorToolbarButton from './source_editor_toolbar_button.vue'; @@ -9,7 +8,6 @@ export default { name: 'SourceEditorToolbar', components: { SourceEditorToolbarButton, - GlButtonGroup, }, data() { return { @@ -52,31 +50,34 @@ export default { <section v-if="isVisible" id="se-toolbar" - class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center" + class="file-buttons gl-display-flex gl-align-items-center gl-justify-content-end" > - <gl-button-group v-if="hasGroupItems($options.groups.file)"> + <div v-if="hasGroupItems($options.groups.file)"> <source-editor-toolbar-button v-for="item in getGroupItems($options.groups.file)" :key="item.id" :button="item" @click="$emit('click', item)" /> - </gl-button-group> - <gl-button-group v-if="hasGroupItems($options.groups.edit)"> + </div> + <div + v-if="hasGroupItems($options.groups.edit)" + class="md-header-toolbar gl-display-flex gl-flex-wrap gl-gap-3 gl-ml-auto" + > <source-editor-toolbar-button v-for="item in getGroupItems($options.groups.edit)" :key="item.id" :button="item" @click="$emit('click', item)" /> - </gl-button-group> - <gl-button-group v-if="hasGroupItems($options.groups.settings)" class="gl-ml-auto"> + </div> + <div v-if="hasGroupItems($options.groups.settings)" class="gl-align-self-start"> <source-editor-toolbar-button v-for="item in getGroupItems($options.groups.settings)" :key="item.id" :button="item" @click="$emit('click', item)" /> - </gl-button-group> + </div> </section> </template> diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue index 38f586f0773..996ecea04e5 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue @@ -30,6 +30,15 @@ export default { showButton() { return Object.entries(this.button).length > 0; }, + showLabel() { + if (this.button.category === 'tertiary' && this.button.icon) { + return false; + } + return true; + }, + isSelected() { + return this.button.category === 'tertiary' && this.button.selected; + }, }, mounted() { if (this.button.data) { @@ -55,11 +64,12 @@ export default { :category="button.category" :variant="button.variant" type="button" - :selected="button.selected" + :selected="isSelected" :icon="icon" :title="label" :aria-label="label" :class="button.class" @click="clickHandler($event)" - /> + ><template v-if="showLabel">{{ label }}</template></gl-button + > </template> diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 8ec83e4df1c..905126cae52 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -151,6 +151,7 @@ export class SourceEditorExtension { instance.toolbar.updateItem(EXTENSION_SOFTWRAP_ID, { selected: !isSoftWrapped, }); + document.querySelector('.soft-wrap-toggle')?.blur(); } }, }; 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 9ec1a97ba1a..60aa00da861 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 @@ -14,7 +14,6 @@ import { EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, EXTENSION_MARKDOWN_PREVIEW_LABEL, EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, - EDITOR_TOOLBAR_BUTTON_GROUPS, } from '../constants'; const fetchPreview = (text, previewMarkdownPath) => { @@ -58,9 +57,6 @@ export class EditorMarkdownPreviewExtension { this.toolbarButtons = []; this.setupPreviewAction(instance); - if (instance.toolbar) { - this.setupToolbar(instance); - } const debouncedResizeHandler = debounce((entries) => { for (const entry of entries) { @@ -104,25 +100,6 @@ export class EditorMarkdownPreviewExtension { instance.layout({ width, height }); } - setupToolbar(instance) { - this.toolbarButtons = [ - { - id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - label: EXTENSION_MARKDOWN_PREVIEW_LABEL, - icon: 'live-preview', - selected: false, - group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings, - category: 'primary', - selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, - onClick: () => instance.togglePreview(), - data: { - qaSelector: 'editor_toolbar_button', - }, - }, - ]; - instance.toolbar.addItems(this.toolbarButtons); - } - togglePreviewLayout(instance) { const { width } = instance.getLayoutInfo(); let newWidth; diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index d240ad7353a..3a1188d7aab 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -359,7 +359,7 @@ "pattern": "\\.ya?ml$" }, "rules": { - "$ref": "#/definitions/rules" + "$ref": "#/definitions/includeRules" }, "inputs": { "$ref": "#/definitions/inputs" @@ -399,6 +399,9 @@ } ] }, + "rules": { + "$ref": "#/definitions/includeRules" + }, "inputs": { "$ref": "#/definitions/inputs" } @@ -418,6 +421,9 @@ "format": "uri-reference", "pattern": "\\.ya?ml$" }, + "rules": { + "$ref": "#/definitions/includeRules" + }, "inputs": { "$ref": "#/definitions/inputs" } @@ -435,6 +441,9 @@ "type": "string", "format": "uri-reference" }, + "rules": { + "$ref": "#/definitions/includeRules" + }, "inputs": { "$ref": "#/definitions/inputs" } @@ -453,6 +462,9 @@ "format": "uri-reference", "pattern": "^https?://.+\\.ya?ml$" }, + "rules": { + "$ref": "#/definitions/includeRules" + }, "inputs": { "$ref": "#/definitions/inputs" } @@ -794,6 +806,55 @@ ] } }, + "includeRules": { + "type": [ + "array", + "null" + ], + "markdownDescription": "You can use rules to conditionally include other configuration files. [Learn More](https://docs.gitlab.com/ee/ci/yaml/includes.html#use-rules-with-include).", + "items": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "if": { + "$ref": "#/definitions/if" + }, + "exists": { + "$ref": "#/definitions/exists" + }, + "when": { + "markdownDescription": "Use `when: never` to exclude the configuration file if the condition matches. [Learn More](https://docs.gitlab.com/ee/ci/yaml/includes.html#include-with-rulesif).", + "oneOf": [ + { + "type": "string", + "enum": [ + "never", + "always" + ] + }, + { + "type": "null" + } + ] + } + } + }, + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minLength": 1, + "items": { + "type": "string" + } + } + ] + } + }, "workflowName": { "type": "string", "markdownDescription": "Defines the pipeline name. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowname).", @@ -1067,11 +1128,7 @@ "type": "string", "markdownDescription": "Determines the strategy for downloading and updating the cache. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachepolicy)", "default": "pull-push", - "enum": [ - "pull", - "push", - "pull-push" - ] + "pattern": "pull-push|pull|push|\\$\\w{1,255}" }, "unprotect": { "type": "boolean", diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js index 69c81c35bd4..4566ab20258 100644 --- a/app/assets/javascripts/ensure_data.js +++ b/app/assets/javascripts/ensure_data.js @@ -1,4 +1,4 @@ -import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg'; +import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg?raw'; import { GlEmptyState } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index b2843b79ba6..ce7a6f0abe7 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -8,7 +8,7 @@ * - Button Actions. * [Mockup](https://gitlab.com/gitlab-org/gitlab-foss/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png) */ -import deployBoardSvg from '@gitlab/svgs/dist/illustrations/deploy-boards.svg'; +import deployBoardSvg from '@gitlab/svgs/dist/illustrations/deploy-boards.svg?raw'; import { GlIcon, GlLoadingIcon, diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index b63a6897a39..7905c5cf572 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -1,39 +1,121 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getEnvironment from '../graphql/queries/environment.query.graphql'; +import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql'; import EnvironmentForm from './environment_form.vue'; export default { components: { + GlLoadingIcon, EnvironmentForm, }, - inject: ['projectEnvironmentsPath', 'updateEnvironmentPath'], + mixins: [glFeatureFlagsMixin()], + inject: ['projectEnvironmentsPath', 'updateEnvironmentPath', 'projectPath'], props: { environment: { required: true, type: Object, }, }, + apollo: { + environment: { + query: getEnvironment, + variables() { + return { + environmentName: this.environment.name, + projectFullPath: this.projectPath, + }; + }, + update(data) { + this.formEnvironment = data?.project?.environment || {}; + }, + }, + }, data() { return { - formEnvironment: { + isQueryLoading: false, + loading: false, + formEnvironment: null, + }; + }, + mounted() { + if (this.glFeatures?.environmentSettingsToGraphql) { + this.fetchWithGraphql(); + } else { + this.formEnvironment = { id: this.environment.id, name: this.environment.name, externalUrl: this.environment.external_url, - }, - loading: false, - }; + }; + } }, methods: { + async fetchWithGraphql() { + this.$apollo.addSmartQuery('environmentData', { + variables() { + return { environmentName: this.environment.name, projectFullPath: this.projectPath }; + }, + query: getEnvironment, + update(data) { + const result = data?.project?.environment || {}; + this.formEnvironment = { ...result, clusterAgentId: result?.clusterAgent?.id }; + }, + watchLoading: (isLoading) => { + this.isQueryLoading = isLoading; + }, + }); + }, onChange(environment) { this.formEnvironment = environment; }, onSubmit() { + if (this.glFeatures?.environmentSettingsToGraphql) { + this.updateWithGraphql(); + } else { + this.updateWithAxios(); + } + }, + async updateWithGraphql() { + this.loading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: updateEnvironment, + variables: { + input: { + id: this.formEnvironment.id, + externalUrl: this.formEnvironment.externalUrl, + clusterAgentId: this.formEnvironment.clusterAgentId, + }, + }, + }); + + const { errors } = data.environmentUpdate; + + if (errors.length > 0) { + throw new Error(errors[0]?.message ?? errors[0]); + } + + const { path } = data.environmentUpdate.environment; + + if (path) { + visitUrl(path); + } + } catch (error) { + const { message } = error; + createAlert({ message }); + } finally { + this.loading = false; + } + }, + updateWithAxios() { this.loading = true; axios .put(this.updateEnvironmentPath, { - id: this.environment.id, + id: this.formEnvironment.id, external_url: this.formEnvironment.externalUrl, }) .then(({ data: { path } }) => visitUrl(path)) @@ -47,7 +129,9 @@ export default { }; </script> <template> + <gl-loading-icon v-if="isQueryLoading" class="gl-mt-5" /> <environment-form + v-else-if="formEnvironment" :cancel-path="projectEnvironmentsPath" :environment="formEnvironment" :title="__('Edit environment')" diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 63169b790c7..6072d923b5f 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -4,14 +4,14 @@ * Used in the environments table. */ -import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; import setEnvironmentToDelete from '../graphql/mutations/set_environment_to_delete.mutation.graphql'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, directives: { GlModalDirective, @@ -30,11 +30,15 @@ export default { data() { return { isLoading: false, + item: { + text: s__('Environments|Delete environment'), + extraAttrs: { + variant: 'danger', + class: 'gl-text-red-500!', + }, + }, }; }, - i18n: { - title: s__('Environments|Delete environment'), - }, mounted() { if (!this.graphql) { eventHub.$on('deleteEnvironment', this.onDeleteEnvironment); @@ -65,12 +69,10 @@ export default { }; </script> <template> - <gl-dropdown-item + <gl-disclosure-dropdown-item v-gl-modal-directive.delete-environment-modal + :item="item" :loading="isLoading" - variant="danger" - @click="onClick" - > - {{ $options.i18n.title }} - </gl-dropdown-item> + @action="onClick" + /> </template> diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 62ceb66d803..266b221b481 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -1,12 +1,22 @@ <script> -import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlCollapsibleListbox, + GlLink, + GlSprintf, +} from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { isAbsolute } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { ENVIRONMENT_NEW_HELP_TEXT, ENVIRONMENT_EDIT_HELP_TEXT, } from 'ee_else_ce/environments/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql'; export default { components: { @@ -14,10 +24,15 @@ export default { GlForm, GlFormGroup, GlFormInput, + GlCollapsibleListbox, GlLink, GlSprintf, }, - inject: { protectedEnvironmentSettingsPath: { default: '' } }, + mixins: [glFeatureFlagsMixin()], + inject: { + protectedEnvironmentSettingsPath: { default: '' }, + projectPath: { default: '' }, + }, props: { environment: { required: true, @@ -47,8 +62,11 @@ export default { nameDisabledLinkText: __('How do I rename an environment?'), urlLabel: __('External URL'), urlFeedback: __('The URL should start with http:// or https://'), + agentLabel: s__('Environments|GitLab agent'), + agentHelpText: s__('Environments|Select agent'), save: __('Save'), cancel: __('Cancel'), + reset: __('Reset'), }, helpPagePath: helpPagePath('ci/environments/index.md'), renamingDisabledHelpPagePath: helpPagePath('ci/environments/index.md', { @@ -60,6 +78,10 @@ export default { name: null, url: null, }, + userAccessAuthorizedAgents: [], + loadingAgentsList: false, + selectedAgentId: this.environment.clusterAgentId, + searchTerm: '', }; }, computed: { @@ -75,6 +97,37 @@ export default { url: this.visited.url && isAbsolute(this.environment.externalUrl), }; }, + agentsList() { + return this.userAccessAuthorizedAgents.map((node) => { + return { + value: node?.agent?.id, + text: node?.agent?.name, + }; + }); + }, + dropdownToggleText() { + if (!this.selectedAgentId) { + return this.$options.i18n.agentHelpText; + } + const selectedAgentById = this.agentsList.find( + (agent) => agent.value === this.selectedAgentId, + ); + return selectedAgentById?.text || this.environment.clusterAgent?.name; + }, + filteredAgentsList() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.agentsList.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + showAgentsSelect() { + return this.glFeatures?.environmentSettingsToGraphql; + }, + }, + watch: { + environment(change) { + this.selectedAgentId = change.clusterAgentId; + }, }, methods: { onChange(env) { @@ -83,6 +136,23 @@ export default { visit(field) { this.visited[field] = true; }, + getAgentsList() { + this.$apollo.addSmartQuery('userAccessAuthorizedAgents', { + variables() { + return { projectFullPath: this.projectPath }; + }, + query: getUserAuthorizedAgents, + update: (data) => { + return data?.project?.userAccessAuthorizedAgents?.nodes || []; + }, + watchLoading: (isLoading) => { + this.loadingAgentsList = isLoading; + }, + }); + }, + onAgentSearch(search) { + this.searchTerm = search; + }, }, }; </script> @@ -153,6 +223,29 @@ export default { /> </gl-form-group> + <gl-form-group + v-if="showAgentsSelect" + :label="$options.i18n.agentLabel" + label-for="environment_agent" + > + <gl-collapsible-listbox + id="environment_agent" + v-model="selectedAgentId" + class="gl-w-full" + block + :items="filteredAgentsList" + :loading="loadingAgentsList" + :toggle-text="dropdownToggleText" + :header-text="$options.i18n.agentHelpText" + :reset-button-label="$options.i18n.reset" + :searchable="true" + @shown="getAgentsList" + @search="onAgentSearch" + @select="onChange({ ...environment, clusterAgentId: $event })" + @reset="onChange({ ...environment, clusterAgentId: null })" + /> + </gl-form-group> + <div class="gl-mr-6"> <gl-button :loading="loading" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 1486a66fe13..b02142c24cf 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -16,12 +16,10 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; import DeleteComponent from './environment_delete.vue'; import ExternalUrlComponent from './environment_external_url.vue'; -import MonitoringButtonComponent from './environment_monitoring.vue'; import PinComponent from './environment_pin.vue'; import RollbackComponent from './environment_rollback.vue'; import StopComponent from './environment_stop.vue'; @@ -43,7 +41,6 @@ export default { GlIcon, GlLink, GlSprintf, - MonitoringButtonComponent, PinComponent, DeleteComponent, RollbackComponent, @@ -57,7 +54,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [timeagoMixin, glFeatureFlagsMixin()], + mixins: [timeagoMixin], props: { model: { @@ -529,14 +526,6 @@ export default { return this.model.environment_path || ''; }, - monitoringUrl() { - return this.model.metrics_path || ''; - }, - - canShowMetricsLink() { - return Boolean(!this.glFeatures.removeMonitorMetrics && this.monitoringUrl); - }, - terminalPath() { return this.model?.terminal_path ?? ''; }, @@ -549,7 +538,6 @@ export default { return ( this.actions.length > 0 || this.externalURL || - this.canShowMetricsLink || this.canStopEnvironment || this.canDeleteEnvironment || this.canRetry @@ -571,11 +559,7 @@ export default { }, hasExtraActions() { return Boolean( - this.canRetry || - this.canShowAutoStopDate || - this.canShowMetricsLink || - this.terminalPath || - this.canDeleteEnvironment, + this.canRetry || this.canShowAutoStopDate || this.terminalPath || this.canDeleteEnvironment, ); }, }, @@ -860,14 +844,6 @@ export default { data-track-label="environment_pin" /> - <monitoring-button-component - v-if="canShowMetricsLink" - :monitoring-url="monitoringUrl" - data-track-action="click_button" - data-track-label="environment_monitoring" - data-testid="environment-monitoring" - /> - <terminal-button-component v-if="terminalPath" :terminal-path="terminalPath" diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue deleted file mode 100644 index 06c7f10223a..00000000000 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -import { GlDropdownItem } from '@gitlab/ui'; -import { __ } from '~/locale'; -/** - * Renders the Monitoring (Metrics) link in environments table. - */ -export default { - components: { - GlDropdownItem, - }, - props: { - monitoringUrl: { - type: String, - required: true, - }, - }, - title: __('Monitoring'), -}; -</script> -<template> - <gl-dropdown-item :href="monitoringUrl" rel="noopener noreferrer nofollow" target="_blank"> - {{ $options.title }} - </gl-dropdown-item> -</template> diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue index f5a83b97552..1a63bfa2c1c 100644 --- a/app/assets/javascripts/environments/components/environment_pin.vue +++ b/app/assets/javascripts/environments/components/environment_pin.vue @@ -3,14 +3,14 @@ * Renders a prevent auto-stop button. * Used in environments table. */ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; import eventHub from '../event_hub'; import cancelAutoStopMutation from '../graphql/mutations/cancel_auto_stop.mutation.graphql'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { autoStopUrl: { @@ -23,6 +23,11 @@ export default { default: false, }, }, + data() { + return { + item: { text: __('Prevent auto-stopping') }, + }; + }, methods: { onPinClick() { if (this.graphql) { @@ -35,11 +40,8 @@ export default { } }, }, - title: __('Prevent auto-stopping'), }; </script> <template> - <gl-dropdown-item @click="onPinClick"> - {{ $options.title }} - </gl-dropdown-item> + <gl-disclosure-dropdown-item :item="item" @action="onPinClick" /> </template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index f7a853f3128..291d8558a74 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -5,14 +5,14 @@ * * Makes a post request when the button is clicked. */ -import { GlModalDirective, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; import setEnvironmentToRollback from '../graphql/mutations/set_environment_to_rollback.mutation.graphql'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, directives: { GlModal: GlModalDirective, @@ -41,12 +41,14 @@ export default { }, }, - computed: { - title() { - return this.isLastDeployment - ? s__('Environments|Re-deploy to environment') - : s__('Environments|Rollback environment'); - }, + data() { + return { + item: { + text: this.isLastDeployment + ? s__('Environments|Re-deploy to environment') + : s__('Environments|Rollback environment'), + }, + }; }, methods: { @@ -71,7 +73,5 @@ export default { }; </script> <template> - <gl-dropdown-item v-gl-modal.confirm-rollback-modal @click="onClick"> - {{ title }} - </gl-dropdown-item> + <gl-disclosure-dropdown-item v-gl-modal.confirm-rollback-modal :item="item" @action="onClick" /> </template> diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 0df07f0457f..1c4209397b1 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -3,12 +3,12 @@ * Renders a terminal button to open a web terminal. * Used in environments table. */ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { terminalPath: { @@ -22,11 +22,13 @@ export default { default: false, }, }, - title: __('Terminal'), + data() { + return { + item: { text: __('Terminal'), href: this.terminalPath }, + }; + }, }; </script> <template> - <gl-dropdown-item :href="terminalPath" :disabled="disabled"> - {{ $options.title }} - </gl-dropdown-item> + <gl-disclosure-dropdown-item :item="item" :disabled="disabled" /> </template> diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue index 0507abf3eaf..92960e2835e 100644 --- a/app/assets/javascripts/environments/components/environments_detail_header.vue +++ b/app/assets/javascripts/environments/components/environments_detail_header.vue @@ -4,7 +4,6 @@ import csrf from '~/lib/utils/csrf'; import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; import DeployFreezeAlert from './deploy_freeze_alert.vue'; @@ -24,7 +23,7 @@ export default { GlModalDirective, GlTooltip, }, - mixins: [timeagoMixin, glFeatureFlagsMixin()], + mixins: [timeagoMixin], props: { environment: { type: Object, @@ -51,11 +50,6 @@ export default { required: false, default: '', }, - metricsPath: { - type: String, - required: false, - default: '', - }, updatePath: { type: String, required: false, @@ -69,8 +63,6 @@ export default { }, i18n: { autoStopAtText: s__('Environments|Auto stops %{autoStopAt}'), - metricsButtonTitle: __('See metrics'), - metricsButtonText: __('Monitoring'), editButtonText: __('Edit'), stopButtonText: s__('Environments|Stop'), deleteButtonText: s__('Environments|Delete'), @@ -91,9 +83,6 @@ export default { shouldShowTerminalButton() { return this.canAdminEnvironment && this.environment.hasTerminals; }, - shouldShowMetricsButton() { - return Boolean(!this.glFeatures.removeMonitorMetrics && this.shouldShowExternalUrlButton); - }, }, }; </script> @@ -146,17 +135,6 @@ export default { target="_blank" >{{ $options.i18n.externalButtonText }}</gl-button > - <gl-button - v-if="shouldShowMetricsButton" - v-gl-tooltip.hover - data-testid="metrics-button" - :href="metricsPath" - :title="$options.i18n.metricsButtonTitle" - icon="chart" - class="gl-mr-2" - > - {{ $options.i18n.metricsButtonText }} - </gl-button> <gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath"> {{ $options.i18n.editButtonText }} </gl-button> diff --git a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue index 7660912f93a..03bde8d64ac 100644 --- a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue +++ b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue @@ -1,68 +1,37 @@ <script> -import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util'; import { AGENT_STATUSES } from '~/clusters_list/constants'; import { s__ } from '~/locale'; -import getK8sClusterAgentQuery from '../graphql/queries/k8s_cluster_agent.query.graphql'; export default { components: { GlIcon, GlLink, GlSprintf, - GlLoadingIcon, TimeAgoTooltip, - GlAlert, }, props: { - agentName: { - required: true, - type: String, - }, - agentId: { - required: true, - type: String, - }, - agentProjectPath: { - required: true, - type: String, - }, - }, - apollo: { clusterAgent: { - query: getK8sClusterAgentQuery, - variables() { - return { - agentName: this.agentName, - projectPath: this.agentProjectPath, - }; - }, - update: (data) => data?.project?.clusterAgent, - error() { - this.clusterAgent = null; - }, + required: true, + type: Object, }, }, - data() { - return { - clusterAgent: null, - }; - }, computed: { - isLoading() { - return this.$apollo.queries.clusterAgent.loading; - }, agentLastContact() { return getAgentLastContact(this.clusterAgent.tokens.nodes); }, agentStatus() { return getAgentStatus(this.agentLastContact); }, + agentId() { + return getIdFromGraphQLId(this.clusterAgent.id); + }, }, methods: {}, i18n: { - loadingError: s__('ClusterAgents|An error occurred while loading your agent'), agentId: s__('ClusterAgents|Agent ID #%{agentId}'), neverConnectedText: s__('ClusterAgents|Never'), }, @@ -70,8 +39,7 @@ export default { }; </script> <template> - <gl-loading-icon v-if="isLoading" inline /> - <div v-else-if="clusterAgent" class="gl-text-gray-900"> + <div class="gl-text-gray-900"> <gl-icon name="kubernetes-agent" class="gl-text-gray-500" /> <gl-link :href="clusterAgent.webPath" class="gl-mr-3"> <gl-sprintf :message="$options.i18n.agentId" @@ -92,8 +60,4 @@ export default { <span v-else>{{ $options.i18n.neverConnectedText }}</span> </span> </div> - - <gl-alert v-else variant="danger" :dismissible="false"> - {{ $options.i18n.loadingError }} - </gl-alert> </template> diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index 1f15c4daa2f..a1efeaac359 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -2,10 +2,11 @@ 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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; import KubernetesAgentInfo from './kubernetes_agent_info.vue'; import KubernetesPods from './kubernetes_pods.vue'; import KubernetesTabs from './kubernetes_tabs.vue'; +import KubernetesStatusBar from './kubernetes_status_bar.vue'; export default { components: { @@ -15,20 +16,13 @@ export default { KubernetesAgentInfo, KubernetesPods, KubernetesTabs, + KubernetesStatusBar, }, inject: ['kasTunnelUrl'], props: { - agentName: { + clusterAgent: { required: true, - type: String, - }, - agentId: { - required: true, - type: String, - }, - agentProjectPath: { - required: true, - type: String, + type: Object, }, namespace: { required: false, @@ -40,6 +34,9 @@ export default { return { isVisible: false, error: '', + hasFailedState: false, + podsLoading: false, + workloadTypesLoading: false, }; }, computed: { @@ -50,17 +47,25 @@ export default { 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(); + return getIdFromGraphQLId(this.clusterAgent.id).toString(); }, k8sAccessConfiguration() { return { basePath: this.kasTunnelUrl, baseOptions: { headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers }, + withCredentials: true, }, }; }, + clusterHealthStatus() { + const clusterDataLoading = this.podsLoading || this.workloadTypesLoading; + if (clusterDataLoading) { + return ''; + } + + return this.hasFailedState ? 'error' : 'success'; + }, }, methods: { toggleCollapse() { @@ -91,11 +96,8 @@ export default { </p> <gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4"> <template v-if="isVisible"> - <kubernetes-agent-info - :agent-name="agentName" - :agent-id="agentId" - :agent-project-path="agentProjectPath" - class="gl-mb-5" /> + <kubernetes-status-bar :cluster-health-status="clusterHealthStatus" class="gl-mb-4" /> + <kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-5" /> <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> {{ error }} @@ -105,12 +107,16 @@ export default { :configuration="k8sAccessConfiguration" :namespace="namespace" class="gl-mb-5" - @cluster-error="onClusterError" /> + @cluster-error="onClusterError" + @loading="podsLoading = $event" + @failed="hasFailedState = true" /> <kubernetes-tabs :configuration="k8sAccessConfiguration" :namespace="namespace" class="gl-mb-5" @cluster-error="onClusterError" + @loading="workloadTypesLoading = $event" + @failed="hasFailedState = true" /></template> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue index a153331ee58..aded3a4d0c4 100644 --- a/app/assets/javascripts/environments/components/kubernetes_pods.vue +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -3,6 +3,7 @@ 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'; +import { PHASE_RUNNING, PHASE_PENDING, PHASE_SUCCEEDED, PHASE_FAILED } from '../constants'; export default { components: { @@ -25,6 +26,9 @@ export default { this.error = error; this.$emit('cluster-error', this.error); }, + watchLoading(isLoading) { + this.$emit('loading', isLoading); + }, }, }, props: { @@ -42,41 +46,39 @@ export default { error: '', }; }, - computed: { podStats() { if (!this.k8sPods) return null; return [ { - // eslint-disable-next-line @gitlab/require-i18n-strings - value: this.getPodsByPhase('Running'), + value: this.countPodsByPhase(PHASE_RUNNING), title: this.$options.i18n.runningPods, }, { - // eslint-disable-next-line @gitlab/require-i18n-strings - value: this.getPodsByPhase('Pending'), + value: this.countPodsByPhase(PHASE_PENDING), title: this.$options.i18n.pendingPods, }, { - // eslint-disable-next-line @gitlab/require-i18n-strings - value: this.getPodsByPhase('Succeeded'), + value: this.countPodsByPhase(PHASE_SUCCEEDED), title: this.$options.i18n.succeededPods, }, { - // eslint-disable-next-line @gitlab/require-i18n-strings - value: this.getPodsByPhase('Failed'), + value: this.countPodsByPhase(PHASE_FAILED), title: this.$options.i18n.failedPods, }, ]; }, loading() { - return this.$apollo.queries.k8sPods.loading; + return this.$apollo?.queries?.k8sPods?.loading; }, }, methods: { - getPodsByPhase(phase) { + countPodsByPhase(phase) { const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase); + if (phase === PHASE_FAILED && filteredPods.length) { + this.$emit('failed'); + } return filteredPods.length; }, }, diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue new file mode 100644 index 00000000000..94cd7438e46 --- /dev/null +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -0,0 +1,39 @@ +<script> +import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { HEALTH_BADGES } from '../constants'; + +export default { + components: { + GlLoadingIcon, + GlBadge, + }, + props: { + clusterHealthStatus: { + required: false, + type: String, + default: '', + validator(val) { + return ['error', 'success', ''].includes(val); + }, + }, + }, + computed: { + healthBadge() { + return HEALTH_BADGES[this.clusterHealthStatus]; + }, + }, + i18n: { + healthLabel: s__('Environment|Environment health'), + }, +}; +</script> +<template> + <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-mb-2"> + <span class="gl-font-sm gl-font-monospace gl-mr-3">{{ $options.i18n.healthLabel }}</span> + <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline /> + <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant"> + {{ healthBadge.text }} + </gl-badge> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/kubernetes_summary.vue b/app/assets/javascripts/environments/components/kubernetes_summary.vue index 85fc1c1a07d..b00e82809f6 100644 --- a/app/assets/javascripts/environments/components/kubernetes_summary.vue +++ b/app/assets/javascripts/environments/components/kubernetes_summary.vue @@ -32,6 +32,12 @@ export default { error(error) { this.$emit('cluster-error', error); }, + result() { + this.checkFailed(); + }, + watchLoading(isLoading) { + this.$emit('loading', isLoading); + }, }, }, props: { @@ -46,7 +52,7 @@ export default { }, computed: { summaryLoading() { - return this.$apollo.queries.k8sWorkloads.loading; + return this.$apollo?.queries?.k8sWorkloads?.loading; }, summaryCount() { return this.k8sWorkloads ? Object.values(this.k8sWorkloads).flat().length : 0; @@ -128,6 +134,17 @@ export default { }; }, }, + methods: { + checkFailed() { + const failed = this.summaryObjects.some((workloadType) => { + return workloadType.items?.failed?.length > 0; + }); + + if (failed) { + this.$emit('failed'); + } + }, + }, i18n: { summaryTitle: s__('Environment|Summary'), deployments: s__('Environment|Deployments'), diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue index b900c23b2b7..4492d209e3b 100644 --- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue +++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue @@ -134,7 +134,12 @@ export default { </script> <template> <gl-tabs> - <kubernetes-summary :namespace="namespace" :configuration="configuration" /> + <kubernetes-summary + :namespace="namespace" + :configuration="configuration" + @loading="$emit('loading', $event)" + @failed="$emit('failed')" + /> <gl-tab> <template #title> diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue index 4b58d133817..3e5f4070066 100644 --- a/app/assets/javascripts/environments/components/new_environment.vue +++ b/app/assets/javascripts/environments/components/new_environment.vue @@ -2,13 +2,16 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import createEnvironment from '../graphql/mutations/create_environment.mutation.graphql'; import EnvironmentForm from './environment_form.vue'; export default { components: { EnvironmentForm, }, - inject: ['projectEnvironmentsPath'], + mixins: [glFeatureFlagsMixin()], + inject: ['projectEnvironmentsPath', 'projectPath'], data() { return { environment: { @@ -23,6 +26,46 @@ export default { this.environment = env; }, onSubmit() { + if (this.glFeatures?.environmentSettingsToGraphql) { + this.createWithGraphql(); + } else { + this.createWithAxios(); + } + }, + async createWithGraphql() { + this.loading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: createEnvironment, + variables: { + input: { + name: this.environment.name, + externalUrl: this.environment.externalUrl, + projectPath: this.projectPath, + clusterAgentId: this.environment.clusterAgentId, + }, + }, + }); + + const { errors } = data.environmentCreate; + + if (errors.length > 0) { + throw new Error(errors[0]?.message ?? errors[0]); + } + + const { path } = data.environmentCreate.environment; + + if (path) { + visitUrl(path); + } + } catch (error) { + const { message } = error; + createAlert({ message }); + } finally { + this.loading = false; + } + }, + createWithAxios() { this.loading = true; axios .post(this.projectEnvironmentsPath, { diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 912c558c3ce..1f3d429cc3e 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -1,9 +1,9 @@ <script> import { - GlCollapse, - GlDropdown, GlBadge, GlButton, + GlCollapse, + GlDisclosureDropdown, GlLink, GlSprintf, GlTooltipDirective as GlTooltip, @@ -13,12 +13,12 @@ import { truncate } from '~/lib/utils/text_utility'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql'; +import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql'; import ExternalUrl from './environment_external_url.vue'; import Actions from './environment_actions.vue'; import StopComponent from './environment_stop.vue'; import Rollback from './environment_rollback.vue'; import Pin from './environment_pin.vue'; -import Monitoring from './environment_monitoring.vue'; import Terminal from './environment_terminal_button.vue'; import Delete from './environment_delete.vue'; import Deployment from './deployment.vue'; @@ -27,8 +27,8 @@ import KubernetesOverview from './kubernetes_overview.vue'; export default { components: { + GlDisclosureDropdown, GlCollapse, - GlDropdown, GlBadge, GlButton, GlLink, @@ -39,7 +39,6 @@ export default { ExternalUrl, StopComponent, Rollback, - Monitoring, Pin, Terminal, TimeAgoTooltip, @@ -53,7 +52,7 @@ export default { GlTooltip, }, mixins: [glFeatureFlagsMixin()], - inject: ['helpPagePath'], + inject: ['helpPagePath', 'projectPath'], props: { environment: { required: true, @@ -83,7 +82,7 @@ export default { tierTooltip: s__('Environment|Deployment tier'), }, data() { - return { visible: false }; + return { visible: false, clusterAgent: null }; }, computed: { icon() { @@ -133,7 +132,6 @@ export default { return Boolean( this.retryPath || this.canShowAutoStopDate || - this.canShowMetricsLink || this.terminalPath || this.canDeleteEnvironment, ); @@ -154,12 +152,6 @@ export default { autoStopPath() { return this.environment?.cancelAutoStopPath ?? ''; }, - metricsPath() { - return this.environment?.metricsPath ?? ''; - }, - canShowMetricsLink() { - return Boolean(!this.glFeatures.removeMonitorMetrics && this.metricsPath); - }, terminalPath() { return this.environment?.terminalPath ?? ''; }, @@ -172,23 +164,33 @@ export default { rolloutStatus() { return this.environment?.rolloutStatus; }, - agent() { - return this.environment?.agent || {}; - }, isKubernetesOverviewAvailable() { return this.glFeatures?.kasUserAccessProject; }, - hasRequiredAgentData() { - const { project, id, name } = this.agent || {}; - return project && id && name; - }, showKubernetesOverview() { - return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData; + return Boolean(this.isKubernetesOverviewAvailable && this.clusterAgent); }, }, methods: { - toggleCollapse() { + toggleEnvironmentCollapse() { this.visible = !this.visible; + + if (this.visible) { + this.getClusterAgent(); + } + }, + getClusterAgent() { + if (!this.isKubernetesOverviewAvailable || this.clusterAgent) return; + + this.$apollo.addSmartQuery('environmentClusterAgent', { + variables() { + return { environmentName: this.environment.name, projectFullPath: this.projectPath }; + }, + query: getEnvironmentClusterAgent, + update(data) { + this.clusterAgent = data?.project?.environment?.clusterAgent; + }, + }); }, }, deploymentClasses: [ @@ -231,7 +233,7 @@ export default { :aria-label="label" size="small" category="secondary" - @click="toggleCollapse" + @click="toggleEnvironmentCollapse" /> <gl-link v-gl-tooltip @@ -282,14 +284,14 @@ export default { graphql /> - <gl-dropdown + <gl-disclosure-dropdown v-if="hasExtraActions" - icon="ellipsis_v" text-sr-only - :text="__('More actions')" - category="secondary" no-caret - right + icon="ellipsis_v" + category="secondary" + placement="right" + :toggle-text="__('More actions')" > <rollback v-if="retryPath" @@ -309,14 +311,6 @@ export default { data-track-label="environment_pin" /> - <monitoring - v-if="canShowMetricsLink" - :monitoring-url="metricsPath" - data-track-action="click_button" - data-track-label="environment_monitoring" - data-testid="environment-monitoring" - /> - <terminal v-if="terminalPath" :terminal-path="terminalPath" @@ -331,7 +325,7 @@ export default { data-track-label="environment_delete" graphql /> - </gl-dropdown> + </gl-disclosure-dropdown> </div> </div> </div> @@ -376,10 +370,8 @@ export default { </div> <div v-if="showKubernetesOverview" :class="$options.kubernetesOverviewClasses"> <kubernetes-overview - :agent-project-path="agent.project" - :agent-name="agent.name" - :agent-id="agent.id" - :namespace="agent.kubernetesNamespace" + :cluster-agent="clusterAgent" + :namespace="environment.kubernetesNamespace" /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 448cee530f6..2b178964c37 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -89,3 +89,22 @@ export const ENVIRONMENT_NEW_HELP_TEXT = __( export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT; export const SERVICES_LIMIT_PER_PAGE = 10; + +export const CLUSTER_STATUS_HEALTHY_TEXT = s__('Environment|Healthy'); +export const CLUSTER_STATUS_UNHEALTHY_TEXT = s__('Environment|Unhealthy'); + +export const HEALTH_BADGES = { + success: { + variant: 'success', + text: CLUSTER_STATUS_HEALTHY_TEXT, + }, + error: { + variant: 'danger', + text: CLUSTER_STATUS_UNHEALTHY_TEXT, + }, +}; + +export const PHASE_RUNNING = 'Running'; +export const PHASE_PENDING = 'Pending'; +export const PHASE_SUCCEEDED = 'Succeeded'; +export const PHASE_FAILED = 'Failed'; diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js index a128d2fb3c7..b26d96e15bd 100644 --- a/app/assets/javascripts/environments/edit.js +++ b/app/assets/javascripts/environments/edit.js @@ -1,19 +1,38 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import EditEnvironment from './components/edit_environment.vue'; +import { apolloProvider } from './graphql/client'; -export default (el) => - new Vue({ +Vue.use(VueApollo); + +export default (el) => { + if (!el) { + return null; + } + + const { + projectEnvironmentsPath, + updateEnvironmentPath, + protectedEnvironmentSettingsPath, + projectPath, + environment, + } = el.dataset; + + return new Vue({ el, + apolloProvider: apolloProvider(), provide: { - projectEnvironmentsPath: el.dataset.projectEnvironmentsPath, - updateEnvironmentPath: el.dataset.updateEnvironmentPath, - protectedEnvironmentSettingsPath: el.dataset.protectedEnvironmentSettingsPath, + projectEnvironmentsPath, + updateEnvironmentPath, + protectedEnvironmentSettingsPath, + projectPath, }, render(h) { return h(EditEnvironment, { props: { - environment: JSON.parse(el.dataset.environment), + environment: JSON.parse(environment), }, }); }, }); +}; 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 92a0b0e550e..787302df60f 100644 --- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue +++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue @@ -110,6 +110,7 @@ export default { data-testid="rollback-button" :title="rollbackButtonTitle" :icon="rollbackIcon" + :aria-label="rollbackButtonTitle" @click="onRollbackClick" /> <environment-approval diff --git a/app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql new file mode 100644 index 00000000000..99330ecca80 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql @@ -0,0 +1,9 @@ +mutation createEnvironment($input: EnvironmentCreateInput!) { + environmentCreate(input: $input) { + environment { + id + path + } + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql new file mode 100644 index 00000000000..9ea0e3609cb --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateEnvironment($input: EnvironmentUpdateInput!) { + environmentUpdate(input: $input) { + environment { + id + path + } + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql new file mode 100644 index 00000000000..20402e8d32e --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql @@ -0,0 +1,14 @@ +query getEnvironment($projectFullPath: ID!, $environmentName: String) { + project(fullPath: $projectFullPath) { + id + environment(name: $environmentName) { + id + name + externalUrl + clusterAgent { + id + name + } + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql new file mode 100644 index 00000000000..760f1fba897 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql @@ -0,0 +1,19 @@ +query getEnvironmentClusterAgent($projectFullPath: ID!, $environmentName: String) { + project(fullPath: $projectFullPath) { + id + environment(name: $environmentName) { + id + clusterAgent { + id + name + webPath + tokens { + nodes { + id + lastUsedAt + } + } + } + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql deleted file mode 100644 index bd45d2dba2f..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql +++ /dev/null @@ -1,15 +0,0 @@ -query getK8sClusterAgentQuery($projectPath: ID!, $agentName: String!) { - project(fullPath: $projectPath) { - id - clusterAgent(name: $agentName) { - id - webPath - tokens { - nodes { - id - lastUsedAt - } - } - } - } -} diff --git a/app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql b/app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql new file mode 100644 index 00000000000..bba85888543 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql @@ -0,0 +1,13 @@ +query getUserAuthorizedAgents($projectFullPath: ID!) { + project(fullPath: $projectFullPath) { + id + userAccessAuthorizedAgents { + nodes { + agent { + id + name + } + } + } + } +} diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index f73cb7fe1bc..fb9a7a02d07 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -51,7 +51,6 @@ export const initHeader = () => { canAdminEnvironment: dataset.canAdminEnvironment, cancelAutoStopPath: dataset.environmentCancelAutoStopPath, terminalPath: dataset.environmentTerminalPath, - metricsPath: dataset.environmentMetricsPath, updatePath: dataset.environmentEditPath, }, }); diff --git a/app/assets/javascripts/environments/new.js b/app/assets/javascripts/environments/new.js index 76aaf809d17..5dd112ac5e6 100644 --- a/app/assets/javascripts/environments/new.js +++ b/app/assets/javascripts/environments/new.js @@ -1,11 +1,23 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import NewEnvironment from './components/new_environment.vue'; +import { apolloProvider } from './graphql/client'; -export default (el) => - new Vue({ +Vue.use(VueApollo); + +export default (el) => { + if (!el) { + return null; + } + + const { projectEnvironmentsPath, projectPath } = el.dataset; + + return new Vue({ el, - provide: { projectEnvironmentsPath: el.dataset.projectEnvironmentsPath }, + apolloProvider: apolloProvider(), + provide: { projectEnvironmentsPath, projectPath }, render(h) { return h(NewEnvironment); }, }); +}; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index ccadf940fe3..0151dbb0bf7 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -13,7 +13,6 @@ import { import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_WARNING } from '~/alert'; import { __, sprintf, n__ } from '~/locale'; -import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import query from '../queries/details.query.graphql'; @@ -25,6 +24,7 @@ import { import { severityLevel, severityLevelVariant, errorStatus } from '../constants'; import Stacktrace from './stacktrace.vue'; import ErrorDetailsInfo from './error_details_info.vue'; +import TimelineChart from './timeline_chart.vue'; const SENTRY_TIMEOUT = 10000; @@ -43,6 +43,7 @@ export default { GlDropdownDivider, TimeAgoTooltip, ErrorDetailsInfo, + TimelineChart, }, props: { issueUpdatePath: { @@ -69,6 +70,10 @@ export default { type: String, required: true, }, + integratedErrorTrackingEnabled: { + type: Boolean, + required: true, + }, }, apollo: { error: { @@ -188,8 +193,7 @@ export default { ]), createIssue() { this.issueCreationInProgress = true; - const { category, action } = trackCreateIssueFromError; - Tracking.event(category, action); + trackCreateIssueFromError(this.integratedErrorTrackingEnabled); this.$refs.sentryIssueForm.submit(); }, onIgnoreStatusUpdate() { @@ -224,12 +228,10 @@ export default { } }, trackPageViews() { - const { category, action } = trackErrorDetailsViewsOptions; - Tracking.event(category, action); + trackErrorDetailsViewsOptions(this.integratedErrorTrackingEnabled); }, trackStatusUpdate(status) { - const { category, action } = trackErrorStatusUpdateOptions(status); - Tracking.event(category, action); + trackErrorStatusUpdateOptions(status, this.integratedErrorTrackingEnabled); }, }, }; @@ -237,7 +239,7 @@ export default { <template> <div> - <div v-if="errorLoading" class="py-3"> + <div v-if="errorLoading" class="gl-py-5"> <gl-loading-icon size="lg" /> </div> @@ -258,23 +260,25 @@ export default { {{ __('No stack trace for this error') }} </gl-alert> - <div class="error-details-header d-flex py-2 justify-content-between"> + <div + class="error-details-header gl-border-b gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-py-3 gl-justify-content-space-between" + > <div v-if="!loadingStacktrace && stacktrace" - class="error-details-meta my-auto" + class="gl-my-auto gl-text-truncate" data-qa-selector="reported_text" > <gl-sprintf :message="__('Reported %{timeAgo} by %{reportedBy}')"> <template #reportedBy> - <strong class="error-details-meta-culprit">{{ error.culprit }}</strong> + <strong>{{ error.culprit }}</strong> </template> <template #timeAgo> <time-ago-tooltip :time="stacktraceData.date_received" /> </template> </gl-sprintf> </div> - <div class="error-details-actions"> - <div class="d-inline-flex bv-d-sm-down-none"> + <div> + <div class="gl-display-none gl-md-display-inline-flex"> <gl-button :loading="updatingIgnoreStatus" data-testid="update-ignore-status-btn" @@ -283,7 +287,7 @@ export default { {{ ignoreBtnLabel }} </gl-button> <gl-button - class="ml-2" + class="gl-ml-3" category="secondary" variant="confirm" :loading="updatingResolveStatus" @@ -294,7 +298,7 @@ export default { </gl-button> <gl-button v-if="error.gitlabIssuePath" - class="ml-2" + class="gl-ml-3" data-testid="view_issue_button" :href="error.gitlabIssuePath" variant="confirm" @@ -305,7 +309,7 @@ export default { ref="sentryIssueForm" :action="projectIssuesPath" method="POST" - class="d-inline-block ml-2" + class="gl-display-inline-block gl-ml-3" > <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> <input name="issue[description]" :value="issueDescription" type="hidden" /> @@ -329,7 +333,7 @@ export default { </div> <gl-dropdown text="Options" - class="error-details-options d-md-none" + class="gl-w-full gl-md-display-none" right :disabled="issueUpdateInProgress" > @@ -362,7 +366,7 @@ export default { </div> <div> <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> - <h2 class="text-truncate">{{ error.title }}</h2> + <h2 class="gl-text-truncate">{{ error.title }}</h2> </tooltip-on-truncate> <template v-if="error.tags"> <gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="gl-mr-3"> @@ -373,12 +377,17 @@ export default { <error-details-info :error="error" /> - <div v-if="loadingStacktrace" class="py-3"> + <div v-if="error.frequency" class="gl-mt-8"> + <h3>{{ __('Last 24 hours') }}</h3> + <timeline-chart :timeline-data="error.frequency" :height="200" /> + </div> + + <div v-if="loadingStacktrace" class="gl-py-5"> <gl-loading-icon size="lg" /> </div> <template v-else-if="showStacktrace"> - <h3 class="my-4">{{ __('Stack trace') }}</h3> + <h3 class="gl-my-6">{{ __('Stack trace') }}</h3> <stacktrace :entries="stacktrace" /> </template> </div> diff --git a/app/assets/javascripts/error_tracking/components/error_details_info.vue b/app/assets/javascripts/error_tracking/components/error_details_info.vue index f6f39f178fb..0b4eabe25d1 100644 --- a/app/assets/javascripts/error_tracking/components/error_details_info.vue +++ b/app/assets/javascripts/error_tracking/components/error_details_info.vue @@ -34,20 +34,14 @@ export default { lastReleaseLink() { return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`; }, - firstCommitLink() { - return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`; - }, - lastCommitLink() { - return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`; - }, shortFirstReleaseVersion() { - return this.error.firstReleaseVersion.substr(0, 10); + return this.error.firstReleaseVersion?.substr(0, 10); }, shortLastReleaseVersion() { - return this.error.lastReleaseVersion.substr(0, 10); + return this.error.lastReleaseVersion?.substr(0, 10); }, shortGitlabCommit() { - return this.error.gitlabCommit.substr(0, 10); + return this.error.gitlabCommit?.substr(0, 10); }, }, methods: { @@ -72,11 +66,11 @@ export default { data-testid="error-count-card" > <template #header> - <span>{{ __('Events') }}</span> + {{ __('Events') }} </template> <template #default> - <span>{{ error.count }}</span> + {{ error.count }} </template> </gl-card> @@ -87,61 +81,66 @@ export default { data-testid="user-count-card" > <template #header> - <span>{{ __('Users') }}</span> + {{ __('Users') }} </template> <template #default> - <span>{{ error.userCount }}</span> + {{ error.userCount }} </template> </gl-card> <gl-card - v-if="error.firstReleaseVersion" + v-if="error.firstSeen" :class="$options.CARD_CLASS" :body-class="$options.BODY_CLASS" :header-class="$options.HEADER_CLASS" data-testid="first-release-card" > <template #header> - <gl-icon v-gl-tooltip :title="shortFirstReleaseVersion" name="commit" class="gl-mr-1" /> - <span>{{ __('First seen') }}</span> + {{ __('First seen') }} </template> - <template #default> - <gl-link v-if="error.integrated" :href="firstCommitLink" class="gl-font-lg"> - <time-ago-tooltip :time="error.firstSeen" /> - </gl-link> + <template v-if="error.integrated" #default> + <time-ago-tooltip :time="error.firstSeen" /> + <span v-if="shortFirstReleaseVersion" class="gl-font-sm gl-text-secondary"> + <gl-icon name="commit" class="gl-mr-1" :size="12" />{{ shortFirstReleaseVersion }} + </span> + </template> - <gl-link v-else :href="firstReleaseLink" target="_blank" class="gl-font-lg"> + <template v-else #default> + <gl-link :href="firstReleaseLink" target="_blank" class="gl-font-lg"> <time-ago-tooltip :time="error.firstSeen" /> </gl-link> </template> </gl-card> <gl-card - v-if="error.lastReleaseVersion" + v-if="error.lastSeen" :class="$options.CARD_CLASS" :body-class="$options.BODY_CLASS" :header-class="$options.HEADER_CLASS" data-testid="last-release-card" > <template #header> - <gl-icon v-gl-tooltip :title="shortLastReleaseVersion" name="commit" class="gl-mr-1" /> {{ __('Last seen') }} </template> - <template #default> - <gl-link v-if="error.integrated" :href="lastCommitLink" class="gl-font-lg"> - <time-ago-tooltip :time="error.lastSeen" /> - </gl-link> - <gl-link v-else :href="lastReleaseLink" target="_blank" class="gl-font-lg"> + <template v-if="error.integrated" #default> + <time-ago-tooltip :time="error.lastSeen" /> + <span v-if="shortLastReleaseVersion" class="gl-font-sm gl-text-secondary"> + <gl-icon name="commit" class="gl-mr-1" :size="12" />{{ shortLastReleaseVersion }} + </span> + </template> + + <template v-else #default> + <gl-link :href="lastReleaseLink" target="_blank" class="gl-font-lg"> <time-ago-tooltip :time="error.lastSeen" /> </gl-link> </template> </gl-card> <gl-card - v-if="error.gitlabCommit" + v-if="!error.integrated && error.gitlabCommit" :class="$options.CARD_CLASS" :body-class="$options.BODY_CLASS" :header-class="$options.HEADER_CLASS" diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue index a5e712f4fc2..35e8e26ecfb 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue @@ -44,37 +44,45 @@ export default { <template> <div> - <gl-button-group class="gl-flex-direction-column flex-md-row gl-ml-0 ml-md-n4"> + <gl-button-group class="gl-flex-direction-column gl-md-flex-direction-row gl-ml-n6"> <gl-button :key="ignoreBtn.status" :ref="`${ignoreBtn.title.toLowerCase()}Error`" v-gl-tooltip.hover - class="gl-display-block gl-mb-4 mb-md-0 gl-w-full" + class="gl-display-block gl-mb-4 gl-md-mb-0 gl-w-full" :title="ignoreBtn.title" :aria-label="ignoreBtn.title" @click="$emit('update-issue-status', { errorId: error.id, status: ignoreBtn.status })" > - <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="ignoreBtn.icon" :size="12" /> - <span class="d-md-none">{{ ignoreBtn.title }}</span> + <gl-icon + class="gl-display-none gl-md-display-inline gl-m-0" + :name="ignoreBtn.icon" + :size="12" + /> + <span class="gl-md-display-none">{{ ignoreBtn.title }}</span> </gl-button> <gl-button :key="resolveBtn.status" :ref="`${resolveBtn.title.toLowerCase()}Error`" v-gl-tooltip.hover - class="gl-display-block gl-mb-4 mb-md-0 gl-w-full" + class="gl-display-block gl-mb-4 gl-md-mb-0 gl-w-full" :title="resolveBtn.title" :aria-label="resolveBtn.title" @click="$emit('update-issue-status', { errorId: error.id, status: resolveBtn.status })" > - <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="resolveBtn.icon" :size="12" /> - <span class="d-md-none">{{ resolveBtn.title }}</span> + <gl-icon + class="gl-display-none gl-md-display-inline gl-m-0" + :name="resolveBtn.icon" + :size="12" + /> + <span class="gl-md-display-none">{{ resolveBtn.title }}</span> </gl-button> </gl-button-group> <gl-button :href="detailsLink" category="primary" variant="confirm" - class="gl-display-block d-md-none gl-mb-4 mb-md-0" + class="gl-display-block gl-md-display-none! gl-mb-4 gl-md-mb-0" > {{ __('More details') }} </gl-button> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 6750f0f5411..0c9a98f3b33 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -20,7 +20,6 @@ import { mapActions, mapState } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; import AccessorUtils from '~/lib/utils/accessor'; import { __ } from '~/locale'; -import Tracking from '~/tracking'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { sanitizeUrl } from '~/lib/utils/url_utility'; import { @@ -31,12 +30,12 @@ import { } from '../events_tracking'; import { I18N_ERROR_TRACKING_LIST } from '../constants'; import ErrorTrackingActions from './error_tracking_actions.vue'; - -export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center'; +import TimelineChart from './timeline_chart.vue'; const isValidErrorId = (errorId) => { return /^[0-9]+$/.test(errorId); }; +export const tableDataClass = 'gl-display-flex gl-md-display-table-cell gl-align-items-center'; export default { FIRST_PAGE: 1, PREV_PAGE: 1, @@ -46,26 +45,32 @@ export default { { key: 'error', label: __('Error'), - thClass: 'w-60p', - tdClass: `${tableDataClass} px-3 rounded-top`, + thClass: 'gl-w-40p', + tdClass: `${tableDataClass}`, + }, + { + key: 'timeline', + label: __('Timeline'), + thClass: 'gl-text-center gl-w-20p', + tdClass: `${tableDataClass} gl-text-center`, }, { key: 'events', label: __('Events'), - thClass: 'text-right', - tdClass: `${tableDataClass}`, + thClass: 'gl-text-center gl-w-10p', + tdClass: `${tableDataClass} gl-text-center`, }, { key: 'users', label: __('Users'), - thClass: 'text-right', - tdClass: `${tableDataClass}`, + thClass: 'gl-text-center gl-w-10p', + tdClass: `${tableDataClass} gl-text-center`, }, { key: 'lastSeen', label: __('Last seen'), - thClass: 'w-15p', - tdClass: `${tableDataClass}`, + thClass: 'gl-text-center gl-w-10p', + tdClass: `${tableDataClass} gl-text-center`, }, { key: 'status', @@ -99,6 +104,7 @@ export default { GlPagination, TimeAgo, ErrorTrackingActions, + TimelineChart, }, directives: { GlTooltip: GlTooltipDirective, @@ -116,6 +122,10 @@ export default { type: Boolean, required: true, }, + integratedErrorTrackingEnabled: { + type: Boolean, + required: true, + }, illustrationPath: { type: String, required: true, @@ -180,7 +190,6 @@ export default { }, }, epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639', - openBetaLink: 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta', featureFlagLink: helpPagePath('operations/error_tracking'), created() { if (this.errorTrackingEnabled) { @@ -242,13 +251,11 @@ export default { }, filterErrors(status, label) { this.filterValue = label; - const { category, action } = trackErrorStatusFilterOptions(status); - Tracking.event(category, action); + trackErrorStatusFilterOptions(status, this.integratedErrorTrackingEnabled); return this.filterByStatus(status); }, sortErrorsByField(field) { - const { category, action } = trackErrorSortedByField(field); - Tracking.event(category, action); + trackErrorSortedByField(field, this.integratedErrorTrackingEnabled); return this.sortByField(field); }, updateErrosStatus({ errorId, status }) { @@ -263,12 +270,10 @@ export default { this.removeIgnoredResolvedErrors(errorId); }, trackPageViews() { - const { category, action } = trackErrorListViewsOptions; - Tracking.event(category, action); + trackErrorListViewsOptions(this.integratedErrorTrackingEnabled); }, trackStatusUpdate(status) { - const { category, action } = trackErrorStatusUpdateOptions(status); - Tracking.event(category, action); + trackErrorStatusUpdateOptions(status, this.integratedErrorTrackingEnabled); }, }, }; @@ -277,6 +282,7 @@ export default { <template> <div class="error-list"> <div v-if="errorTrackingEnabled"> + <!-- Enable ET --> <gl-alert v-if="showIntegratedDisabledAlert" variant="danger" @@ -305,18 +311,20 @@ export default { </gl-button> </div> </gl-alert> + + <!-- Search / Filter Bar --> <div - class="row flex-column flex-md-row align-items-md-center m-0 mt-sm-2 p-3 p-sm-3 bg-secondary border" + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-md-align-items-center gl-m-0 gl-p-5 gl-bg-gray-50 gl-border" > - <div class="search-box flex-fill mb-1 mb-md-0"> - <div class="filtered-search-box mb-0"> + <div class="search-box gl-display-flex gl-flex-grow-1 gl-mb-2 gl-md-mb-0"> + <div class="filtered-search-box gl-mb-0"> <gl-dropdown :text="__('Recent searches')" class="filtered-search-history-dropdown-wrapper" toggle-class="filtered-search-history-dropdown-toggle-button gl-shadow-none! gl-border-r-gray-200! gl-border-1! gl-rounded-0!" :disabled="loading" > - <div v-if="!$options.hasLocalStorage" class="px-3"> + <div v-if="!$options.hasLocalStorage" class="gl-px-5"> {{ __('This feature requires local storage to be enabled') }} </div> <template v-else-if="recentSearches.length > 0"> @@ -331,12 +339,12 @@ export default { >{{ __('Clear recent searches') }} </gl-dropdown-item> </template> - <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> + <div v-else class="gl-px-5">{{ __("You don't have any recent searches") }}</div> </gl-dropdown> <div class="filtered-search-input-container gl-flex-grow-1"> <gl-form-input v-model="errorSearchQuery" - class="pl-2 filtered-search" + class="gl-pl-3! filtered-search" :disabled="loading" :placeholder="__('Search or filter results…')" autofocus @@ -348,7 +356,7 @@ export default { v-if="errorSearchQuery.length > 0" v-gl-tooltip.hover :title="__('Clear')" - class="clear-search text-secondary" + class="clear-search gl-text-secondary" name="clear" icon="close" @click="errorSearchQuery = ''" @@ -359,7 +367,7 @@ export default { <gl-dropdown :text="$options.statusFilters[statusFilter]" - class="status-dropdown mx-md-1 mb-1 mb-md-0" + class="status-dropdown gl-md-ml-2 gl-md-mr-2 gl-mb-2 gl-md-mb-0" :disabled="loading" right > @@ -368,7 +376,7 @@ export default { :key="status" @click="filterErrors(status, label)" > - <span class="d-flex"> + <span class="gl-display-flex"> <gl-icon class="gl-dropdown-item-check-icon" :class="{ invisible: !isCurrentStatusFilter(status) }" @@ -385,7 +393,7 @@ export default { :key="field" @click="sortErrorsByField(field)" > - <span class="d-flex"> + <span class="gl-display-flex"> <gl-icon class="gl-dropdown-item-check-icon" :class="{ invisible: !isCurrentSortField(field) }" @@ -397,58 +405,73 @@ export default { </gl-dropdown> </div> - <div v-if="loading" class="py-3"> + <div v-if="loading" class="gl-py-5"> <gl-loading-icon size="lg" /> </div> + <!-- Results Table --> <template v-else> - <h4 class="d-block d-md-none my-3">{{ __('Open errors') }}</h4> + <h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ __('Open errors') }}</h4> <gl-table - class="error-list-table mt-3" + class="error-list-table gl-mt-5" :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="md" - tbody-tr-class="table-row mb-4" + tbody-tr-class="table-row" > + <!-- table head --> <template #head(error)> - <div class="d-none d-md-block">{{ __('Open errors') }}</div> + <div class="gl-display-none gl-md-display-block">{{ __('Open errors') }}</div> </template> <template #head(events)="data"> - <div class="text-md-right">{{ data.label }}</div> + {{ data.label }} </template> <template #head(users)="data"> - <div class="text-md-right">{{ data.label }}</div> + {{ data.label }} </template> + <!-- table row --> <template #cell(error)="errors"> - <div class="d-flex flex-column"> - <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)"> - <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> + <div class="gl-display-flex gl-flex-direction-column"> + <gl-link + class="gl-display-flex gl-max-w-full gl-text-body" + :href="getDetailsLink(errors.item.id)" + > + <strong class="gl-text-truncate">{{ errors.item.title.trim() }}</strong> </gl-link> - <span class="text-secondary text-truncate mw-100"> + <span class="gl-text-secondary gl-text-truncate gl-max-w-full"> {{ errors.item.culprit }} </span> </div> </template> + + <template #cell(timeline)="errors"> + <timeline-chart + v-if="errors.item.frequency" + :timeline-data="errors.item.frequency" + :height="70" + /> + </template> + <template #cell(events)="errors"> - <div class="text-right">{{ errors.item.count }}</div> + {{ errors.item.count }} </template> <template #cell(users)="errors"> - <div class="text-right">{{ errors.item.userCount }}</div> + {{ errors.item.userCount }} </template> <template #cell(lastSeen)="errors"> - <div class="text-lg-left text-right"> - <time-ago :time="errors.item.lastSeen" class="text-secondary" /> - </div> + <time-ago :time="errors.item.lastSeen" class="gl-text-secondary" /> </template> + <template #cell(status)="errors"> <error-tracking-actions :error="errors.item" @update-issue-status="updateErrosStatus" /> </template> + <template #empty> {{ __('No errors to display.') }} <gl-link class="js-try-again" @click="restartPolling"> @@ -467,6 +490,7 @@ export default { /> </template> </div> + <!-- Get Started with ET --> <div v-else> <gl-empty-state :title="__('Get started with error tracking')" :svg-path="illustrationPath"> <template #description> @@ -476,10 +500,6 @@ export default { __('How do I get started?') }}</gl-link> </div> - <div class="gl-mt-3"> - <span>{{ __('Error tracking is currently in') }}</span> - <gl-link target="_blank" :href="$options.openBetaLink">{{ __('Open Beta.') }}</gl-link> - </div> </template> </gl-empty-state> </div> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue index f58d54f2933..54b9d37be73 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue @@ -25,7 +25,7 @@ export default { v-for="(entry, index) in entries" :key="`stacktrace-entry-${index}`" :lines="entry.context" - :file-path="entry.filename" + :file-path="entry.filename || entry.abs_path" :error-line="entry.lineNo" :error-fn="entry.function" :error-column="entry.colNo" diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 6ddd982ebf1..bf549063031 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlTooltip, GlSprintf, GlIcon, GlTruncate } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -10,6 +10,7 @@ export default { FileIcon, GlIcon, GlSprintf, + GlTruncate, }, directives: { GlTooltip, @@ -22,7 +23,8 @@ export default { }, filePath: { type: String, - required: true, + required: false, + default: '', }, errorFn: { type: String, @@ -79,26 +81,23 @@ export default { <template> <div class="file-holder"> <div ref="header" class="file-title file-title-flex-parent"> - <div class="file-header-content d-flex align-content-center"> + <div class="file-header-content d-flex align-content-center gl-flex-wrap overflow-hidden"> <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()"> <gl-icon :name="collapseIcon" :size="16" class="gl-mr-2" /> </div> - <file-icon :file-name="filePath" :size="16" aria-hidden="true" css-classes="gl-mr-2" /> - <strong - v-gl-tooltip - :title="filePath" - class="file-title-name d-inline-block overflow-hidden text-truncate limited-width" - data-container="body" - > - {{ filePath }} - </strong> - <clipboard-button - :title="__('Copy file path')" - :text="filePath" - category="tertiary" - size="small" - css-class="gl-mr-1" - /> + <template v-if="filePath"> + <file-icon :file-name="filePath" :size="16" aria-hidden="true" css-classes="gl-mr-2" /> + <strong class="file-title-name d-inline-block overflow-hidden limited-width"> + <gl-truncate with-tooltip :text="filePath" position="middle" /> + </strong> + <clipboard-button + :title="__('Copy file path')" + :text="filePath" + category="tertiary" + size="small" + css-class="gl-mr-1" + /> + </template> <gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')"> <template #span="{ content }"> diff --git a/app/assets/javascripts/error_tracking/components/timeline_chart.vue b/app/assets/javascripts/error_tracking/components/timeline_chart.vue new file mode 100644 index 00000000000..51e0c900e4b --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/timeline_chart.vue @@ -0,0 +1,129 @@ +<script> +import { GlChart } from '@gitlab/ui/dist/charts'; +import { dataVizBlue500 } from '@gitlab/ui/scss_to_js/scss_variables'; +import { hexToRgba } from '@gitlab/ui/dist/utils/utils'; +import { isNumber } from 'lodash'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; +import { logError } from '~/lib/logger'; + +function parseTimelineData(timelineData) { + const xData = []; + const yData = []; + const invalidDataPoints = []; + timelineData.forEach((f) => { + let rawDate; + let count; + + if (Array.isArray(f)) { + [rawDate, count] = f; + } else if (f.count !== undefined && f.time !== undefined) { + rawDate = f.time; + count = f.count; + } + if (rawDate !== undefined && count !== undefined) { + // dates/timestamps are in seconds + const date = isNumber(rawDate) ? rawDate * 1000 : rawDate; + xData.push(formatDate(date)); + yData.push(count); + } else { + invalidDataPoints.push(f); + } + }); + if (invalidDataPoints.length > 0) { + // only log up to 5 invalid data points to reduce log size + logError(`Found invalid data points ${invalidDataPoints.slice(0, 5)}`); + } + return { xData, yData }; +} + +export default { + components: { + GlChart, + }, + props: { + timelineData: { + /** + * Array items can be: + * touples: [a_date: string | number, a_count: number] + * objects: {time: a_date, count: a_count}: {time: string | number, count: number} + * + * Dates can either be string or number/timestamp. + * When dates are timestamps, they are expected in seconds. + * + */ + type: Array, + required: true, + validator(value) { + for (const item of value) { + if (Array.isArray(item)) { + if (item.length !== 2 || !isNumber(item[1])) { + return false; + } + } else if (typeof item === 'object') { + if (!('time' in item) || !('count' in item)) { + return false; + } + } else { + return false; + } + } + return true; + }, + }, + height: { + type: Number, + required: true, + }, + }, + computed: { + chartOptions() { + if (!this.timelineData) { + return {}; + } + const { xData, yData } = parseTimelineData(this.timelineData); + + return { + xAxis: { + type: 'category', + data: xData, + show: true, + axisTick: { + show: false, + }, + axisLabel: { + show: false, + }, + axisLine: { + show: true, + lineStyle: { + width: 1, + color: '#ececec', + }, + }, + }, + yAxis: { + type: 'value', + show: false, + }, + series: [ + { + data: yData, + type: 'bar', + itemStyle: { color: hexToRgba(dataVizBlue500, 0.5) }, + }, + ], + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + }, + }; + }, + }, +}; +</script> + +<template> + <gl-chart v-if="timelineData" :options="chartOptions" :height="height" /> +</template> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js index 37b8007d556..04bb50ab733 100644 --- a/app/assets/javascripts/error_tracking/details.js +++ b/app/assets/javascripts/error_tracking/details.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import csrf from '~/lib/utils/csrf'; +import { parseBoolean } from '~/lib/utils/common_utils'; import ErrorDetails from './components/error_details.vue'; import store from './store'; @@ -19,6 +20,9 @@ export default () => { projectIssuesPath, } = domEl.dataset; + let { integratedErrorTrackingEnabled } = domEl.dataset; + integratedErrorTrackingEnabled = parseBoolean(integratedErrorTrackingEnabled); + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); @@ -40,6 +44,7 @@ export default () => { issueStackTracePath, projectIssuesPath, csrfToken: csrf.token, + integratedErrorTrackingEnabled, }, }); }, diff --git a/app/assets/javascripts/error_tracking/events_tracking.js b/app/assets/javascripts/error_tracking/events_tracking.js index aaef274d0cd..eb38fe6542b 100644 --- a/app/assets/javascripts/error_tracking/events_tracking.js +++ b/app/assets/javascripts/error_tracking/events_tracking.js @@ -1,5 +1,15 @@ +import Tracking from '~/tracking'; + const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings +function sendTrackingEvents(action, integrated) { + Tracking.event(category, action, { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); +} + /** * Tracks snowplow event when User clicks on error link to Sentry * @param {String} externalUrl that will be send as a property for the event @@ -14,47 +24,42 @@ export const trackClickErrorLinkToSentryOptions = (url) => ({ /** * Tracks snowplow event when user views error list */ -export const trackErrorListViewsOptions = { - category, - action: 'view_errors_list', + +export const trackErrorListViewsOptions = (integrated) => { + sendTrackingEvents('view_errors_list', integrated); }; /** * Tracks snowplow event when user views error details */ -export const trackErrorDetailsViewsOptions = { - category, - action: 'view_error_details', +export const trackErrorDetailsViewsOptions = (integrated) => { + sendTrackingEvents('view_error_details', integrated); }; /** * Tracks snowplow event when error status is updated */ -export const trackErrorStatusUpdateOptions = (status) => ({ - category, - action: `update_${status}_status`, -}); +export const trackErrorStatusUpdateOptions = (status, integrated) => { + sendTrackingEvents(`update_${status}_status`, integrated); +}; /** * Tracks snowplow event when error list is filter by status */ -export const trackErrorStatusFilterOptions = (status) => ({ - category, - action: `filter_${status}_status`, -}); +export const trackErrorStatusFilterOptions = (status, integrated) => { + sendTrackingEvents(`filter_${status}_status`, integrated); +}; /** * Tracks snowplow event when error list is sorted by field */ -export const trackErrorSortedByField = (field) => ({ - category, - action: `sort_by_${field}`, -}); +export const trackErrorSortedByField = (field, integrated) => { + sendTrackingEvents(`sort_by_${field}`, integrated); +}; /** * Tracks snowplow event when the Create Issue button is clicked */ -export const trackCreateIssueFromError = { - category, - action: 'click_create_issue_from_error', +export const trackCreateIssueFromError = (integrated) => { + sendTrackingEvents('click_create_issue_from_error', integrated); }; diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js index 8b2086e1522..9805aed681d 100644 --- a/app/assets/javascripts/error_tracking/list.js +++ b/app/assets/javascripts/error_tracking/list.js @@ -18,10 +18,12 @@ export default () => { errorTrackingEnabled, userCanEnableErrorTracking, showIntegratedTrackingDisabledAlert, + integratedErrorTrackingEnabled, } = domEl.dataset; errorTrackingEnabled = parseBoolean(errorTrackingEnabled); userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking); + integratedErrorTrackingEnabled = parseBoolean(integratedErrorTrackingEnabled); showIntegratedTrackingDisabledAlert = parseBoolean(showIntegratedTrackingDisabledAlert); // eslint-disable-next-line no-new @@ -42,6 +44,7 @@ export default () => { projectPath, listPath, showIntegratedTrackingDisabledAlert, + integratedErrorTrackingEnabled, }, }); }, diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index dd21b0f9c92..5745491c32d 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -20,6 +20,10 @@ query errorDetails($fullPath: ID!, $errorId: GitlabErrorTrackingDetailedErrorID! externalUrl externalBaseUrl firstReleaseVersion + frequency { + count + time + } lastReleaseVersion gitlabCommit gitlabCommitPath diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 8b79c661b12..35abcc3d561 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -157,16 +157,18 @@ export default { <template> <form class="feature-flags-form"> <fieldset> - <div class="row"> - <div class="form-group col-md-4"> - <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label> + <div class="gl-display-flex gl-flex-wrap gl-mx-n5"> + <div class="gl-mb-5 gl-px-5 gl-w-full col-md-4"> + <label for="feature-flag-name" class="gl-font-weight-bold" + >{{ s__('FeatureFlags|Name') }} *</label + > <input id="feature-flag-name" v-model="formName" class="form-control" /> </div> </div> - <div class="row"> - <div class="form-group col-md-4"> - <label for="feature-flag-description" class="label-bold"> + <div class="gl-display-flex gl-flex-wrap gl-mx-n5"> + <div class="gl-mb-5 gl-px-5 gl-w-full col-md-4"> + <label for="feature-flag-description" class="gl-font-weight-bold"> {{ s__('FeatureFlags|Description') }} </label> <textarea @@ -185,8 +187,8 @@ export default { :show-categorized-issues="false" /> - <div class="row"> - <div class="col-md-12"> + <div class="gl-display-flex gl-flex-wrap gl-mx-n5"> + <div class="gl-mb-5 gl-px-5 gl-w-full"> <h4>{{ s__('FeatureFlags|Strategies') }}</h4> <div class="gl-display-flex gl-align-items-baseline gl-justify-content-space-between"> <p class="gl-mr-5">{{ $options.translations.newHelpText }}</p> @@ -206,7 +208,7 @@ export default { @delete="deleteStrategy(strategy)" /> </div> - <div v-else class="gl-display-flex gl-justify-content-center gl-border-t gl-py-6 w-100"> + <div v-else class="gl-display-flex gl-justify-content-center gl-border-t gl-py-6 gl-w-full"> <span>{{ $options.translations.noStrategiesText }}</span> </div> </fieldset> diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue index 9dbffe75f6b..53745d3b021 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; import { createNamespacedHelpers } from 'vuex'; import { s__ } from '~/locale'; @@ -11,10 +11,7 @@ const { fetchUserLists, setFilter } = mapActions(['fetchUserLists', 'setFilter'] export default { components: { - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, + GlCollapsibleListbox, ParameterFormGroup, }, props: { @@ -38,24 +35,25 @@ export default { dropdownText() { return this.strategy?.userList?.name ?? this.$options.translations.defaultDropdownText; }, + listboxItems() { + return this.userLists.map((list) => ({ + value: list.id, + text: list.name, + })); + }, }, mounted() { fetchUserLists.apply(this); }, + methods: { setFilter: debounce(setFilter, 250), - fetchUserLists: debounce(fetchUserLists, 250), - onUserListChange(list) { + onUserListChange(listId) { + const list = this.userLists.find((userList) => userList.id === listId); this.$emit('change', { userList: list, }); }, - isSelectedUserList({ id }) { - return id === this.userListId; - }, - setFocus() { - this.$refs.searchBox.focusInput(); - }, }, }; </script> @@ -67,26 +65,16 @@ export default { :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''" > <template #default="{ inputId }"> - <gl-dropdown :id="inputId" :text="dropdownText" @shown="setFocus"> - <gl-search-box-by-type - ref="searchBox" - class="gl-m-3" - :value="filter" - @input="setFilter" - @focus="fetchUserLists" - @keyup="fetchUserLists" - /> - <gl-loading-icon v-if="isLoading" size="sm" /> - <gl-dropdown-item - v-for="list in userLists" - :key="list.id" - :is-checked="isSelectedUserList(list)" - is-check-item - @click="onUserListChange(list)" - > - {{ list.name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + :id="inputId" + :toggle-text="dropdownText" + :loading="isLoading" + :items="listboxItems" + searchable + :selected="userListId" + @select="onUserListChange" + @search="setFilter" + /> </template> </parameter-form-group> </template> diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue index 1c6e6380e76..24f7d567ea7 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue +++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue @@ -1,5 +1,5 @@ <script> -import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg'; +import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg?raw'; import { GlPopover, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/google_cloud/databases/service_table.vue b/app/assets/javascripts/google_cloud/databases/service_table.vue index 80bd6ef28fb..f0489ab1f5b 100644 --- a/app/assets/javascripts/google_cloud/databases/service_table.vue +++ b/app/assets/javascripts/google_cloud/databases/service_table.vue @@ -48,7 +48,7 @@ const i18n = { ), }; -const helpUrlSecrets = helpPagePath('ee/ci/secrets'); +const helpUrlSecrets = helpPagePath('ci/secrets/index'); export default { components: { GlAlert, GlButton, GlLink, GlSprintf, GlTable }, diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue deleted file mode 100644 index 3911201457f..00000000000 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ /dev/null @@ -1,131 +0,0 @@ -<script> -import { - GlButton, - GlFormGroup, - GlFormInput, - GlFormCheckbox, - GlIcon, - GlLink, - GlSprintf, -} from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -export default { - components: { - GlButton, - GlFormCheckbox, - GlFormGroup, - GlFormInput, - GlIcon, - GlLink, - GlSprintf, - }, - data() { - return { - helpUrl: helpPagePath('operations/metrics/embed_grafana', { - anchor: 'use-integration-with-grafana-api', - }), - placeholderUrl: 'https://my-grafana.example.com/', - }; - }, - computed: { - ...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']), - integrationEnabled: { - get() { - return this.grafanaEnabled; - }, - set(grafanaEnabled) { - this.setGrafanaEnabled(grafanaEnabled); - }, - }, - localGrafanaToken: { - get() { - return this.grafanaToken; - }, - set(token) { - this.setGrafanaToken(token); - }, - }, - localGrafanaUrl: { - get() { - return this.grafanaUrl; - }, - set(url) { - this.setGrafanaUrl(url); - }, - }, - }, - methods: { - ...mapActions([ - 'setGrafanaUrl', - 'setGrafanaToken', - 'setGrafanaEnabled', - 'updateGrafanaIntegration', - ]), - }, -}; -</script> - -<template> - <section id="grafana" class="settings no-animate js-grafana-integration"> - <div class="settings-header"> - <h4 - class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" - > - {{ s__('GrafanaIntegration|Grafana authentication') }} - </h4> - <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> - <p class="js-section-sub-header"> - {{ - s__( - 'GrafanaIntegration|Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.', - ) - }} - <gl-link :href="helpUrl">{{ __('Learn more.') }}</gl-link> - </p> - </div> - <div class="settings-content"> - <form> - <gl-form-group :label="__('Enable authentication')" label-for="grafana-integration-enabled"> - <gl-form-checkbox id="grafana-integration-enabled" v-model="integrationEnabled"> - {{ s__('GrafanaIntegration|Active') }} - </gl-form-checkbox> - </gl-form-group> - <gl-form-group - :label="s__('GrafanaIntegration|Grafana URL')" - label-for="grafana-url" - :description="s__('GrafanaIntegration|Enter the base URL of the Grafana instance.')" - > - <gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" /> - </gl-form-group> - <gl-form-group :label="s__('GrafanaIntegration|API token')" label-for="grafana-token"> - <gl-form-input id="grafana-token" v-model="localGrafanaToken" /> - <p class="form-text text-muted"> - <gl-sprintf - :message=" - s__('GrafanaIntegration|Enter the %{docLinkStart}Grafana API token%{docLinkEnd}.') - " - > - <template #docLink="{ content }"> - <gl-link - href="https://grafana.com/docs/http_api/auth/#create-api-token" - target="_blank" - >{{ content }} <gl-icon name="external-link" class="gl-vertical-align-middle" - /></gl-link> - </template> - </gl-sprintf> - </p> - </gl-form-group> - <gl-button - variant="confirm" - category="primary" - data-testid="save-grafana-settings-button" - @click="updateGrafanaIntegration" - > - {{ __('Save changes') }} - </gl-button> - </form> - </div> - </section> -</template> diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js deleted file mode 100644 index 9ade29dae69..00000000000 --- a/app/assets/javascripts/grafana_integration/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import GrafanaIntegration from './components/grafana_integration.vue'; -import store from './store'; - -export default () => { - const el = document.querySelector('.js-grafana-integration'); - - if (!el) return false; - - return new Vue({ - el, - store: store(el.dataset), - render(createElement) { - return createElement(GrafanaIntegration); - }, - }); -}; diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js deleted file mode 100644 index 76e21f09719..00000000000 --- a/app/assets/javascripts/grafana_integration/store/actions.js +++ /dev/null @@ -1,44 +0,0 @@ -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import * as mutationTypes from './mutation_types'; - -export const setGrafanaUrl = ({ commit }, url) => commit(mutationTypes.SET_GRAFANA_URL, url); - -export const setGrafanaToken = ({ commit }, token) => - commit(mutationTypes.SET_GRAFANA_TOKEN, token); - -export const setGrafanaEnabled = ({ commit }, enabled) => - commit(mutationTypes.SET_GRAFANA_ENABLED, enabled); - -export const updateGrafanaIntegration = ({ state, dispatch }) => - axios - .patch(state.operationsSettingsEndpoint, { - project: { - grafana_integration_attributes: { - grafana_url: state.grafanaUrl, - token: state.grafanaToken, - enabled: state.grafanaEnabled, - }, - }, - }) - .then(() => dispatch('receiveGrafanaIntegrationUpdateSuccess')) - .catch((error) => dispatch('receiveGrafanaIntegrationUpdateError', error)); - -export const receiveGrafanaIntegrationUpdateSuccess = () => { - /** - * The operations_controller currently handles successful requests - * by creating an alert banner message to notify the user. - */ - refreshCurrentPage(); -}; - -export const receiveGrafanaIntegrationUpdateError = (_, error) => { - const { response } = error; - const message = response.data && response.data.message ? response.data.message : ''; - - createAlert({ - message: `${__('There was an error saving your changes.')} ${message}`, - }); -}; diff --git a/app/assets/javascripts/grafana_integration/store/index.js b/app/assets/javascripts/grafana_integration/store/index.js deleted file mode 100644 index a11bd8089fd..00000000000 --- a/app/assets/javascripts/grafana_integration/store/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import mutations from './mutations'; -import createState from './state'; - -Vue.use(Vuex); - -export const createStore = (initialState) => - new Vuex.Store({ - state: createState(initialState), - actions, - mutations, - }); - -export default createStore; diff --git a/app/assets/javascripts/grafana_integration/store/mutation_types.js b/app/assets/javascripts/grafana_integration/store/mutation_types.js deleted file mode 100644 index 314c3a4039a..00000000000 --- a/app/assets/javascripts/grafana_integration/store/mutation_types.js +++ /dev/null @@ -1,3 +0,0 @@ -export const SET_GRAFANA_URL = 'SET_GRAFANA_URL'; -export const SET_GRAFANA_TOKEN = 'SET_GRAFANA_TOKEN'; -export const SET_GRAFANA_ENABLED = 'SET_GRAFANA_ENABLED'; diff --git a/app/assets/javascripts/grafana_integration/store/mutations.js b/app/assets/javascripts/grafana_integration/store/mutations.js deleted file mode 100644 index 0992030d404..00000000000 --- a/app/assets/javascripts/grafana_integration/store/mutations.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_GRAFANA_URL](state, url) { - state.grafanaUrl = url; - }, - [types.SET_GRAFANA_TOKEN](state, token) { - state.grafanaToken = token; - }, - [types.SET_GRAFANA_ENABLED](state, enabled) { - state.grafanaEnabled = enabled; - }, -}; diff --git a/app/assets/javascripts/grafana_integration/store/state.js b/app/assets/javascripts/grafana_integration/store/state.js deleted file mode 100644 index a912eb58327..00000000000 --- a/app/assets/javascripts/grafana_integration/store/state.js +++ /dev/null @@ -1,8 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; - -export default (initialState = {}) => ({ - operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, - grafanaToken: initialState.grafanaIntegrationToken || '', - grafanaUrl: initialState.grafanaIntegrationUrl || '', - grafanaEnabled: parseBoolean(initialState.grafanaIntegrationEnabled) || false, -}); diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index d0b0a485fe6..ae7676a3e9e 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -6,8 +6,6 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; -import workItemQuery from '~/work_items/graphql/work_item.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 = { @@ -47,6 +45,16 @@ export const config = { }, }, }, + DescriptionVersion: { + fields: { + startVersionId: { + read() { + // we need to set this when fetching the diff in the last 10 mins , the starting diff will be the very first one , so need to save it + return ''; + }, + }, + }, + }, WorkItem: { fields: { // widgets policy because otherwise the subscriptions invalidate the cache @@ -82,14 +90,6 @@ export const config = { }); }, }, - userPermissions: { - read(permission = {}) { - return { - ...permission, - setWorkItemMetadata: false, - }; - }, - }, }, }, MemberInterfaceConnection: { @@ -181,28 +181,6 @@ export const config = { export const resolvers = { Mutation: { - addHierarchyChild: (_, { id, workItem }, { cache }) => { - const queryArgs = { query: workItemQuery, variables: { id } }; - const sourceData = cache.readQuery(queryArgs); - - const data = produce(sourceData, (draftState) => { - findHierarchyWidgetChildren(draftState.workItem).push(workItem); - }); - - cache.writeQuery({ ...queryArgs, data }); - }, - removeHierarchyChild: (_, { id, workItem }, { cache }) => { - const queryArgs = { query: workItemQuery, variables: { id } }; - const sourceData = cache.readQuery(queryArgs); - - const data = produce(sourceData, (draftState) => { - const hierarchyChildren = findHierarchyWidgetChildren(draftState.workItem); - const index = hierarchyChildren.findIndex((child) => child.id === workItem.id); - hierarchyChildren.splice(index, 1); - }); - - cache.writeQuery({ ...queryArgs, data }); - }, updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { const sourceData = cache.readQuery({ query: getIssueStateQuery }); const data = produce(sourceData, (draftData) => { diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index f35886716ee..7651bbba71c 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -3,6 +3,10 @@ "AlertManagementHttpIntegration", "AlertManagementPrometheusIntegration" ], + "BaseHeaderInterface": [ + "AuditEventStreamingHeader", + "AuditEventsStreamingInstanceHeader" + ], "CiVariable": [ "CiGroupVariable", "CiInstanceVariable", @@ -94,6 +98,7 @@ "ContainerRepositoryRegistry", "DependencyProxyBlobRegistry", "DependencyProxyManifestRegistry", + "DesignManagementRepositoryRegistry", "JobArtifactRegistry", "LfsObjectRegistry", "MergeRequestDiffRegistry", @@ -150,6 +155,7 @@ "VulnerabilityDetailList", "VulnerabilityDetailMarkdown", "VulnerabilityDetailModuleLocation", + "VulnerabilityDetailNamedList", "VulnerabilityDetailTable", "VulnerabilityDetailText", "VulnerabilityDetailUrl" diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index c05b4a5950c..c59a061597c 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -6,7 +6,7 @@ query projectUsersSearch($search: String!, $fullPath: ID!, $after: String, $firs id users: projectMembers( search: $search - relations: [DIRECT, INHERITED, INVITED_GROUPS] + relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS] first: $first after: $after sort: USER_FULL_NAME_ASC diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql index 5a589b094de..8fa786535c8 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql @@ -10,7 +10,7 @@ query projectUsersSearchWithMRPermissions( id users: projectMembers( search: $search - relations: [DIRECT, INHERITED, INVITED_GROUPS] + relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS] sort: USER_FULL_NAME_ASC ) { nodes { diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 82eddf5603f..ebfffdaaf50 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -41,10 +41,6 @@ export default { type: Object, required: true, }, - hideProjects: { - type: Boolean, - required: true, - }, }, data() { return { diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index 1f9fc68a612..8d193310a98 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -56,7 +56,7 @@ export default { 'An error occurred while checking group path. Please refresh and try again.', ), changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'), - learnMore: s__('Groups|Learn more'), + learnMore: __('Learn more'), }, inputSize: { md: 'lg' }, changingGroupPathHelpPagePath: helpPagePath('user/group/manage', { diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 79a2e11b0bb..982dab45117 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -176,7 +176,7 @@ export default { :title="title" :lazy="lazy" > - <groups-app :action="key" :service="service" :store="store" :hide-projects="false"> + <groups-app :action="key" :service="service" :store="store"> <template v-if="emptyStateComponent" #empty-state> <component :is="emptyStateComponent" /> </template> diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index f6711bde7d0..df2a23dc0f7 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -2,11 +2,11 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; +import GroupItemComponent from 'jh_else_ce/groups/components/group_item.vue'; import Translate from '../vue_shared/translate'; import GroupsApp from './components/app.vue'; import GroupFolderComponent from './components/group_folder.vue'; -import GroupItemComponent from './components/group_item.vue'; import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants'; import GroupFilterableList from './groups_filterable_list'; import GroupsService from './service/groups_service'; @@ -73,17 +73,15 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { }, data() { const { dataset } = dataEl || this.$options.el; - const hideProjects = parseBoolean(dataset.hideProjects); const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); const renderEmptyState = parseBoolean(dataset.renderEmptyState); const service = new GroupsService(endpoint || dataset.endpoint); - const store = new GroupsStore({ hideProjects, showSchemaMarkup }); + const store = new GroupsStore({ hideProjects: true, showSchemaMarkup }); return { action, store, service, - hideProjects, renderEmptyState, loading: true, containerId, @@ -120,7 +118,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { action: this.action, store: this.store, service: this.service, - hideProjects: this.hideProjects, renderEmptyState: this.renderEmptyState, containerId: this.containerId, }, diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index 4064520d1ca..ced5d76d8b9 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import { GlToast } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; +import GroupItem from 'jh_else_ce/groups/components/group_item.vue'; import GroupFolder from './components/group_folder.vue'; -import GroupItem from './components/group_item.vue'; import { ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, ACTIVE_TAB_SHARED, diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 422ec27346e..8c7612f37ff 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -215,7 +215,7 @@ export default { <form role="search" :aria-label="$options.i18n.SEARCH_GITLAB" - class="header-search gl-relative gl-rounded-base gl-w-full" + class="header-search-form gl-relative gl-rounded-base gl-w-full" :class="searchBarClasses" data-testid="header-search-form" > diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index f6963263725..2bbad5f3f98 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -11,12 +11,12 @@ export const initHeaderSearchApp = (search = '') => { const el = document.getElementById('js-header-search'); const headerEl = document.querySelector('.header-content'); - if (!el && !headerEl) { + if (!el || !headerEl) { return false; } const searchContainer = headerEl.querySelector('.global-search-container'); - const newHeader = headerEl.querySelector('.header-search-new'); + const newHeader = headerEl.querySelector('.header-search'); const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset; let { searchContext } = el.dataset; diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 51af73decad..11a0095db92 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -42,6 +42,7 @@ export const initGitlabWebIDE = async (el) => { editorFontSrcUrl, editorFontFormat, editorFontFamily, + codeSuggestionsEnabled, } = el.dataset; const rootEl = setupRootElement(el); @@ -74,6 +75,7 @@ export const initGitlabWebIDE = async (el) => { fontFamily: editorFontFamily, format: editorFontFormat, }, + codeSuggestionsEnabled, handleTracking, async handleStartRemote({ remoteHost, remotePath, connectionToken }) { const confirmed = await confirmAction( diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index 9708e5e588c..bf0d3ed337c 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -18,10 +18,6 @@ export const templateTypes = () => [ name: __('Dockerfile'), key: 'dockerfiles', }, - { - name: '.metrics-dashboard.yml', - key: 'metrics_dashboard_ymls', - }, ]; export const showFileTemplatesBar = (_, getters, rootState) => (name) => diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 246d27d3b94..fe50cb77eb8 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -560,7 +560,7 @@ export default { gitlabLogo: window.gon.gitlab_logo, PAGE_SIZES, permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }), - betaFeatureHelpPath: helpPagePath('policy/alpha-beta-support', { anchor: 'beta-features' }), + betaFeatureHelpPath: helpPagePath('policy/experiment-beta-support', { anchor: 'beta-features' }), popoverOptions: { title: __('What is listed here?') }, i18n, LOCAL_STORAGE_KEY: 'gl-bulk-imports-status-page-size-v1', diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 6b5a828c009..e803e11bf6d 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -57,6 +57,8 @@ export const integrationTriggerEvents = { PIPELINE: 'pipeline_events', WIKI_PAGE: 'wiki_page_events', DEPLOYMENT: 'deployment_events', + ALERT: 'alert_events', + INCIDENT: 'incident_events', }; export const integrationTriggerEventTitles = { @@ -82,6 +84,10 @@ export const integrationTriggerEventTitles = { [integrationTriggerEvents.DEPLOYMENT]: s__( 'IntegrationEvents|A deployment is started or finished', ), + [integrationTriggerEvents.ALERT]: s__('IntegrationEvents|A new, unique alert is recorded'), + [integrationTriggerEvents.INCIDENT]: s__( + 'IntegrationEvents|An incident is created, closed, or reopened', + ), }; export const billingPlans = { @@ -104,4 +110,25 @@ export const placeholderForType = { [INTEGRATION_TYPE_MATTERMOST]: __('my-channel'), }; +export const INTEGRATION_FORM_TYPE_JIRA = 'jira'; export const INTEGRATION_FORM_TYPE_SLACK = 'gitlab_slack_application'; + +export const jiraIntegrationAuthFields = { + AUTH_TYPE: 'jira_auth_type', + USERNAME: 'username', + PASSWORD: 'password', +}; +export const jiraAuthTypeFieldProps = [ + { + username: s__('JiraService|Email or username'), + password: s__('JiraService|API token or password'), + passwordHelp: s__( + 'JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server', + ), + nonEmptyPassword: s__('JiraService|New API token or password'), + }, + { + password: s__('JiraService|Jira personal access token'), + nonEmptyPassword: s__('JiraService|New Jira personal access token'), + }, +]; diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 904e5639cac..0a29906d5aa 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -97,7 +97,7 @@ export default { return isEmpty(this.value) && this.required; }, options() { - return this.choices.map((choice) => { + return this.choices?.map((choice) => { return { value: choice[1], text: choice[0], diff --git a/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue new file mode 100644 index 00000000000..30a39e48959 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue @@ -0,0 +1,152 @@ +<script> +import { mapGetters } from 'vuex'; +import { isEmpty } from 'lodash'; +import { GlFormGroup, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +import { jiraIntegrationAuthFields, jiraAuthTypeFieldProps } from '~/integrations/constants'; +import DynamicField from './dynamic_field.vue'; + +const authTypeOptions = [ + { + value: 0, + text: s__('JiraService|Basic'), + }, + { + value: 1, + text: s__('JiraService|Jira personal access token'), + help: s__('JiraService|Recommended. Only available for Jira Data Center and Jira Server.'), + }, +]; + +export default { + name: 'JiraAuthFields', + + components: { + GlFormGroup, + GlFormRadio, + GlFormRadioGroup, + DynamicField, + }, + + props: { + isValidated: { + type: Boolean, + required: false, + default: false, + }, + + fields: { + type: Array, + required: false, + default: () => [], + }, + }, + + data() { + return { + authType: 0, + }; + }, + + computed: { + ...mapGetters(['currentKey', 'isInheriting']), + + isAuthTypeBasic() { + return this.authType === 0; + }, + + isNonEmptyPassword() { + return !isEmpty(this.passwordField?.value); + }, + + authTypeProps() { + return jiraAuthTypeFieldProps[this.authType]; + }, + + authTypeField() { + return this.findFieldByName(jiraIntegrationAuthFields.AUTH_TYPE); + }, + + usernameField() { + return this.findFieldByName(jiraIntegrationAuthFields.USERNAME); + }, + + passwordField() { + return this.findFieldByName(jiraIntegrationAuthFields.PASSWORD); + }, + + usernameProps() { + return { + ...this.usernameField, + ...(this.isAuthTypeBasic ? { required: true } : {}), + title: this.authTypeProps.username, + }; + }, + + passwordProps() { + const extraProps = this.isNonEmptyPassword + ? { title: this.authTypeProps.nonEmptyPassword } + : { title: this.authTypeProps.password, help: this.authTypeProps.passwordHelp }; + + return { + ...this.passwordField, + ...extraProps, + }; + }, + }, + + mounted() { + const authTypeValue = this.authTypeField?.value; + if (authTypeValue) { + this.authType = parseInt(authTypeValue, 10); + } + }, + + methods: { + findFieldByName(name) { + return this.fields.find((field) => field.name === name); + }, + }, + + authTypeOptions, + + i18n: { + authTypeLabel: __('Authentication method'), + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.authTypeLabel" label-for="service[jira_auth_type]"> + <input name="service[jira_auth_type]" type="hidden" :value="authType" /> + <gl-form-radio-group v-model="authType" :disabled="isInheriting"> + <gl-form-radio + v-for="option in $options.authTypeOptions" + :key="option.value" + :value="option.value" + > + <template v-if="option.help" #help> + {{ option.help }} + </template> + {{ option.text }} + </gl-form-radio> + </gl-form-radio-group> + + <div class="gl-ml-6 gl-mt-3"> + <dynamic-field + v-if="isAuthTypeBasic" + :key="`${currentKey}-${usernameProps.name}`" + data-testid="jira-auth-username" + v-bind="usernameProps" + :is-validated="isValidated" + /> + <dynamic-field + :key="`${currentKey}-${passwordProps.name}`" + data-testid="jira-auth-password" + v-bind="passwordProps" + :is-validated="isValidated" + /> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue index 63650400bb7..96ba276033c 100644 --- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -1,16 +1,16 @@ <script> -import { GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; import { mapState } from 'vuex'; import { s__ } from '~/locale'; import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants'; const dropdownOptions = [ { - value: false, + value: 'default', text: s__('Integrations|Use default settings'), }, { - value: true, + value: 'custom', text: s__('Integrations|Use custom settings'), }, ]; @@ -19,8 +19,7 @@ export default { dropdownOptions, name: 'OverrideDropdown', components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlLink, }, props: { @@ -39,8 +38,10 @@ export default { }, }, data() { + const selectedValue = this.override ? 'custom' : 'default'; return { - selected: dropdownOptions.find((x) => x.value === this.override), + selectedValue, + selectedOption: dropdownOptions.find((x) => x.value === selectedValue), }; }, computed: { @@ -54,9 +55,10 @@ export default { }, }, methods: { - onClick(option) { - this.selected = option; - this.$emit('change', option.value); + onSelect(value) { + this.selectedValue = value; + this.selectedOption = dropdownOptions.find((item) => item.value === value); + this.$emit('change', value === 'custom'); }, }, }; @@ -73,14 +75,11 @@ export default { }}</gl-link> </span> <input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" /> - <gl-dropdown :text="selected.text"> - <gl-dropdown-item - v-for="option in $options.dropdownOptions" - :key="option.value" - @click="onClick(option)" - > - {{ option.text }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + v-model="selectedValue" + :toggle-text="selectedOption.text" + :items="$options.dropdownOptions" + @select="onSelect" + /> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue index 364e9324e43..6237f7983a6 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/connection.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue @@ -1,5 +1,6 @@ <script> import { mapGetters } from 'vuex'; +import { INTEGRATION_FORM_TYPE_JIRA, jiraIntegrationAuthFields } from '~/integrations/constants'; import ActiveCheckbox from '../active_checkbox.vue'; import DynamicField from '../dynamic_field.vue'; @@ -9,6 +10,10 @@ export default { components: { ActiveCheckbox, DynamicField, + JiraAuthFields: () => + import( + /* webpackChunkName: 'integrationJiraAuthFields' */ '~/integrations/edit/components/jira_auth_fields.vue' + ), }, props: { fields: { @@ -24,6 +29,29 @@ export default { }, computed: { ...mapGetters(['currentKey', 'propsSource']), + + isJiraIntegration() { + return this.propsSource.type === INTEGRATION_FORM_TYPE_JIRA; + }, + + filteredFields() { + if (!this.isJiraIntegration) { + return this.fields; + } + + return this.fields.filter( + (field) => !Object.values(jiraIntegrationAuthFields).includes(field.name), + ); + }, + jiraAuthFields() { + if (!this.isJiraIntegration) { + return []; + } + + return this.fields.filter((field) => + Object.values(jiraIntegrationAuthFields).includes(field.name), + ); + }, }, }; </script> @@ -36,10 +64,16 @@ export default { @toggle-integration-active="$emit('toggle-integration-active', $event)" /> <dynamic-field - v-for="field in fields" + v-for="field in filteredFields" :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" /> + <jira-auth-fields + v-if="isJiraIntegration" + :key="`${currentKey}-jira-auth-fields`" + :is-validated="isValidated" + :fields="jiraAuthFields" + /> </div> </template> diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/api.js b/app/assets/javascripts/integrations/gitlab_slack_application/api.js new file mode 100644 index 00000000000..9a4887f70f5 --- /dev/null +++ b/app/assets/javascripts/integrations/gitlab_slack_application/api.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export const addProjectToSlack = (url, projectId) => { + return axios.get(url, { + params: { project_id: projectId }, + }); +}; diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue b/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue new file mode 100644 index 00000000000..bcb199853bd --- /dev/null +++ b/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue @@ -0,0 +1,135 @@ +<script> +import { GlButton, GlIcon, GlLink } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { helpPagePath } from '~/helpers/help_page_helper'; + +import { i18n } from '../constants'; + +import { addProjectToSlack } from '../api'; +import ProjectsDropdown from './projects_dropdown.vue'; + +export default { + components: { + GlButton, + GlIcon, + GlLink, + ProjectsDropdown, + }, + props: { + projects: { + type: Array, + required: false, + default: () => [], + }, + isSignedIn: { + type: Boolean, + required: true, + }, + signInPath: { + type: String, + required: true, + }, + slackLinkPath: { + type: String, + required: true, + }, + gitlabLogoPath: { + type: String, + required: true, + }, + slackLogoPath: { + type: String, + required: true, + }, + }, + i18n, + learnMoreLink: helpPagePath('user/project/integrations/gitlab_slack_application', { + anchor: 'configuration', + }), + data() { + return { + selectedProject: null, + }; + }, + computed: { + hasProjects() { + return this.projects.length > 0; + }, + }, + methods: { + selectProject(project) { + this.selectedProject = project; + }, + addToSlack() { + addProjectToSlack(this.slackLinkPath, this.selectedProject.id) + .then((response) => redirectTo(response.data.add_to_slack_link)) // eslint-disable-line import/no-deprecated + .catch(() => + createAlert({ + message: i18n.slackErrorMessage, + }), + ); + }, + }, +}; +</script> + +<template> + <div class="gl-max-w-max-content gl-mx-auto gl-mt-11 gl-text-center"> + <div v-once class="gl-my-5 gl-display-flex gl-justify-content-center gl-align-items-center"> + <img :src="gitlabLogoPath" :alt="$options.i18n.gitlabLogoAlt" class="gl-h-11 gl-w-11" /> + <gl-icon name="arrow-right" :size="32" class="gl-mx-5 gl-text-gray-200" /> + <img + :src="slackLogoPath" + :alt="$options.i18n.slackLogoAlt" + class="gitlab-slack-slack-logo gl-h-11 gl-w-11" + /> + </div> + + <h2>{{ $options.i18n.title }}</h2> + + <div data-testid="gitlab-slack-content"> + <template v-if="isSignedIn"> + <div v-if="hasProjects" class="gl-mt-6"> + <p> + {{ $options.i18n.dropdownLabel }} + </p> + + <projects-dropdown + :projects="projects" + :selected-project="selectedProject" + @project-selected="selectProject" + /> + + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + category="primary" + variant="confirm" + :disabled="!selectedProject" + @click="addToSlack" + > + {{ $options.i18n.dropdownButtonText }} + </gl-button> + </div> + </div> + <div v-else> + <p class="gl-mb-0">{{ $options.i18n.noProjects }}</p> + <p> + <span>{{ $options.i18n.noProjectsDescription }}</span> + <gl-link :href="$options.learnMoreLink" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link + >. + </p> + </div> + </template> + + <template v-else> + <p>{{ $options.i18n.signInLabel }}</p> + <gl-button category="primary" variant="confirm" :href="signInPath"> + {{ $options.i18n.signInButtonText }} + </gl-button> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue b/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue new file mode 100644 index 00000000000..26d191cd0bf --- /dev/null +++ b/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue @@ -0,0 +1,55 @@ +<script> +import { GlDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; + +export default { + components: { + GlDropdown, + ProjectListItem, + }, + props: { + projectDropdownText: { + type: String, + required: false, + default: __('Select a project'), + }, + projects: { + type: Array, + required: false, + default: () => [], + }, + selectedProject: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + dropdownText() { + return this.selectedProject + ? this.selectedProject.name_with_namespace + : this.projectDropdownText; + }, + }, + methods: { + onClick(project) { + this.$emit('project-selected', project); + this.$refs.dropdown.hide(true); + }, + }, +}; +</script> + +<template> + <gl-dropdown ref="dropdown" block :text="dropdownText" menu-class="gl-w-full!"> + <project-list-item + v-for="project in projects" + :key="project.id" + :project="project" + :selected="false" + @click="onClick(project)" + /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/constants.js b/app/assets/javascripts/integrations/gitlab_slack_application/constants.js new file mode 100644 index 00000000000..4f3c75b64fb --- /dev/null +++ b/app/assets/javascripts/integrations/gitlab_slack_application/constants.js @@ -0,0 +1,15 @@ +import { __, s__ } from '~/locale'; + +export const i18n = { + slackErrorMessage: __('Unable to build Slack link.'), + gitlabLogoAlt: __('GitLab logo'), + slackLogoAlt: __('Slack logo'), + title: s__('SlackIntegration|GitLab for Slack'), + dropdownLabel: s__('SlackIntegration|Select a GitLab project to link with your Slack workspace.'), + dropdownButtonText: __('Continue'), + noProjects: __('No projects available.'), + noProjectsDescription: __('Make sure you have the correct permissions to link your project.'), + learnMore: __('Learn more'), + signInLabel: s__('JiraService|Sign in to GitLab to get started.'), + signInButtonText: __('Sign in to GitLab'), +}; diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/index.js b/app/assets/javascripts/integrations/gitlab_slack_application/index.js new file mode 100644 index 00000000000..8bbb81df997 --- /dev/null +++ b/app/assets/javascripts/integrations/gitlab_slack_application/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import GitlabSlackApplication from './components/gitlab_slack_application.vue'; + +export default () => { + const el = document.querySelector('.js-gitlab-slack-application'); + + if (!el) return null; + + const { + projects, + isSignedIn, + signInPath, + slackLinkPath, + gitlabLogoPath, + slackLogoPath, + } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(GitlabSlackApplication, { + props: { + projects: JSON.parse(projects), + isSignedIn: parseBoolean(isSignedIn), + signInPath, + slackLinkPath, + gitlabLogoPath, + slackLogoPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index 10c08d63612..cc95027f0db 100644 --- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -1,15 +1,24 @@ <script> import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { uniqueId, isEmpty } from 'lodash'; import { importProjectMembers } from '~/api/projects_api'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import eventHub from '../event_hub'; + import { displaySuccessfulInvitationAlert, reloadOnInvitationSuccess, } from '../utils/trigger_successful_invite_alert'; -import { PROJECT_SELECT_LABEL_ID } from '../constants'; + +import { + PROJECT_SELECT_LABEL_ID, + IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, + IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL, +} from '../constants'; + +import UserLimitNotification from './user_limit_notification.vue'; import ProjectSelect from './project_select.vue'; export default { @@ -18,8 +27,15 @@ export default { GlFormGroup, GlModal, GlSprintf, + UserLimitNotification, ProjectSelect, }, + mixins: [ + Tracking.mixin({ + category: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, + label: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL, + }), + ], props: { projectId: { type: String, @@ -34,6 +50,11 @@ export default { required: false, default: false, }, + usersLimitDataset: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -54,6 +75,12 @@ export default { validationState() { return this.invalidFeedbackMessage === '' ? null : false; }, + showUserLimitNotification() { + return !isEmpty(this.usersLimitDataset.alertVariant); + }, + limitVariant() { + return this.usersLimitDataset.alertVariant; + }, actionPrimary() { return { text: this.$options.i18n.modalPrimaryButton, @@ -79,6 +106,7 @@ export default { }, methods: { openModal() { + this.track('render'); this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId); }, closeModal() { @@ -102,6 +130,8 @@ export default { }); }, onInviteSuccess() { + this.track('invite_successful'); + if (this.reloadPageOnSubmit) { reloadOnInvitationSuccess(); } else { @@ -115,6 +145,12 @@ export default { showErrorAlert() { this.invalidFeedbackMessage = this.$options.i18n.defaultError; }, + onCancel() { + this.track('click_cancel'); + }, + onClose() { + this.track('click_x'); + }, }, toastOptions() { return { @@ -153,7 +189,15 @@ export default { no-focus-on-show @primary="submitImport" @hidden="resetFields" + @cancel="onCancel" + @close="onClose" > + <user-limit-notification + v-if="showUserLimitNotification" + class="gl-mb-5" + :limit-variant="limitVariant" + :users-limit-dataset="usersLimitDataset" + /> <p ref="modalIntro"> <gl-sprintf :message="modalIntro"> <template #strong="{ content }"> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 68602068699..e0bfa1111e8 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -114,11 +114,11 @@ export default { }, }, methods: { - handleTextInput(query) { + handleTextInput(inputQuery) { this.hideDropdownWithNoItems = false; - this.query = query; + this.query = inputQuery.trim(); this.loading = true; - this.retrieveUsers(query); + this.retrieveUsers(); }, updateTokenClasses() { this.selectedTokens = this.selectedTokens.map((token) => ({ diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 9afcaff6e16..a4fe1a413aa 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -25,6 +25,8 @@ export const TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI = 'dropdown-text-emoji'; export const TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN = 'dropdown-text'; export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal'; export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button'; +export const IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY = 'invite_project_members_modal'; +export const IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL = 'project-members-page'; export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members'); export const MEMBERS_MODAL_CELEBRATE_TITLE = s__( 'InviteMembersModal|GitLab is better with colleagues!', diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js index 227d8395250..90479038414 100644 --- a/app/assets/javascripts/invite_members/init_import_project_members_modal.js +++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default function initImportProjectMembersModal() { const el = document.querySelector('.js-import-project-members-modal'); @@ -9,16 +9,20 @@ export default function initImportProjectMembersModal() { return false; } - const { projectId, projectName, reloadPageOnSubmit } = el.dataset; + const { projectId, projectName, reloadPageOnSubmit, usersLimitDataset } = el.dataset; return new Vue({ el, + provide: { + name: projectName, + }, render: (createElement) => createElement(ImportProjectMembersModal, { props: { projectId, projectName, reloadPageOnSubmit: parseBoolean(reloadPageOnSubmit), + usersLimitDataset: convertObjectPropsToCamelCase(JSON.parse(usersLimitDataset || '{}')), }, }), }); diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index b492194d1cf..872e1d4269d 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -1,18 +1,13 @@ <script> -import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui'; import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import CsvExportModal from './csv_export_modal.vue'; import CsvImportModal from './csv_import_modal.vue'; export default { - i18n: { - exportAsCsvButtonText: __('Export as CSV'), - importCsvText: __('Import CSV'), - importFromJiraText: __('Import from Jira'), - }, components: { - GlDropdownItem, + GlDisclosureDropdownItem, CsvExportModal, CsvImportModal, }, @@ -48,6 +43,22 @@ export default { default: undefined, }, }, + data() { + return { + dropdownItems: { + exportAsCSV: { + text: __('Export as CSV'), + }, + importCSV: { + text: __('Import CSV'), + }, + importFromJIRA: { + text: __('Import from Jira'), + href: this.projectImportJiraPath, + }, + }, + }; + }, computed: { exportModalId() { return `${this.issuableType}-export-modal`; @@ -61,23 +72,25 @@ export default { <template> <ul class="gl-display-contents"> - <gl-dropdown-item + <gl-disclosure-dropdown-item v-if="showExportButton" v-gl-modal="exportModalId" + data-testid="export-as-csv-button" data-qa-selector="export_as_csv_button" - > - {{ $options.i18n.exportAsCsvButtonText }} - </gl-dropdown-item> - <gl-dropdown-item v-if="showImportButton" v-gl-modal="importModalId"> - {{ $options.i18n.importCsvText }} - </gl-dropdown-item> - <gl-dropdown-item + :item="dropdownItems.exportAsCSV" + /> + <gl-disclosure-dropdown-item + v-if="showImportButton" + v-gl-modal="importModalId" + data-testid="import-from-csv-button" + :item="dropdownItems.importCSV" + /> + <gl-disclosure-dropdown-item v-if="showImportButton && canEdit" - :href="projectImportJiraPath" + data-testid="import-from-jira-link" data-qa-selector="import_from_jira_link" - > - {{ $options.i18n.importFromJiraText }} - </gl-dropdown-item> + :item="dropdownItems.importFromJIRA" + /> <csv-export-modal v-if="showExportButton" diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 403997779ac..eab7d01be14 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -40,6 +40,9 @@ export default { iconName: 'lock', visible: this.isLocked, dataTestId: 'locked', + tooltip: sprintf(__('This %{issuable} is locked. Only project members can comment.'), { + issuable: noteableTypeText[this.getNoteableData.targetType], + }), }, { iconName: 'spam', @@ -67,7 +70,7 @@ export default { <div v-if="meta.visible" :key="meta.iconName" - v-gl-tooltip + v-gl-tooltip.bottom :data-testid="meta.dataTestId" :title="meta.tooltip || null" :class="{ diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index df50a30abb7..ff48bfceb29 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -248,7 +248,7 @@ export default { size="small" :disabled="removeDisabled" class="js-issue-item-remove-button gl-mr-2" - data-qa-selector="remove_related_issue_button" + data-testid="remove_related_issue_button" :title="__('Remove')" :aria-label="__('Remove')" @click="onRemoveRequest" diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index c79612ad5d0..444ee704521 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -14,6 +14,7 @@ export const TYPE_EPIC = 'epic'; export const TYPE_INCIDENT = 'incident'; export const TYPE_ISSUE = 'issue'; export const TYPE_MERGE_REQUEST = 'merge_request'; +export const TYPE_MILESTONE = 'milestone'; export const TYPE_TEST_CASE = 'test_case'; export const WORKSPACE_GROUP = 'group'; diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index b9e4d0df3f2..14fe88b8f61 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -29,6 +29,7 @@ import { getSortOptions, isSortKey, } from '~/issues/list/utils'; +import { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; @@ -126,6 +127,10 @@ export default { update(data) { return data.issues.nodes ?? []; }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + // We need this for handling loading state when using frontend cache + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details + notifyOnNetworkStatusChange: true, result({ data }) { this.pageInfo = data?.issues.pageInfo ?? {}; }, @@ -183,6 +188,17 @@ export default { hasSearch() { return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length); }, + // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache. + // if we have cached data, we disregard the loading state + isLoading() { + return ( + this.$apollo.queries.issues.loading && + !this.$apollo.provider.clients.defaultClient.readQuery({ + query: getIssuesQuery, + variables: this.queryVariables, + }) + ); + }, queryVariables() { return { hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, @@ -446,7 +462,7 @@ export default { :initial-filter-value="filterTokens" :initial-sort-by="sortKey" :issuables="renderedIssues" - :issuables-loading="$apollo.queries.issues.loading" + :issuables-loading="isLoading" namespace="dashboard" recent-searches-storage-key="issues" :search-input-placeholder="$options.i18n.searchPlaceholder" @@ -494,6 +510,7 @@ export default { <gl-empty-state :description="emptyStateDescription" :svg-path="emptyStateSvgPath" + :svg-height="150" :title="emptyStateTitle" /> </template> diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index 005ab5ce3b0..999f07781b2 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -1,10 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; +import { gqlClient } from '~/issues/list/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import IssuesDashboardApp from './components/issues_dashboard_app.vue'; -export function mountIssuesDashboardApp() { +export async function mountIssuesDashboardApp() { const el = document.querySelector('.js-issues-dashboard'); if (!el) { @@ -34,7 +34,7 @@ export function mountIssuesDashboardApp() { el, name: 'IssuesDashboardRoot', apolloProvider: new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: await gqlClient(), }), provide: { autocompleteAwardEmojisPath, diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql index 5625e6afad3..5c331fe95e2 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "./issue.fragment.graphql" +#import "~/issues/list/queries/issue.fragment.graphql" query getDashboardIssues( $hideUsers: Boolean = false @@ -44,8 +44,9 @@ query getDashboardIssues( before: $beforeCursor first: $firstPageSize last: $lastPageSize - ) { + ) @persist { nodes { + __persist ...IssueFragment reference(full: true) } diff --git a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql deleted file mode 100644 index 040763f2ba4..00000000000 --- a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql +++ /dev/null @@ -1,56 +0,0 @@ -fragment IssueFragment on Issue { - id - iid - confidential - createdAt - downvotes - dueDate - hidden - humanTimeEstimate - mergeRequestsCount - moved - state - title - updatedAt - closedAt - upvotes - userDiscussionsCount @include(if: $isSignedIn) - webPath - webUrl - type - assignees @skip(if: $hideUsers) { - nodes { - id - avatarUrl - name - username - webUrl - } - } - author @skip(if: $hideUsers) { - id - avatarUrl - name - username - webUrl - } - labels { - nodes { - id - color - title - description - } - } - milestone { - id - dueDate - startDate - webPath - title - } - taskCompletionStatus { - completedCount - count - } -} diff --git a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue index 8aece24de0c..3c58843bcbc 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue @@ -28,6 +28,7 @@ export default { :description="$options.i18n.noSearchResultsDescription" :title="$options.i18n.noSearchResultsTitle" :svg-path="emptyStateSvgPath" + :svg-height="150" > <template #actions> <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> @@ -49,5 +50,10 @@ export default { </template> </gl-empty-state> - <gl-empty-state v-else :title="$options.i18n.noClosedIssuesTitle" :svg-path="emptyStateSvgPath" /> + <gl-empty-state + v-else + :title="$options.i18n.noClosedIssuesTitle" + :svg-path="emptyStateSvgPath" + :svg-height="150" + /> </template> diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue index 98429f3ffd1..3f29fc66abb 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; @@ -12,7 +12,7 @@ export default { components: { CsvImportExportButtons, GlButton, - GlDropdown, + GlDisclosureDropdown, GlEmptyState, GlLink, GlSprintf, @@ -56,7 +56,11 @@ export default { <template> <div v-if="isSignedIn"> - <gl-empty-state :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath"> + <gl-empty-state + :title="$options.i18n.noIssuesTitle" + :svg-path="emptyStateSvgPath" + :svg-height="150" + > <template #description> <gl-link :href="$options.issuesHelpPagePath"> {{ $options.i18n.noIssuesDescription }} @@ -73,17 +77,17 @@ export default { {{ $options.i18n.newIssueLabel }} </gl-button> - <gl-dropdown + <gl-disclosure-dropdown v-if="showCsvButtons" class="gl-w-full gl-sm-w-auto gl-sm-mr-3" - :text="$options.i18n.importIssues" + :toggle-text="$options.i18n.importIssues" data-qa-selector="import_issues_dropdown" > <csv-import-export-buttons :export-csv-path="exportCsvPathWithQuery" :issuable-count="currentTabCount" /> - </gl-dropdown> + </gl-disclosure-dropdown> <new-resource-dropdown v-if="showNewIssueDropdown" diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 5fb83dfd1ab..83b0bcebe67 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -1,11 +1,11 @@ <script> import { GlButton, + GlButtonGroup, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, GlFilteredSearchToken, GlTooltipDirective, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; @@ -14,6 +14,7 @@ import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_st import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { createAlert, VARIANT_INFO } from '~/alert'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; @@ -68,6 +69,9 @@ import { defaultWorkItemTypes, i18n, ISSUE_REFERENCE, + ISSUES_GRID_VIEW_KEY, + ISSUES_LIST_VIEW_KEY, + ISSUES_VIEW_TYPE_KEY, MAX_LIST_SIZE, PARAM_FIRST_PAGE_SIZE, PARAM_LAST_PAGE_SIZE, @@ -116,19 +120,23 @@ const CrmOrganizationToken = () => export default { i18n, issuableListTabs, + ISSUES_VIEW_TYPE_KEY, + ISSUES_GRID_VIEW_KEY, + ISSUES_LIST_VIEW_KEY, components: { CsvImportExportButtons, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, EmptyStateWithAnyIssues, EmptyStateWithoutAnyIssues, GlButton, - GlDropdown, - GlDropdownDivider, - GlDropdownItem, + GlButtonGroup, IssuableByEmail, IssuableList, IssueCardStatistics, IssueCardTimeInfo, NewResourceDropdown, + LocalStorageSync, }, directives: { GlTooltip: GlTooltipDirective, @@ -194,6 +202,21 @@ export default { sortKey: CREATED_DESC, state: STATUS_OPEN, pageSize: DEFAULT_PAGE_SIZE, + viewType: ISSUES_LIST_VIEW_KEY, + subscribeDropdownOptions: { + items: [ + { + text: i18n.rssLabel, + href: this.rssPath, + extraAttrs: { 'data-testid': 'subscribe-rss' }, + }, + { + text: i18n.calendarLabel, + href: this.calendarPath, + extraAttrs: { 'data-testid': 'subscribe-calendar' }, + }, + ], + }, }; }, apollo: { @@ -504,6 +527,12 @@ export default { }) ); }, + gridViewFeatureEnabled() { + return Boolean(this.glFeatures?.issuesGridView); + }, + isGridView() { + return this.viewType === ISSUES_GRID_VIEW_KEY; + }, }, watch: { $route(newValue, oldValue) { @@ -764,6 +793,15 @@ export default { this.sortKey = sortKey; this.state = state || STATUS_OPEN; }, + switchViewType(type) { + // Filter the wrong data from localStorage + if (type === ISSUES_GRID_VIEW_KEY) { + this.viewType = ISSUES_GRID_VIEW_KEY; + return; + } + // The default view is list view + this.viewType = ISSUES_LIST_VIEW_KEY; + }, }, }; </script> @@ -798,6 +836,7 @@ export default { :has-next-page="pageInfo.hasNextPage" :has-previous-page="pageInfo.hasPreviousPage" :show-filtered-search-friendly-text="hasOrFeature" + :is-grid-view="isGridView" show-work-item-type-icon @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @@ -810,6 +849,30 @@ export default { @page-size-change="handlePageSizeChange" > <template #nav-actions> + <local-storage-sync + v-if="gridViewFeatureEnabled" + :value="viewType" + :storage-key="$options.ISSUES_VIEW_TYPE_KEY" + @input="switchViewType" + > + <gl-button-group> + <gl-button + :variant="isGridView ? 'default' : 'confirm'" + data-testid="list-view-type" + @click="switchViewType($options.ISSUES_LIST_VIEW_KEY)" + > + {{ $options.i18n.listLabel }} + </gl-button> + <gl-button + :variant="isGridView ? 'confirm' : 'default'" + data-testid="grid-view-type" + @click="switchViewType($options.ISSUES_GRID_VIEW_KEY)" + > + {{ $options.i18n.gridLabel }} + </gl-button> + </gl-button-group> + </local-storage-sync> + <gl-button v-if="canBulkUpdate" :disabled="isBulkEditButtonDisabled" @@ -831,12 +894,12 @@ export default { :query-variables="newIssueDropdownQueryVariables" :extract-projects="extractProjects" /> - <gl-dropdown + <gl-disclosure-dropdown v-gl-tooltip.hover="$options.i18n.actionsLabel" category="tertiary" icon="ellipsis_v" no-caret - :text="$options.i18n.actionsLabel" + :toggle-text="$options.i18n.actionsLabel" text-sr-only data-qa-selector="issues_list_more_actions_dropdown" > @@ -845,16 +908,8 @@ export default { :export-csv-path="exportCsvPathWithQuery" :issuable-count="currentTabCount" /> - - <gl-dropdown-divider v-if="showCsvButtons" /> - - <gl-dropdown-item :href="rssPath"> - {{ $options.i18n.rssLabel }} - </gl-dropdown-item> - <gl-dropdown-item :href="calendarPath"> - {{ $options.i18n.calendarLabel }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-group :bordered="true" :group="subscribeDropdownOptions" /> + </gl-disclosure-dropdown> </template> <template #timeframe="{ issuable = {} }"> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 56d3a57457b..1a3d97277c7 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -75,6 +75,10 @@ export const NORMAL_FILTER = 'normalFilter'; export const SPECIAL_FILTER = 'specialFilter'; export const ALTERNATIVE_FILTER = 'alternativeFilter'; +export const ISSUES_VIEW_TYPE_KEY = 'issuesViewType'; +export const ISSUES_LIST_VIEW_KEY = 'List'; +export const ISSUES_GRID_VIEW_KEY = 'Grid'; + export const i18n = { actionsLabel: __('Actions'), calendarLabel: __('Subscribe to calendar'), @@ -116,6 +120,8 @@ export const i18n = { upvotes: __('Upvotes'), titles: __('Titles'), descriptions: __('Descriptions'), + listLabel: __('List'), + gridLabel: __('Grid'), }; export const urlSortParams = { diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql index 46b48e4e41c..6a1967a8875 100644 --- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql @@ -14,7 +14,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) } project(fullPath: $fullPath) @include(if: $isProject) { id - projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { + projectMembers( + search: $search + relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS] + ) { nodes { id user { diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 86311b99f7c..fcdf1f7741b 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -188,6 +188,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, }, data() { const store = new Store({ @@ -446,7 +451,10 @@ export default { }, showStickyHeader() { - this.isStickyHeaderShowing = true; + // only if scrolled under the issue's title + if (this.$refs.title.$el.offsetTop < window.pageYOffset) { + this.isStickyHeaderShowing = true; + } }, handleSaveDescription(description) { @@ -496,6 +504,7 @@ export default { </div> <div v-else> <title-component + ref="title" :issuable-ref="issuableRef" :can-update="canUpdate" :title-html="state.titleHtml" @@ -522,7 +531,13 @@ export default { statusText }}</span></gl-badge > - <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon"> + <span + v-if="isLocked" + v-gl-tooltip.bottom + data-testid="locked" + class="issuable-warning-icon" + :title="__('This issue is locked. Only project members can comment.')" + > <gl-icon name="lock" :aria-label="__('Locked')" /> </span> <confidentiality-badge @@ -533,19 +548,20 @@ export default { /> <span v-if="isHidden" - v-gl-tooltip + v-gl-tooltip.bottom :title="__('This issue is hidden because its author has been banned')" data-testid="hidden" class="issuable-warning-icon" > <gl-icon name="spam" /> </span> - <p - class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" + <a + href="#top" + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal" :title="state.titleText" > {{ state.titleText }} - </p> + </a> </div> </div> </transition> @@ -560,6 +576,7 @@ export default { <component :is="descriptionComponent" :issue-id="issueId" + :issue-iid="issueIid" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 3721f224d5e..3bf4dfc7a99 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -11,8 +11,7 @@ import { TYPE_ISSUE } from '~/issues/constants'; import { __, s__, sprintf } from '~/locale'; import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; -import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql'; -import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql'; +import { addHierarchyChild, removeHierarchyChild } from '~/work_items/graphql/cache_utils'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; @@ -78,6 +77,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, isUpdating: { type: Boolean, required: false, @@ -330,29 +334,33 @@ export default { async createTask({ taskTitle, taskDescription, oldDescription }) { try { const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription); + const iterationInput = { iterationWidget: { iterationId: this.issueDetails.iteration?.id ?? null, }, }; - const input = { - confidential: this.issueDetails.confidential, - description, - hierarchyWidget: { - parentId: this.issueGid, - }, - ...(this.hasIterationsFeature && iterationInput), - milestoneWidget: { - milestoneId: this.issueDetails.milestone?.id ?? null, - }, - projectPath: this.fullPath, - title, - workItemTypeId: this.taskWorkItemTypeId, - }; const { data } = await this.$apollo.mutate({ mutation: createWorkItemMutation, - variables: { input }, + variables: { + input: { + confidential: this.issueDetails.confidential, + description, + hierarchyWidget: { + parentId: this.issueGid, + }, + ...(this.hasIterationsFeature && iterationInput), + milestoneWidget: { + milestoneId: this.issueDetails.milestone?.id ?? null, + }, + projectPath: this.fullPath, + title, + workItemTypeId: this.taskWorkItemTypeId, + }, + }, + update: (cache, { data: { workItemCreate } }) => + addHierarchyChild(cache, this.fullPath, String(this.issueIid), workItemCreate.workItem), }); const { workItem, errors } = data.workItemCreate; @@ -361,11 +369,6 @@ export default { throw new Error(errors); } - await this.$apollo.mutate({ - mutation: addHierarchyChildMutation, - variables: { id: this.issueGid, workItem }, - }); - this.$toast.show(s__('WorkItem|Converted to task'), { action: { text: s__('WorkItem|Undo'), @@ -386,19 +389,14 @@ export default { const { data } = await this.$apollo.mutate({ mutation: deleteWorkItemMutation, variables: { input: { id } }, + update: (cache) => + removeHierarchyChild(cache, this.fullPath, String(this.issueIid), { id }), }); - const { errors } = data.workItemDelete; - - if (errors?.length) { - throw new Error(errors); + if (data.workItemDelete.errors?.length) { + throw new Error(data.workItemDelete.errors); } - await this.$apollo.mutate({ - mutation: removeHierarchyChildMutation, - variables: { id: this.issueGid, workItem: { id } }, - }); - this.$toast.show(s__('WorkItem|Task reverted')); } catch (error) { this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error); diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 775f25bdbc0..576d157e0fc 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -71,7 +71,7 @@ export default { :label="$options.i18n.label" label-class="sr-only" label-for="issuable-type" - class="mb-2 mb-md-0" + class="gl-mb-0" > <gl-collapsible-listbox v-model="selectedIssueType" diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 2e99c03d250..c9e21b296e4 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -222,7 +222,7 @@ export default { <convert-description-modal v-if="issueId && glFeatures.generateDescriptionAi" - class="gl-pl-5 gl-sm-pl-0" + class="gl-pl-5 gl-md-pl-0" :resource-id="resourceId" :user-id="userId" @contentGenerated="setDescription" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 4d9b69ddf99..a36b0c46927 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -20,7 +20,7 @@ import { NEW_ACTIONS_POPOVER_KEY, } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils'; +import { getCookie, parseBoolean, setCookie, isLoggedIn } from '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; import eventHub from '~/notes/event_hub'; @@ -137,6 +137,7 @@ export default { data() { return { isReportAbuseDrawerOpen: false, + isUserSignedIn: isLoggedIn(), }; }, apollo: { @@ -204,7 +205,11 @@ export default { }, hasDesktopDropdown() { return ( - this.canCreateIssue || this.canPromoteToEpic || !this.isIssueAuthor || this.canReportSpam + this.canCreateIssue || + this.canPromoteToEpic || + !this.isIssueAuthor || + this.canReportSpam || + this.issuableReference ); }, hasMobileDropdown() { @@ -219,7 +224,10 @@ export default { return this.glFeatures.movedMrSidebar; }, showLockIssueOption() { - return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE; + return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE && this.isUserSignedIn; + }, + showMovedSidebarOptions() { + return this.isMrSidebarMoved && this.isUserSignedIn; }, }, created() { @@ -326,17 +334,16 @@ export default { </script> <template> - <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-gap-3"> + <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-sm-gap-3"> <gl-dropdown v-if="hasMobileDropdown" class="gl-sm-display-none! w-100" block :text="dropdownText" - data-qa-selector="issue_actions_dropdown" data-testid="mobile-dropdown" :loading="isToggleStateButtonLoading" > - <template v-if="isMrSidebarMoved"> + <template v-if="showMovedSidebarOptions"> <sidebar-subscriptions-widget :iid="String(iid)" :full-path="fullPath" @@ -356,7 +363,7 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="showToggleIssueStateButton" - :data-qa-selector="`mobile_${qaSelector}`" + :data-testid="`mobile_${qaSelector}`" @click="toggleIssueState" > {{ buttonText }} @@ -375,7 +382,7 @@ export default { >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item > <gl-dropdown-item - v-if="issuableEmailAddress" + v-if="issuableEmailAddress && showMovedSidebarOptions" :data-clipboard-text="issuableEmailAddress" data-testid="copy-email" @click="copyEmailAddress" @@ -401,7 +408,7 @@ export default { </gl-dropdown-item> </template> <gl-dropdown-item - v-if="!isIssueAuthor" + v-if="!isIssueAuthor && isUserSignedIn" data-testid="report-abuse-item" @click="toggleReportAbuseDrawer(true)" > @@ -426,7 +433,7 @@ export default { class="gl-display-none gl-sm-display-inline-flex!" :data-qa-selector="qaSelector" :loading="isToggleStateButtonLoading" - data-testid="toggle-button" + data-testid="toggle-issue-state-button" @click="toggleIssueState" > {{ buttonText }} @@ -439,7 +446,6 @@ export default { class="gl-display-none gl-sm-display-inline-flex!" icon="ellipsis_v" category="tertiary" - data-qa-selector="issue_actions_ellipsis_dropdown" :text="dropdownText" :text-sr-only="true" :title="dropdownText" @@ -449,7 +455,7 @@ export default { right @shown="dismissPopover" > - <template v-if="isMrSidebarMoved"> + <template v-if="showMovedSidebarOptions"> <sidebar-subscriptions-widget :iid="String(iid)" :full-path="fullPath" @@ -460,7 +466,7 @@ export default { <gl-dropdown-divider /> </template> - <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + <gl-dropdown-item v-if="canCreateIssue && isUserSignedIn" :href="newIssuePath"> {{ newIssueTypeText }} </gl-dropdown-item> <gl-dropdown-item @@ -482,7 +488,7 @@ export default { >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item > <gl-dropdown-item - v-if="issuableEmailAddress" + v-if="issuableEmailAddress && showMovedSidebarOptions" :data-clipboard-text="issuableEmailAddress" data-testid="copy-email" @click="copyEmailAddress" @@ -502,14 +508,14 @@ export default { <gl-dropdown-item v-gl-modal="$options.deleteModalId" variant="danger" - data-qa-selector="delete_issue_button" + data-testid="delete_issue_button" @click="track('click_dropdown')" > {{ deleteButtonText }} </gl-dropdown-item> </template> <gl-dropdown-item - v-if="!isIssueAuthor" + v-if="!isIssueAuthor && isUserSignedIn" data-testid="report-abuse-item" @click="toggleReportAbuseDrawer(true)" > diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue index 5160903c762..64b916caddb 100644 --- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -17,14 +17,9 @@ export default { methods: { convertToTask() { eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos); - this.closeDropdown(); }, deleteTaskListItem() { eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos); - this.closeDropdown(); - }, - closeDropdown() { - this.$refs.dropdown.close(); }, }, }; @@ -33,7 +28,6 @@ export default { <template> <gl-disclosure-dropdown v-if="canUpdate" - ref="dropdown" class="task-list-item-actions-wrapper" category="tertiary" icon="ellipsis_v" diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 2d2ef327018..c464f48d574 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -60,7 +60,6 @@ export default { 'issue-realtime-trigger-pulse': pulseAnimation, }" class="title gl-font-size-h-display" - data-qa-selector="title_content" data-testid="issue-title" dir="auto" ></h1> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 5a51ac18446..bc4284457f6 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -133,6 +133,7 @@ export function initIssueApp(issueData, store) { isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, issueId: this.getNoteableData?.id, + issueIid: this.getNoteableData?.iid, }, }); }, diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue index 0b286bc903f..58b15b3eed1 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue @@ -14,10 +14,11 @@ export default { ADD_NAMESPACE_MODAL_ID, }; </script> + <template> <div> <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="info"> - {{ s__('Integrations|Add namespace') }} + {{ s__('JiraConnect|Link groups') }} </gl-button> <add-namespace-modal /> </div> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue index 0e209a09b16..b2700c660b1 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue @@ -8,13 +8,14 @@ export default { components: { GlModal, GroupsList }, modal: { id: ADD_NAMESPACE_MODAL_ID, - title: s__('Integrations|Link namespaces'), + title: s__('JiraConnect|Link groups'), cancelProps: { text: __('Cancel'), }, }, }; </script> + <template> <gl-modal :modal-id="$options.modal.id" diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue index a4b728335c5..3d02dcb1198 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue @@ -65,7 +65,7 @@ export default { this.groups = response.data; }) .catch(() => { - this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.'); + this.errorMessage = s__('JiraConnect|Failed to load groups. Please try again.'); }) .finally(() => { this.isLoadingMore = false; @@ -102,20 +102,25 @@ export default { </gl-alert> <gl-search-box-by-type - class="gl-mb-5" + class="gl-mb-3" debounce="500" - :placeholder="__('Search by name')" + :placeholder="__('Search groups')" :is-loading="isLoadingMore" :value="userSearchTerm" @input="onGroupSearch" /> + <p class="gl-mb-3"> + {{ + s__( + 'JiraConnect|Not seeing your groups? Only groups you have at least the Maintainer role for appear here.', + ) + }} + </p> + <gl-loading-icon v-if="isLoadingInitial" size="lg" /> <div v-else-if="groups.length === 0" class="gl-text-center"> - <h5>{{ s__('Integrations|No available namespaces.') }}</h5> - <p class="gl-mt-5"> - {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }} - </p> + <h5 class="gl-mt-5">{{ s__('JiraConnect|No groups found.') }}</h5> </div> <ul v-else diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue index ea7db5be0c4..d627e8cdd3a 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue @@ -11,14 +11,15 @@ export default { GlLink, }, i18n: { - title: s__('Integrations|Your browser is not supported'), + title: s__('JiraConnect|Your browser is not supported'), body: s__( - 'Integrations|You must use a %{linkStart}supported browser%{linkEnd} to use the GitLab for Jira app.', + 'JiraConnect|You must use a %{linkStart}supported browser%{linkEnd} to use the GitLab for Jira app.', ), }, DOCS_LINK_URL: helpPagePath('install/requirements', { anchor: 'supported-web-browsers' }), }; </script> + <template> <gl-alert variant="danger" :title="$options.i18n.title" :dismissible="false"> <gl-sprintf :message="$options.i18n.body"> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index bc8cdf35701..45a39fa5fab 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -183,6 +183,7 @@ export default { }, }; </script> + <template> <gl-button v-bind="$attrs" diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue index 4c039be9ba5..a765040a6e7 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue @@ -24,11 +24,11 @@ export default { fields: [ { key: 'name', - label: s__('Integrations|Linked namespaces'), + label: s__('JiraConnect|Linked groups'), }, { key: 'created_at', - label: __('Added'), + label: __('Created on'), tdClass: 'gl-vertical-align-middle! gl-w-20p', }, { @@ -38,7 +38,7 @@ export default { }, ], i18n: { - unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'), + unlinkError: s__('JiraConnect|Failed to unlink group. Please try again.'), }, computed: { ...mapState(['subscriptions']), diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue index cc0af0b9ab7..1e2c157b58d 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue @@ -41,6 +41,7 @@ export default { }, }; </script> + <template> <div class="gl-font-base"> <gl-sprintf :message="signedInText"> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 321d10205e6..72fd25a6230 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; export const DEFAULT_GROUPS_PER_PAGE = 10; @@ -8,30 +8,30 @@ export const MINIMUM_SEARCH_TERM_LENGTH = 3; export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal'; -export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab'); -export const I18N_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to %{url}'); -export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.'); +export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = __('Sign in to GitLab'); +export const I18N_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('JiraConnect|Sign in to %{url}'); +export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('JiraConnect|Failed to sign in to GitLab.'); export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__( - 'Integrations|Failed to load subscriptions.', + 'JiraConnect|Failed to load subscriptions.', ); export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE = s__( - 'Integrations|Namespace successfully linked', + 'JiraConnect|Group successfully linked', ); export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE = s__( - 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + 'JiraConnect|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', ); export const I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE = s__( - 'Integrations|Failed to link namespace. Please try again.', + 'JiraConnect|Failed to link group. Please try again.', ); export const I18N_UPDATE_INSTALLATION_ERROR_MESSAGE = s__( - 'Integrations|Failed to update GitLab version. Please try again.', + 'JiraConnect|Failed to update the GitLab instance. See the %{linkStart}troubleshooting documentation%{linkEnd}.', ); export const I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE = s__( - 'Integrations|Failed to load Jira Connect Application ID. Please try again.', + 'JiraConnect|Failed to load Jira Connect Application ID. Please try again.', ); -export const I18N_OAUTH_FAILED_TITLE = s__('Integrations|Failed to sign in to GitLab.'); +export const I18N_OAUTH_FAILED_TITLE = s__('JiraConnect|Failed to sign in to GitLab.'); export const I18N_OAUTH_FAILED_MESSAGE = s__( - 'Integrations|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.', + 'JiraConnect|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.', ); export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', { @@ -40,6 +40,9 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_ export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances', }); +export const FAILED_TO_UPDATE_DOC_LINK = helpPagePath('integration/jira/connect-app', { + anchor: 'failed-to-update-the-gitlab-instance-for-self-managed-instances', +}); export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue index 113ce34fdcd..78bdb5caa77 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue @@ -17,8 +17,8 @@ export default { }, }, i18n: { - signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), - signInText: s__('JiraService|Sign in to GitLab to get started.'), + signInButtonTextWithSubscriptions: s__('JiraConnect|Sign in to link groups'), + signInText: s__('JiraConnect|Sign in to GitLab to get started.'), }, GITLAB_COM_BASE_PATH, methods: { @@ -31,7 +31,7 @@ export default { <template> <div> - <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraConnect|GitLab for Jira Configuration') }}</h2> <div v-if="hasSubscriptions"> <div class="gl-display-flex gl-justify-content-end gl-mb-3"> <sign-in-oauth-button 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 8cc107930d1..e05eb900efa 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 @@ -8,6 +8,7 @@ import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/ import { GITLAB_COM_BASE_PATH, I18N_UPDATE_INSTALLATION_ERROR_MESSAGE, + FAILED_TO_UPDATE_DOC_LINK, } from '~/jira_connect/subscriptions/constants'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; @@ -56,6 +57,7 @@ export default { .catch(() => { this.setAlert({ message: I18N_UPDATE_INSTALLATION_ERROR_MESSAGE, + linkUrl: FAILED_TO_UPDATE_DOC_LINK, variant: 'danger', }); this.loadingVersionSelect = false; @@ -66,9 +68,9 @@ export default { }, }, i18n: { - title: s__('JiraService|Welcome to GitLab for Jira'), - signInSubtitle: s__('JiraService|Sign in to GitLab to link namespaces.'), - changeVersionButtonText: s__('JiraService|Change GitLab version'), + title: s__('JiraConnect|Welcome to GitLab for Jira'), + signInSubtitle: s__('JiraConnect|Sign in to GitLab to link groups.'), + changeVersionButtonText: s__('JiraConnect|Change GitLab version'), }, }; </script> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue index 8ddbbffa708..cd71ded87b5 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue @@ -7,9 +7,9 @@ export default { GlAlert, }, i18n: { - title: s__('JiraService|Are you a GitLab administrator?'), + title: s__('JiraConnect|Are you a GitLab administrator?'), body: s__( - "JiraService|Setting up this integration is only possible if you're a GitLab administrator.", + "JiraConnect|Setting up this integration is only possible if you're a GitLab administrator.", ), }, }; diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue index 621bcccd19a..d8d2db18d9f 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue @@ -13,11 +13,11 @@ export default { <template> <div class="gl-mt-5"> - <h3>{{ s__('JiraService|Continue setup in GitLab') }}</h3> + <h3>{{ s__('JiraConnect|Continue setup in GitLab') }}</h3> <p> {{ s__( - 'JiraService|In order to complete the set up, you’ll need to complete a few steps in GitLab.', + 'JiraConnect|In order to complete the set up, you’ll need to complete a few steps in GitLab.', ) }} <gl-link diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue index 3a080afd3c5..d3770cc310a 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue @@ -56,7 +56,7 @@ export default { ? this.$options.i18n.buttonNext : this.$options.i18n.buttonSave; }, - showVersonSelect() { + showVersionSelect() { return !this.showSetupInstructions && !this.showSelfManagedInstanceInput; }, }, @@ -85,21 +85,21 @@ export default { }, radioOptions: RADIO_OPTIONS, i18n: { - title: s__('JiraService|What version of GitLab are you using?'), + title: s__('JiraConnect|What version of GitLab are you using?'), saasRadioLabel: __('GitLab.com (SaaS)'), saasRadioHelp: __('Most common'), selfManagedRadioLabel: __('GitLab (self-managed)'), buttonNext: __('Next'), buttonSave: __('Save'), - instanceURLInputLabel: s__('JiraService|GitLab instance URL'), - instanceURLInputDescription: s__('JiraService|For example: https://gitlab.example.com'), + instanceURLInputLabel: s__('JiraConnect|GitLab instance URL'), + instanceURLInputDescription: s__('JiraConnect|For example: https://gitlab.example.com'), }, }; </script> <template> <gl-form class="gl-max-w-62 gl-mx-auto" @submit.prevent="onSubmit"> - <div v-if="showVersonSelect"> + <div v-if="showVersionSelect"> <h5 class="gl-mb-5">{{ $options.i18n.title }}</h5> <gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version"> <gl-form-radio :value="$options.radioOptions.saas"> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue index ee20e21011f..87d7f73be2a 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue @@ -22,6 +22,7 @@ export default { }, }; </script> + <template> <sign-in-gitlab-multiversion v-if="isOauthSelfManagedEnabled" diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue index d7213f683d8..ac30fa2faa0 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue @@ -27,7 +27,7 @@ export default { <template> <div> - <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraConnect|GitLab for Jira Configuration') }}</h2> <gl-loading-icon v-if="subscriptionsLoading" size="lg" /> <div v-else-if="hasSubscriptions && !subscriptionsError"> @@ -39,10 +39,10 @@ export default { </div> <gl-empty-state v-else - :title="s__('Integrations|No linked namespaces')" + :title="s__('JiraConnect|No linked groups')" :description=" s__( - 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', + 'JiraConnect|Groups are the GitLab groups and subgroups you link to this Jira instance.', ) " > diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue index 28a17abb20b..9a88018205b 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { Mousetrap } from '~/lib/mousetrap'; import { s__ } from '~/locale'; @@ -12,8 +12,7 @@ export default { components: { CiIcon, ClipboardButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlLink, GlSprintf, }, @@ -32,6 +31,15 @@ export default { }, }, computed: { + dropdownItems() { + return this.stages.map((stage) => ({ + text: stage.name, + action: () => { + this.onStageClick(stage); + }, + })); + }, + hasRef() { return !isEmpty(this.pipeline.ref); }, @@ -153,15 +161,6 @@ export default { </gl-sprintf> </div> - <gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3"> - <gl-dropdown-item - v-for="stage in stages" - :key="stage.name" - class="js-stage-item stage-item" - @click="onStageClick(stage)" - > - {{ stage.name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown :toggle-text="selectedStage" :items="dropdownItems" class="gl-mt-3" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue index 88a9f73258f..b692553fdc2 100644 --- a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue @@ -74,7 +74,7 @@ export default { <div class="gl-text-truncate"> <gl-link v-if="canReadJob" - class="gl-text-gray-500!" + class="gl-text-blue-600!" :href="jobPath" data-testid="job-id-link" > @@ -92,9 +92,12 @@ export default { /> <div - class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" + class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" > - <div v-if="jobRef" class="gl-max-w-15 gl-text-truncate"> + <div + v-if="jobRef" + class="gl-px-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" + > <gl-icon v-if="createdByTag" name="label" @@ -103,7 +106,7 @@ export default { /> <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> <gl-link - class="gl-font-weight-bold gl-text-gray-500!" + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" :href="job.refPath" data-testid="job-ref" >{{ job.refName }}</gl-link @@ -111,10 +114,15 @@ export default { </div> <span v-else>{{ __('none') }}</span> - - <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> - - <gl-link :href="job.commitPath" data-testid="job-sha">{{ job.shortSha }}</gl-link> + <div class="gl-ml-2 gl-rounded-base gl-px-2 gl-bg-gray-50"> + <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.commitPath" + data-testid="job-sha" + >{{ job.shortSha }}</gl-link + > + </div> </div> </div> diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue index 298cc20ab35..ab50e6cdcd3 100644 --- a/app/assets/javascripts/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/labels/components/promote_label_modal.vue @@ -3,6 +3,7 @@ import { GlSprintf, GlModal } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; @@ -52,6 +53,12 @@ export default { }, ); }, + cleanedLabelColor() { + return stripQuotes(this.labelColor); + }, + cleanedLabelTextColor() { + return stripQuotes(this.labelTextColor); + }, }, methods: { onSubmit() { @@ -97,7 +104,7 @@ export default { <template #labelTitle> <span class="label color-label" - :style="`background-color: ${labelColor}; color: ${labelTextColor};`" + :style="`background-color: ${cleanedLabelColor}; color: ${cleanedLabelTextColor};`" > {{ labelTitle }} </span> diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index f5078962b8f..42682d9b79f 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -16,6 +16,21 @@ export function initScrollingTabs() { const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); $scrollingTabs.addClass('is-initialized'); + const el = $scrollingTabs.get(0); + const parentElement = el?.parentNode; + if (el && parentElement) { + parentElement + .querySelector('button.fade-left') + ?.addEventListener('click', function scrollLeft() { + el.scrollBy({ left: -200, behavior: 'smooth' }); + }); + parentElement + .querySelector('button.fade-right') + ?.addEventListener('click', function scrollRight() { + el.scrollBy({ left: 200, behavior: 'smooth' }); + }); + } + $(window) .on('resize.nav', () => { hideEndFade($scrollingTabs); @@ -49,9 +64,31 @@ export function initScrollingTabs() { }); } -function initDeferred() { - initScrollingTabs(); +function initInviteMembers() { + const modalEl = document.querySelector('.js-invite-members-modal'); + if (!modalEl) return; + + import( + /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal' + ) + .then(({ default: initInviteMembersModal }) => { + initInviteMembersModal(); + }) + .catch(() => {}); + + const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger'); + if (!inviteTriggers) return; + import( + /* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger' + ) + .then(({ default: initInviteMembersTrigger }) => { + initInviteMembersTrigger(); + }) + .catch(() => {}); +} + +function initWhatsNewComponent() { const appEl = document.getElementById('whats-new-app'); if (!appEl) return; @@ -69,6 +106,12 @@ function initDeferred() { }); } +function initDeferred() { + initScrollingTabs(); + initWhatsNewComponent(); + initInviteMembers(); +} + export default function initLayoutNav() { if (!gon.use_new_navigation) { const contextualSidebar = new ContextualSidebar(); diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js index 8fc7c69c79d..f8ae180107c 100644 --- a/app/assets/javascripts/lib/apollo/persistence_mapper.js +++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js @@ -32,7 +32,9 @@ export const persistenceMapper = async (data) => { persistEntities.push(...entities); } else { const entity = rootQuery[key].__ref; - persistEntities.push(entity); + if (entity) { + persistEntities.push(entity); + } } } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index a4c13f9e40e..6ab530576fc 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -120,6 +120,8 @@ function createApolloClient(resolvers = {}, config = {}) { cacheConfig = { typePolicies: {}, possibleTypes: {} }, fetchPolicy = fetchPolicies.CACHE_FIRST, typeDefs, + httpHeaders = {}, + fetchCredentials = 'same-origin', path = '/api/graphql', useGet = false, } = config; @@ -138,11 +140,12 @@ function createApolloClient(resolvers = {}, config = {}) { uri, headers: { [csrf.headerKey]: csrf.token, + ...httpHeaders, }, // fetch won’t send cookies in older browsers, unless you set the credentials init option. // We set to `same-origin` which is default value in modern browsers. // See https://github.com/whatwg/fetch/pull/585 for more information. - credentials: 'same-origin', + credentials: fetchCredentials, batchMax, }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 9bf382c41e7..7795dac18bc 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -82,6 +82,7 @@ export const handleLocationHash = () => { const fixedTabs = document.querySelector('.js-tabs-affix'); const fixedDiffStats = document.querySelector('.js-diff-files-changed'); const fixedNav = document.querySelector('.navbar-gitlab'); + const fixedTopBar = document.querySelector('.top-bar-fixed'); const performanceBar = document.querySelector('#js-peek'); const topPadding = 8; const diffFileHeader = document.querySelector('.js-file-title'); @@ -93,6 +94,7 @@ export const handleLocationHash = () => { adjustment -= getElementOffsetHeight(fixedNav); adjustment -= getElementOffsetHeight(fixedTabs); adjustment -= getElementOffsetHeight(fixedDiffStats); + adjustment -= getElementOffsetHeight(fixedTopBar); adjustment -= getElementOffsetHeight(performanceBar); adjustment -= getElementOffsetHeight(diffFileHeader); adjustment -= getElementOffsetHeight(versionMenusContainer); @@ -153,6 +155,7 @@ export const contentTop = () => { const heightCalculators = [ () => getOuterHeight('#js-peek'), () => getOuterHeight('.navbar-gitlab'), + () => getOuterHeight('.top-bar-fixed'), ({ desktop }) => { const mrStickyHeader = document.querySelector('.merge-request-sticky-header'); if (mrStickyHeader) { @@ -689,21 +692,6 @@ export const getCookie = (name) => Cookies.get(name); export const removeCookie = (name) => Cookies.remove(name); /** - * Returns the status of a feature flag. - * Currently, there is no way to access feature - * flags in Vuex other than directly tapping into - * window.gon. - * - * This should only be used on Vuex. If feature flags - * need to be accessed in Vue components consider - * using the Vue feature flag mixin. - * - * @param {String} flag Feature flag - * @returns {Boolean} on/off - */ -export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; - -/** * This method takes in array with snake_case strings * and returns a new array with camelCase strings * diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index fb69a61880a..d1e5e4eea13 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -27,7 +27,7 @@ export const DRAWER_Z_INDEX = 252; export const MIN_USERNAME_LENGTH = 2; -export const BYTES_FORMAT_BYTES = 'Bytes'; +export const BYTES_FORMAT_BYTES = 'B'; export const BYTES_FORMAT_KIB = 'KiB'; export const BYTES_FORMAT_MIB = 'MiB'; export const BYTES_FORMAT_GIB = 'GiB'; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 9eb812b8694..d52672b9d08 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -730,3 +730,13 @@ export const getTimeRemainingInWords = (date) => { const years = dateInFuture.getFullYear() - today.getFullYear(); return n__('1 year remaining', '%d years remaining', years); }; + +/** + * Returns the current date according to UTC time at midnight + * @return {Date} The current date in UTC + */ +export const getCurrentUtcDate = () => { + const now = new Date(); + + return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); +}; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 198f2da385c..5f54243d4e5 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -114,7 +114,7 @@ export const setAttributes = (el, attributes) => { * @param {String} contentWrapperClass the content wrapper class * @returns {String} height in px */ -export const getContentWrapperHeight = (contentWrapperClass) => { +export const getContentWrapperHeight = (contentWrapperClass = '.content-wrapper') => { const wrapperEl = document.querySelector(contentWrapperClass); return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; }; diff --git a/app/assets/javascripts/lib/utils/listbox_helpers.js b/app/assets/javascripts/lib/utils/listbox_helpers.js new file mode 100644 index 00000000000..b43a29ad28b --- /dev/null +++ b/app/assets/javascripts/lib/utils/listbox_helpers.js @@ -0,0 +1,45 @@ +import { n__ } from '~/locale'; + +/** + * Accepts an array of options and an array of selected option IDs + * and optionally a placeholder and maximum number of options to show. + * + * Returns a string with the text of the selected options: + * - If no options are selected, returns the placeholder or an empty string. + * - If less than maxOptionsShown is selected, returns the text of those options comma-separated. + * - If more than maxOptionsShown is selected, returns the text of those options comma-separated + * followed by the text "+X more", where X is the number of additional selected options + * + * @param {Object} opts + * @param {Array<{ id: number | string, value: string }>} opts.options + * @param {Array<{ id: number | string }>} opts.selected + * @param {String} opts.placeholder - Placeholder when no option is selected + * @param {Integer} opts.maxOptionsShown – Max number of options to show + * @returns {String} + */ +export const getSelectedOptionsText = ({ + options, + selected, + placeholder = '', + maxOptionsShown = 1, +}) => { + const selectedOptions = options.filter(({ id, value }) => selected.includes(id || value)); + + if (selectedOptions.length === 0) { + return placeholder; + } + + const optionTexts = selectedOptions.map((option) => option.text); + + if (selectedOptions.length <= maxOptionsShown) { + return optionTexts.join(', '); + } + + // Prevent showing "+-1 more" when the array is empty. + const additionalItemsCount = selectedOptions.length - maxOptionsShown; + return `${optionTexts.slice(0, maxOptionsShown).join(', ')} ${n__( + '+%d more', + '+%d more', + additionalItemsCount, + )}`; +}; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index d64f84d2040..0e943cdb623 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -106,7 +106,7 @@ export function numberToHumanSize(size, digits = 2) { switch (format) { case BYTES_FORMAT_BYTES: - return sprintf(__('%{size} bytes'), { size: humanSize }); + return sprintf(__('%{size} B'), { size: humanSize }); case BYTES_FORMAT_KIB: return sprintf(__('%{size} KiB'), { size: humanSize }); case BYTES_FORMAT_MIB: diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js index 2807911c9bb..8e673855631 100644 --- a/app/assets/javascripts/lib/utils/secret_detection.js +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -11,19 +11,21 @@ export const i18n = { 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) => { + const patPrefix = window.gon?.pat_prefix || 'glpat-'; + + const sensitiveDataPatterns = [ + { + name: 'GitLab Personal Access Token', + regex: `${patPrefix}[0-9a-zA-Z_-]{20}`, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Feed Token', + regex: 'feed_token=((glft-)?[0-9a-zA-Z_-]{20}|glft-[a-h0-9]+-[0-9]+_)', + }, + ]; + for (const rule of sensitiveDataPatterns) { const regex = new RegExp(rule.regex, 'gi'); if (regex.test(message)) { diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 963041dd5d0..42f481261a2 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -568,3 +568,12 @@ export const humanizeBranchValidationErrors = (invalidChars = []) => { } return ''; }; + +/** + * Strips enclosing quotations from a string if it has one. + * + * @param {String} value String to strip quotes from + * + * @returns {String} String without any enclosure + */ +export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index f16ff188edb..85740117c00 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/browser'; + export const DASH_SCOPE = '-'; export const PATH_SEPARATOR = '/'; @@ -8,12 +10,18 @@ const SHA_REGEX = /[\da-f]{40}/gi; // GitLab default domain (override in jh) export const DOMAIN = 'gitlab.com'; -// About GitLab default host (overwrite in jh) +// Following URLs will be overwritten in jh +export const FORUM_URL = `https://forum.${DOMAIN}/`; // forum.gitlab.com +export const DOCS_URL = `https://docs.${DOMAIN}`; // docs.gitlab.com + +// About GitLab default host export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com -// About Gitlab default url (overwrite in jh) +// About Gitlab default url export const PROMO_URL = `https://${PROMO_HOST}`; +export const DOCS_URL_IN_EE_DIR = `${DOCS_URL}/ee`; + // Reset the cursor in a Regex so that multiple uses before a recompile don't fail function resetRegExp(regex) { regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */ @@ -272,36 +280,6 @@ export const setUrlFragment = (url, fragment) => { return `${rootUrl}#${encodedFragment}`; }; -/** - * Navigates to a URL - * @param {*} url - url to navigate to - * @param {*} external - if true, open a new page or tab - */ -export function visitUrl(url, external = false) { - if (external) { - // Simulate `target="_blank" rel="noopener noreferrer"` - // See https://mathiasbynens.github.io/rel-noopener/ - const otherWindow = window.open(); - otherWindow.opener = null; - otherWindow.location = url; - } else { - window.location.href = url; - } -} - -export function refreshCurrentPage() { - visitUrl(window.location.href); -} - -/** - * Navigates to a URL - * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead - * @param {*} url - */ -export function redirectTo(url) { - return window.location.assign(url); -} - export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) { if (win.history) { if (replace) { @@ -697,3 +675,41 @@ export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, ''); */ export const removeLastSlashInUrlPath = (url) => url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2'); + +/** + * Navigates to a URL + * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead + * @param {*} url + */ +export function redirectTo(url) { + return window.location.assign(url); +} + +/** + * Navigates to a URL + * @param {*} url - url to navigate to + * @param {*} external - if true, open a new page or tab + */ +export function visitUrl(url, external = false) { + if (!isSafeURL(url)) { + // For now log this to Sentry and do not block the execution. + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121551#note_1408873600 + // for more context. Once we're sure that it's not breaking functionality, we can use + // a RangeError here (throw new RangeError('Only http and https protocols are allowed')). + Sentry.captureException(new RangeError(`Only http and https protocols are allowed: ${url}`)); + } + + if (external) { + // Simulate `target="_blank" rel="noopener noreferrer"` + // See https://mathiasbynens.github.io/rel-noopener/ + const otherWindow = window.open(); + otherWindow.opener = null; + otherWindow.location.assign(url); + } else { + window.location.assign(url); + } +} + +export function refreshCurrentPage() { + visitUrl(window.location.href); +} diff --git a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue index 15606ad567c..23d6edae415 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui'; import { LEAVE_MODAL_ID } from '../../constants'; import LeaveModal from '../modals/leave_modal.vue'; @@ -7,7 +7,7 @@ export default { name: 'LeaveGroupDropdownItem', modalId: LEAVE_MODAL_ID, components: { - GlDropdownItem, + GlDisclosureDropdownItem, LeaveModal, }, directives: { @@ -27,10 +27,12 @@ export default { </script> <template> - <gl-dropdown-item v-gl-modal="$options.modalId"> - <span class="gl-text-red-500"> - <slot></slot> - </span> - <leave-modal :member="member" :permissions="permissions" /> - </gl-dropdown-item> + <gl-disclosure-dropdown-item v-gl-modal="$options.modalId"> + <template #list-item> + <span class="gl-text-red-500"> + <slot></slot> + </span> + <leave-modal :member="member" :permissions="permissions" /> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue index f224aaa31f7..627b47a1e81 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue @@ -1,10 +1,10 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; export default { name: 'RemoveMemberDropdownItem', - components: { GlDropdownItem }, + components: { GlDisclosureDropdownItem }, inject: ['namespace'], props: { memberId: { @@ -75,12 +75,14 @@ export default { </script> <template> - <gl-dropdown-item + <gl-disclosure-dropdown-item data-qa-selector="delete_member_dropdown_item" - @click="showRemoveMemberModal(modalData)" + @action="showRemoveMemberModal(modalData)" > - <span class="gl-text-red-500"> - <slot></slot> - </span> - </gl-dropdown-item> + <template #list-item> + <span class="gl-text-red-500"> + <slot></slot> + </span> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue index c82ebadea6e..25dc4831b11 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { @@ -14,7 +14,7 @@ export default { name: 'UserActionDropdown', i18n: I18N, components: { - GlDropdown, + GlDisclosureDropdown, DisableTwoFactorDropdownItem: () => import( 'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue' @@ -99,15 +99,15 @@ export default { </script> <template> - <gl-dropdown + <gl-disclosure-dropdown v-if="showDropdown" v-gl-tooltip="$options.i18n.actions" - :text="$options.i18n.actions" + :toggle-text="$options.i18n.actions" text-sr-only icon="ellipsis_v" category="tertiary" no-caret - right + placement="right" data-testid="user-action-dropdown" data-qa-selector="user_action_dropdown" > @@ -131,15 +131,16 @@ export default { :user-deletion-obstacles="userDeletionObstaclesUserData" :modal-message="modalRemoveUser" :prevent-removal="permissions.canRemoveBlockedByLastOwner" - >{{ $options.i18n.removeMember }}</remove-member-dropdown-item > + {{ $options.i18n.removeMember }} + </remove-member-dropdown-item> </template> - <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{ - $options.i18n.editPermissions - }}</ldap-override-dropdown-item> - <ban-member-dropdown-item v-if="showBan" :member="member">{{ - $options.i18n.banMember - }}</ban-member-dropdown-item> - </gl-dropdown> + <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member"> + {{ $options.i18n.editPermissions }} + </ldap-override-dropdown-item> + <ban-member-dropdown-item v-if="showBan" :member="member"> + {{ $options.i18n.banMember }} + </ban-member-dropdown-item> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index a85bb09e17b..4571c4172e5 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { mapActions } from 'vuex'; import * as Sentry from '@sentry/browser'; @@ -9,10 +9,9 @@ import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_conf export default { name: 'RoleDropdown', components: { - GlDropdown, - GlDropdownItem, - LdapDropdownItem: () => - import('ee_component/members/components/action_dropdowns/ldap_dropdown_item.vue'), + GlCollapsibleListbox, + LdapDropdownFooter: () => + import('ee_component/members/components/action_dropdowns/ldap_dropdown_footer.vue'), }, inject: ['namespace', 'group'], props: { @@ -29,23 +28,22 @@ export default { return { isDesktop: false, busy: false, + selectedRoleValue: this.member.accessLevel.integerValue, }; }, computed: { disabled() { return this.permissions.canOverride && !this.member.isOverridden; }, + dropdownItems() { + return Object.entries(this.member.validRoles).map(([name, value]) => ({ + value, + text: name, + })); + }, }, mounted() { this.isDesktop = bp.isDesktop(); - - // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle - // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented - const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle'); - - if (dropdownToggle) { - dropdownToggle.dataset.qaSelector = 'access_level_dropdown'; - } }, methods: { ...mapActions({ @@ -63,7 +61,7 @@ export default { memberType: this.namespace, }); }, - async handleSelect(newRoleValue, newRoleName) { + async handleSelect(newRoleValue) { const currentRoleValue = this.member.accessLevel.integerValue; if (newRoleValue === currentRoleValue) { return; @@ -71,6 +69,7 @@ export default { this.busy = true; + const { text: newRoleName } = this.dropdownItems.find((item) => item.value === newRoleValue); const confirmed = await this.handleOverageConfirm( currentRoleValue, newRoleValue, @@ -99,27 +98,25 @@ export default { </script> <template> - <gl-dropdown - ref="glDropdown" - :right="!isDesktop" - :text="member.accessLevel.stringValue" + <gl-collapsible-listbox + v-model="selectedRoleValue" + :placement="isDesktop ? 'left' : 'right'" + :toggle-text="member.accessLevel.stringValue" :header-text="__('Change role')" :disabled="disabled" :loading="busy" + data-qa-selector="access_level_dropdown" + :items="dropdownItems" + @select="handleSelect" > - <gl-dropdown-item - v-for="(value, name) in member.validRoles" - :key="value" - is-check-item - :is-checked="value === member.accessLevel.integerValue" - data-qa-selector="access_level_link" - @click="handleSelect(value, name)" - > - {{ name }} - </gl-dropdown-item> - <ldap-dropdown-item - v-if="permissions.canOverride && member.isOverridden" - :member-id="member.id" - /> - </gl-dropdown> + <template #list-item="{ item }"> + <span data-qa-selector="access_level_link">{{ item.text }}</span> + </template> + <template #footer> + <ldap-dropdown-footer + v-if="permissions.canOverride && member.isOverridden" + :member-id="member.id" + /> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 4277e535d20..c837583dd45 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -5,7 +5,6 @@ import { createAlert } from '~/alert'; import { TYPE_MERGE_REQUEST } from '~/issues/constants'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; -import eventHub from '~/vue_merge_request_widget/event_hub'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; @@ -145,17 +144,7 @@ MergeRequest.decreaseCounter = function (by = 1) { $el.text(addDelimiter(count)); }; -MergeRequest.hideCloseButton = function () { - const el = document.querySelector('.merge-request .js-issuable-actions'); - // Dropdown for mobile screen - el.querySelector('li.js-close-item').classList.add('hidden'); -}; - MergeRequest.toggleDraftStatus = function (title, isReady) { - if (!window.gon?.features?.realtimeMrStatusChange) { - eventHub.$emit('MRWidgetUpdateRequested'); - } - if (isReady) { toast(__('Marked as ready. Merging is now allowed.')); } else { diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index cef224d83e2..8307d0a9eed 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -97,6 +97,7 @@ function mountPipelines() { targetProjectFullPath: mrWidgetData?.target_project_full_path || '', fullPath: pipelineTableViewEl.dataset.fullPath, manualActionsLimit: 50, + withFailedJobsDetails: true, }, render(createElement) { return createElement('commit-pipelines-table', { diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index e63b9613257..c6e8a9ea582 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -25,7 +25,7 @@ export default { }; }, skip() { - return !this.issuableId || !this.glFeatures.realtimeMrStatusChange; + return !this.issuableId; }, result({ data: { mergeRequestMergeStatusUpdated } }) { if (mergeRequestMergeStatusUpdated) { @@ -115,10 +115,11 @@ export default { > <div class="gl-w-full gl-display-flex gl-align-items-center"> <status-box :initial-state="getNoteableData.state" issuable-type="merge_request" /> - <p + <a v-safe-html:[$options.safeHtmlConfig]="titleHtml" - class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4" - ></p> + href="#top" + class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4 gl-text-black-normal" + ></a> <div class="gl-display-flex gl-align-items-center"> <gl-sprintf :message="__('%{source} %{copyButton} into %{target}')"> <template #copyButton> diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js index 8780d931588..420f7cee4d2 100644 --- a/app/assets/javascripts/milestones/index.js +++ b/app/assets/javascripts/milestones/index.js @@ -9,12 +9,18 @@ import Sidebar from '~/right_sidebar'; import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; import Translate from '~/vue_shared/translate'; import ZenMode from '~/zen_mode'; +import TaskList from '~/task_list'; +import { TYPE_MILESTONE } from '~/issues/constants'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; import eventHub from './event_hub'; // See app/views/shared/milestones/_description.html.haml export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description'; +export const MILESTONE_DESCRIPTION_TASK_LIST_CONTAINER_ELEMENT = `${MILESTONE_DESCRIPTION_ELEMENT}.js-task-list-container`; +export const MILESTONE_DETAIL_ELEMENT = '.milestone-detail'; export function initForm(initGFM = true) { new ZenMode(); // eslint-disable-line no-new @@ -40,6 +46,26 @@ export function initShow() { new MountMilestoneSidebar(); // eslint-disable-line no-new renderGFM(document.querySelector(MILESTONE_DESCRIPTION_ELEMENT)); + + const el = document.querySelector(MILESTONE_DESCRIPTION_TASK_LIST_CONTAINER_ELEMENT); + + if (!el) { + return null; + } + + return new TaskList({ + dataType: TYPE_MILESTONE, + fieldName: 'description', + selector: MILESTONE_DETAIL_ELEMENT, + lockVersion: el.dataset.lockVersion, + onError: () => { + createAlert({ + message: __( + 'Someone edited this milestone at the same time you did. Please refresh the page to see changes.', + ), + }); + }, + }); } export function initPromoteMilestoneModal() { diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue index 20c5248052b..747e92b9e85 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue @@ -1,25 +1,11 @@ <script> -import { GlLink } from '@gitlab/ui'; - export default { name: 'CandidateDetailRow', - components: { - GlLink, - }, props: { label: { type: String, required: true, }, - text: { - type: [String, Number], - required: true, - }, - href: { - type: String, - required: false, - default: '', - }, sectionLabel: { type: String, required: false, @@ -34,8 +20,7 @@ export default { <td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td> <td class="gl-font-weight-bold">{{ label }}</td> <td> - <gl-link v-if="href" :href="href">{{ text }}</gl-link> - <template v-else>{{ text }}</template> + <slot></slot> </td> </tr> </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 3ef73e7c874..a68fb7d340a 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 @@ -1,4 +1,5 @@ <script> +import { GlAvatarLabeled, GlLink } from '@gitlab/ui'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import DetailRow from './components/candidate_detail_row.vue'; @@ -17,6 +18,10 @@ import { DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, DELETE_CANDIDATE_MODAL_TITLE, MLFLOW_ID_LABEL, + CI_SECTION_LABEL, + JOB_LABEL, + CI_USER_LABEL, + CI_MR_LABEL, } from './translations'; export default { @@ -25,6 +30,8 @@ export default { ModelExperimentsHeader, DeleteButton, DetailRow, + GlAvatarLabeled, + GlLink, }, props: { candidate: { @@ -43,11 +50,18 @@ export default { DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, DELETE_CANDIDATE_MODAL_TITLE, MLFLOW_ID_LABEL, + CI_SECTION_LABEL, + JOB_LABEL, + CI_USER_LABEL, + CI_MR_LABEL, }, computed: { info() { return Object.freeze(this.candidate.info); }, + ciJob() { + return Object.freeze(this.info.ci_job); + }, sections() { return [ { @@ -83,28 +97,52 @@ export default { <tbody> <tr class="divider"></tr> - <detail-row - :label="$options.i18n.ID_LABEL" - :section-label="$options.i18n.INFO_LABEL" - :text="info.iid" - /> + <detail-row :label="$options.i18n.ID_LABEL" :section-label="$options.i18n.INFO_LABEL"> + {{ info.iid }} + </detail-row> + + <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> - <detail-row :label="$options.i18n.MLFLOW_ID_LABEL" :text="info.eid" /> + <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> - <detail-row :label="$options.i18n.STATUS_LABEL" :text="info.status" /> + <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> + <gl-link :href="info.path_to_experiment"> + {{ info.experiment_name }} + </gl-link> + </detail-row> - <detail-row - :label="$options.i18n.EXPERIMENT_LABEL" - :text="info.experiment_name" - :href="info.path_to_experiment" - /> + <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL"> + <gl-link :href="info.path_to_artifact"> + {{ $options.i18n.ARTIFACTS_LABEL }} + </gl-link> + </detail-row> - <detail-row - v-if="info.path_to_artifact" - :label="$options.i18n.ARTIFACTS_LABEL" - :href="info.path_to_artifact" - :text="$options.i18n.ARTIFACTS_LABEL" - /> + <template v-if="ciJob"> + <tr class="divider"></tr> + + <detail-row + :label="$options.i18n.JOB_LABEL" + :section-label="$options.i18n.CI_SECTION_LABEL" + > + <gl-link :href="ciJob.path"> + {{ ciJob.name }} + </gl-link> + </detail-row> + + <detail-row v-if="ciJob.user" :label="$options.i18n.CI_USER_LABEL"> + <gl-avatar-labeled label="" :size="24" :src="ciJob.user.avatar"> + <gl-link :href="ciJob.user.path"> + {{ ciJob.user.name }} + </gl-link> + </gl-avatar-labeled> + </detail-row> + + <detail-row v-if="ciJob.merge_request" :label="$options.i18n.CI_MR_LABEL"> + <gl-link :href="ciJob.merge_request.path"> + !{{ ciJob.merge_request.iid }} {{ ciJob.merge_request.title }} + </gl-link> + </detail-row> + </template> <template v-for="{ sectionName, sectionValues } in sections"> <tr v-if="sectionValues" :key="sectionName" class="divider"></tr> @@ -114,8 +152,9 @@ export default { :key="item.name" :label="item.name" :section-label="index === 0 ? sectionName : ''" - :text="item.value" - /> + > + {{ item.value }} + </detail-row> </template> </tbody> </table> 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 66ee84adb4e..fa9518f3e27 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 @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const TITLE_LABEL = s__('MlExperimentTracking|Model candidate details'); export const INFO_LABEL = s__('MlExperimentTracking|Info'); @@ -15,3 +15,7 @@ export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( ); export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); +export const CI_SECTION_LABEL = __('CI'); +export const JOB_LABEL = __('Job'); +export const CI_USER_LABEL = s__('MlExperimentTracking|Triggered by'); +export const CI_MR_LABEL = __('Merge request'); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue index 66f94c6bee5..b543169d501 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue @@ -51,27 +51,29 @@ export default { </script> <template> - <div v-if="hasExperiments"> + <div> <model-experiments-header :page-title="$options.i18n.TITLE_LABEL" /> - <gl-table-lite :items="tableItems" :fields="$options.tableFields"> - <template #cell(nameColumn)="data"> - <gl-link :href="data.value.path"> - {{ data.value.name }} - </gl-link> - </template> - </gl-table-lite> + <template v-if="hasExperiments"> + <gl-table-lite :items="tableItems" :fields="$options.tableFields"> + <template #cell(nameColumn)="data"> + <gl-link :href="data.value.path"> + {{ data.value.name }} + </gl-link> + </template> + </gl-table-lite> - <pagination v-if="hasExperiments" v-bind="pageInfo" /> - </div> + <pagination v-if="hasExperiments" v-bind="pageInfo" /> + </template> - <gl-empty-state - v-else - :title="$options.i18n.EMPTY_STATE_TITLE_LABEL" - :primary-button-text="$options.i18n.CREATE_NEW_LABEL" - :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" - class="gl-py-8" - /> + <gl-empty-state + v-else + :title="$options.i18n.EMPTY_STATE_TITLE_LABEL" + :primary-button-text="$options.i18n.CREATE_NEW_LABEL" + :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" + class="gl-py-8" + /> + </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js index e954c054cf5..f556197633b 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js @@ -4,8 +4,10 @@ export const TITLE_LABEL = s__('MlExperimentTracking|Model experiments'); export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create a new experiment'); -export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No experiments'); +export const EMPTY_STATE_TITLE_LABEL = s__( + 'MlExperimentTracking|Get started with model experiments!', +); export const EMPTY_STATE_DESCRIPTION_LABEL = s__( - 'MlExperimentTracking|There are no logged experiments for this project. Create a new experiment using the MLflow client.', + 'MlExperimentTracking|Experiments keep track of comparable model candidates, and determine which parameters provides the best performance. Create experiments using the MLflow client', ); diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index da4c92df711..6419c45c20c 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -1,5 +1,5 @@ <script> -import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg'; +import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg?raw'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { chartHeight } from '../../constants'; diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 752ba4241d8..cfc20b7b95f 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -11,16 +11,8 @@ import { import VueDraggable from 'vuedraggable'; import { mapActions, mapState, mapGetters } from 'vuex'; import { createAlert } from '~/alert'; -import { - keysFor, - METRICS_COPY_LINK_TO_CHART, - METRICS_DOWNLOAD_CSV, - METRICS_EXPAND_PANEL, - METRICS_SHOW_ALERTS, -} from '~/behaviors/shortcuts/keybindings'; import invalidUrl from '~/lib/utils/invalid_url'; import { ESC_KEY } from '~/lib/utils/keys'; -import { Mousetrap } from '~/lib/mousetrap'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { defaultTimeRange } from '~/vue_shared/constants'; @@ -218,32 +210,6 @@ export default { } }, }, - created() { - window.addEventListener('keyup', this.onKeyup); - - Mousetrap.bind(keysFor(METRICS_EXPAND_PANEL), () => - this.runShortcut('onExpandFromKeyboardShortcut'), - ); - Mousetrap.bind(keysFor(METRICS_SHOW_ALERTS), () => - this.runShortcut('showAlertModalFromKeyboardShortcut'), - ); - Mousetrap.bind(keysFor(METRICS_DOWNLOAD_CSV), () => - this.runShortcut('downloadCsvFromKeyboardShortcut'), - ); - Mousetrap.bind(keysFor(METRICS_COPY_LINK_TO_CHART), () => - this.runShortcut('copyChartLinkFromKeyboardShotcut'), - ); - }, - destroyed() { - window.removeEventListener('keyup', this.onKeyup); - - [ - METRICS_COPY_LINK_TO_CHART, - METRICS_DOWNLOAD_CSV, - METRICS_EXPAND_PANEL, - METRICS_SHOW_ALERTS, - ].forEach((command) => Mousetrap.unbind(keysFor(command))); - }, mounted() { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 44dde454983..f4dc29f2184 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -105,7 +105,7 @@ export default { return ( this.customMetricsAvailable && !this.shouldShowEmptyState && - // Custom metrics only avaialble on system dashboards because + // Custom metrics only available on system dashboards because // they are stored in the database. This can be improved. See: // https://gitlab.com/gitlab-org/gitlab/-/issues/28241 this.selectedDashboard?.out_of_the_box_dashboard diff --git a/app/assets/javascripts/mr_more_dropdown.js b/app/assets/javascripts/mr_more_dropdown.js new file mode 100644 index 00000000000..720619b72ae --- /dev/null +++ b/app/assets/javascripts/mr_more_dropdown.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import MrMoreDropdown from '~/vue_shared/components/mr_more_dropdown.vue'; + +export const initMrMoreDropdown = () => { + const el = document.querySelector('.js-mr-more-dropdown'); + + if (!el) { + return false; + } + + const { + mergeRequest, + projectPath, + editUrl, + isCurrentUser, + isLoggedIn, + canUpdateMergeRequest, + open, + merged, + sourceProjectMissing, + clipboardText, + reportedUserId, + reportedFromUrl, + } = el.dataset; + + let mr; + + try { + mr = JSON.parse(mergeRequest); + } catch { + mr = {}; + } + + return new Vue({ + el, + provide: { + reportAbusePath: el.dataset.reportAbusePath, + }, + render: (createElement) => + createElement(MrMoreDropdown, { + props: { + mr, + projectPath, + editUrl, + isCurrentUser, + isLoggedIn: Boolean(isLoggedIn), + canUpdateMergeRequest, + open, + isMerged: merged, + sourceProjectMissing, + clipboardText, + reportedUserId: Number(reportedUserId), + reportedFromUrl, + }, + }), + }); +}; diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js index 9852efea95f..e8e3376cee2 100644 --- a/app/assets/javascripts/mr_notes/init.js +++ b/app/assets/javascripts/mr_notes/init.js @@ -1,12 +1,14 @@ import { parseBoolean } from '~/lib/utils/common_utils'; -import store from '~/mr_notes/stores'; +import mrNotes from '~/mr_notes/stores'; import { getLocationHash } from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; import { initReviewBar } from '~/batch_comments'; import { initDiscussionCounter } from '~/mr_notes/discussion_counter'; import { initOverviewTabCounter } from '~/mr_notes/init_count'; +import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; +import { getReviewsForMergeRequest } from '~/diffs/utils/file_reviews'; -function setupMrNotesState(notesDataset) { +function setupMrNotesState(store, notesDataset, diffsDataset) { const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; noteableData.targetType = notesDataset.targetType; @@ -15,26 +17,43 @@ function setupMrNotesState(notesDataset) { const currentUserData = JSON.parse(notesDataset.currentUserData); const endpoints = { metadata: notesDataset.endpointMetadata }; + const { mrPath } = getDerivedMergeRequestInformation({ endpoint: diffsDataset.endpoint }); + store.dispatch('setNotesData', notesData); store.dispatch('setNoteableData', noteableData); store.dispatch('setUserData', currentUserData); store.dispatch('setTargetNoteHash', getLocationHash()); store.dispatch('setEndpoints', endpoints); + store.dispatch('diffs/setBaseConfig', { + endpoint: diffsDataset.endpoint, + endpointMetadata: diffsDataset.endpointMetadata, + endpointBatch: diffsDataset.endpointBatch, + endpointDiffForPath: diffsDataset.endpointDiffForPath, + endpointCoverage: diffsDataset.endpointCoverage, + endpointUpdateUser: diffsDataset.updateCurrentUserPath, + projectPath: diffsDataset.projectPath, + dismissEndpoint: diffsDataset.dismissEndpoint, + showSuggestPopover: parseBoolean(diffsDataset.showSuggestPopover), + viewDiffsFileByFile: parseBoolean(diffsDataset.fileByFileDefault), + defaultSuggestionCommitMessage: diffsDataset.defaultSuggestionCommitMessage, + mrReviews: getReviewsForMergeRequest(mrPath), + }); } -export function initMrStateLazyLoad({ reviewBarParams } = {}) { +export function initMrStateLazyLoad(store = mrNotes, { reviewBarParams } = {}) { store.dispatch('setActiveTab', window.mrTabs.getCurrentAction()); window.mrTabs.eventHub.$on('MergeRequestTabChange', (value) => store.dispatch('setActiveTab', value), ); const discussionsEl = document.getElementById('js-vue-mr-discussions'); - const notesDataset = discussionsEl.dataset; + const diffsEl = document.getElementById('js-diffs-app'); + let stop = () => {}; stop = store.watch( (state) => state.page.activeTab, (activeTab) => { - setupMrNotesState(notesDataset); + setupMrNotesState(store, discussionsEl.dataset, diffsEl.dataset); // prevent loading MR state on commits and pipelines pages // this is due to them having a shared controller with the Overview page diff --git a/app/assets/javascripts/mr_notes/init_mr_notes.js b/app/assets/javascripts/mr_notes/init_mr_notes.js index e0a8d1f7e7d..3fcf0958868 100644 --- a/app/assets/javascripts/mr_notes/init_mr_notes.js +++ b/app/assets/javascripts/mr_notes/init_mr_notes.js @@ -13,7 +13,7 @@ export default function initMrNotes(lazyLoadParams) { action: mrShowNode.dataset.mrAction, }); - initMrStateLazyLoad(lazyLoadParams); + initMrStateLazyLoad(undefined, lazyLoadParams); document.addEventListener('merged:UpdateActions', () => { initRevertCommitModal('i_code_review_post_merge_submit_revert_modal'); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 6794f838c84..cba0f960c00 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -6,7 +6,6 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests 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, @@ -21,6 +20,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import * as constants from '../constants'; import eventHub from '../event_hub'; import { COMMENT_FORM } from '../i18n'; +import { getErrorMessages } from '../utils'; import issuableStateMixin from '../mixins/issuable_state'; import CommentFieldLayout from './comment_field_layout.vue'; @@ -219,11 +219,7 @@ export default { 'toggleIssueLocalState', ]), handleSaveError({ data, status }) { - if (status === HTTP_STATUS_UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) { - this.errors = data.errors.commands_only; - } else { - this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK]; - } + this.errors = getErrorMessages(data, status); }, handleSaveDraft() { this.handleSave({ isDraft: true }); diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index f949142d90a..c53d3203327 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -5,6 +5,7 @@ import { mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; +import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import NoteEditedText from './note_edited_text.vue'; import NoteHeader from './note_header.vue'; @@ -62,6 +63,7 @@ export default { for_commit: isForCommit, diff_discussion: isDiffDiscussion, active: isActive, + position, } = this.discussion; let text = s__('MergeRequests|started a thread'); @@ -75,6 +77,10 @@ export default { : s__( 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}', ); + } else if (isDiffDiscussion && position?.position_type === FILE_DIFF_POSITION_TYPE) { + text = isActive + ? s__('MergeRequests|started a thread on %{linkStart}a file%{linkEnd}') + : s__('MergeRequests|started a thread on %{linkStart}an old version of a file%{linkEnd}'); } else if (isDiffDiscussion) { text = isActive ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index aabdc1c99b6..db32079e6b9 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -8,6 +8,7 @@ import { getDiffMode } from '~/diffs/store/utils'; import { diffViewerModes } from '~/ide/constants'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { isCollapsed } from '~/diffs/utils/diff_file'; +import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants'; const FIRST_CHAR_REGEX = /^(\+|-| )/; @@ -42,6 +43,15 @@ export default { diffViewerMode() { return this.discussion.diff_file.viewer.name; }, + fileDiffRefs() { + return this.discussion.diff_file.diff_refs; + }, + headSha() { + return (this.fileDiffRefs ? this.fileDiffRefs.head_sha : this.discussion.commit_id) || ''; + }, + baseSha() { + return (this.fileDiffRefs ? this.fileDiffRefs.base_sha : this.discussion.commit_id) || ''; + }, isTextFile() { return this.diffViewerMode === diffViewerModes.text; }, @@ -53,6 +63,12 @@ export default { isCollapsed() { return isCollapsed(this.discussion.diff_file); }, + positionType() { + return this.discussion.position?.position_type; + }, + isFileDiscussion() { + return this.positionType === FILE_DIFF_POSITION_TYPE; + }, }, mounted() { if (this.isTextFile && !this.hasTruncatedDiffLines) { @@ -87,50 +103,59 @@ export default { /> <div v-if="isTextFile" class="diff-content"> <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass"> - <template v-if="hasTruncatedDiffLines"> - <tr - v-for="line in discussion.truncated_diff_lines" - v-once - :key="line.line_code" - class="line_holder" - > - <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td> - <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td> - <td v-safe-html="trimChar(line.rich_text)" :class="line.type" class="line_content"></td> + <template v-if="!isFileDiscussion"> + <template v-if="hasTruncatedDiffLines"> + <tr + v-for="line in discussion.truncated_diff_lines" + v-once + :key="line.line_code" + class="line_holder" + > + <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td> + <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td> + <td + v-safe-html="trimChar(line.rich_text)" + :class="line.type" + class="line_content" + ></td> + </tr> + </template> + <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> + <td class="old_line diff-line-num"></td> + <td class="new_line diff-line-num"></td> + <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> + {{ __('Unable to load the diff') }} + <button + class="gl-button btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button gl-reset-font-size!" + @click="fetchDiff" + > + {{ __('Try again') }} + </button> + </td> + <td v-else class="line_content js-success-lazy-load"> + <span></span> + <gl-skeleton-loader /> + <span></span> + </td> </tr> </template> - <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> - <td class="old_line diff-line-num"></td> - <td class="new_line diff-line-num"></td> - <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> - {{ __('Unable to load the diff') }} - <button - class="gl-button btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button gl-reset-font-size!" - @click="fetchDiff" - > - {{ __('Try again') }} - </button> - </td> - <td v-else class="line_content js-success-lazy-load"> - <span></span> - <gl-skeleton-loader /> - <span></span> - </td> - </tr> <tr class="notes_holder"> - <td class="notes-content" colspan="3"><slot></slot></td> + <td :class="{ 'gl-border-top-0!': isFileDiscussion }" class="notes-content" colspan="3"> + <slot></slot> + </td> </tr> </table> </div> - <div v-else> + <div v-else class="diff-content"> <diff-viewer + v-if="!isFileDiscussion" :diff-file="discussion.diff_file" :diff-mode="diffMode" :diff-viewer-mode="diffViewerMode" :new-path="discussion.diff_file.new_path" - :new-sha="discussion.diff_file.diff_refs.head_sha" + :new-sha="headSha" :old-path="discussion.diff_file.old_path" - :old-sha="discussion.diff_file.diff_refs.base_sha" + :old-sha="baseSha" :file-hash="discussion.diff_file.file_hash" :project-path="projectPath" > diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 3e8cddc3174..9fb027fb955 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -4,6 +4,7 @@ import { __ } from '~/locale'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue'; +import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { SYSTEM_NOTE } from '../constants'; import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue'; import NoteEditedText from './note_edited_text.vue'; @@ -82,6 +83,12 @@ export default { url: this.discussion.discussion_path, }; }, + isDiscussionInternal() { + return this.discussion.notes[0]?.internal; + }, + isFileDiscussion() { + return this.discussion.position?.position_type === FILE_DIFF_POSITION_TYPE; + }, }, methods: { ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), @@ -139,6 +146,8 @@ export default { :discussion-resolve-path="discussion.resolve_path" :is-overview-tab="isOverviewTab" :should-scroll-to-note="shouldScrollToNote" + :internal-note="isDiscussionInternal" + :class="{ 'gl-border-top-0!': isFileDiscussion }" @handleDeleteNote="$emit('deleteNote')" @startReplying="$emit('startReplying')" > @@ -171,6 +180,7 @@ export default { :note="componentData(note)" :help-page-path="helpPagePath" :line="line" + :internal-note="isDiscussionInternal" @handleDeleteNote="$emit('deleteNote')" /> </template> @@ -190,6 +200,7 @@ export default { :discussion-resolve-path="discussion.resolve_path" :is-overview-tab="isOverviewTab" :should-scroll-to-note="shouldScrollToNote" + :internal-note="isDiscussionInternal" @handleDeleteNote="$emit('deleteNote')" > <template #avatar-badge> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 27fb116d213..47e0ace1ea7 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -214,22 +214,18 @@ export default { methods: { ...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']), onEdit() { - this.closeMoreActionsDropdown(); this.$emit('handleEdit'); }, onDelete() { - this.closeMoreActionsDropdown(); this.$emit('handleDelete'); }, onResolve() { this.$emit('handleResolve'); }, onAbuse() { - this.closeMoreActionsDropdown(); this.toggleReportAbuseDrawer(true); }, onCopyUrl() { - this.closeMoreActionsDropdown(); this.$toast.show(__('Link copied to clipboard.')); }, handleAssigneeUpdate(assignees) { @@ -241,8 +237,6 @@ export default { let { assignees } = this; const { project_id, iid } = this.getNoteableData; - this.closeMoreActionsDropdown(); - if (this.isUserAssigned) { assignees = assignees.filter((assignee) => assignee.id !== this.author.id); } else { @@ -271,11 +265,6 @@ export default { toggleReportAbuseDrawer(isOpen) { this.isReportAbuseDrawerOpen = isOpen; }, - closeMoreActionsDropdown() { - if (this.shouldShowActionsDropdown && this.$refs.moreActionsDropdown) { - this.$refs.moreActionsDropdown.close(); - } - }, }, }; </script> @@ -374,7 +363,6 @@ export default { /> <div v-else-if="shouldShowActionsDropdown" class="more-actions dropdown"> <gl-disclosure-dropdown - ref="moreActionsDropdown" v-gl-tooltip :title="$options.i18n.moreActionsLabel" :aria-label="$options.i18n.moreActionsLabel" diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue index 8c8cc7984b1..18dd3f4366c 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -22,7 +22,7 @@ export default { data-track-action="click_button" data-track-label="reply_comment_button" category="tertiary" - icon="comment" + icon="reply" :title="$options.i18n.buttonText" :aria-label="$options.i18n.buttonText" @click="$emit('startReplying')" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 2cf6e9bb180..fe7967f1ed0 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -94,6 +94,11 @@ export default { required: false, default: false, }, + autofocus: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -359,7 +364,7 @@ export default { :autocomplete-data-sources="autocompleteDataSources" :disabled="isSubmitting" supports-quick-actions - autofocus + :autofocus="autofocus" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" @keydown.exact.up="editMyLastNote()" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 5e776639a7a..83cebb9a0e0 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -8,8 +8,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { TimeAgoTooltip, - GitlabTeamMemberBadge: () => - import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), GlIcon, GlBadge, GlLoadingIcon, @@ -199,7 +197,6 @@ export default { ><span class="note-headline-light">@{{ author.username }}</span> </a> <slot name="note-header-info"></slot> - <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" /> </span> <span v-if="emailParticipant" class="note-headline-light">{{ __('(external participant)') diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 375b16f6ce2..499581653ba 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -15,6 +15,7 @@ import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secr import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; +import { getErrorMessages } from '../utils'; import DiffDiscussionHeader from './diff_discussion_header.vue'; import DiffWithNote from './diff_with_note.vue'; import DiscussionActions from './discussion_actions.vue'; @@ -162,6 +163,17 @@ export default { return true; }, + isDiscussionInternal() { + return this.discussion.notes[0]?.internal; + }, + discussionHolderClass() { + return { + 'is-replying gl-pt-0!': this.isReplying, + 'internal-note': this.isDiscussionInternal, + 'public-note': !this.isDiscussionInternal, + 'gl-pt-0!': !this.discussion.diff_discussion && this.isReplying, + }; + }, }, created() { eventHub.$on('startReplying', this.onStartReplying); @@ -244,26 +256,24 @@ export default { }; this.saveNote(replyData) - .then((res) => { - if (res.hasAlert !== true) { - this.isReplying = false; - clearDraft(this.autosaveKey); - } + .then(() => { + this.isReplying = false; + clearDraft(this.autosaveKey); + callback(); }) .catch((err) => { - this.removePlaceholderNotes(); this.handleSaveError(err); // The 'err' parameter is being used in JH, don't remove it - this.$refs.noteForm.note = noteText; + this.removePlaceholderNotes(); + callback(err); }); }, - handleSaveError() { - const msg = __( - 'Your comment could not be submitted! Please check your network connection and try again.', - ); + handleSaveError({ response }) { + const errorMessage = getErrorMessages(response.data, response.status)[0]; + createAlert({ - message: msg, + message: errorMessage, parent: this.$el, }); }, @@ -284,6 +294,7 @@ export default { <div class="timeline-content"> <div :data-discussion-id="discussion.id" + :data-discussion-resolvable="discussion.resolvable" :data-discussion-resolved="discussion.resolved" class="discussion js-discussion-container" data-qa-selector="discussion_content" @@ -319,8 +330,9 @@ export default { /> <li v-else-if="canShowReplyActions && showReplies" - :class="{ 'is-replying gl-bg-white! gl-pt-0!': isReplying }" + data-testid="reply-wrapper" class="discussion-reply-holder gl-border-t-0! clearfix" + :class="discussionHolderClass" > <discussion-actions v-if="!isReplying && userCanReply" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 5929e419247..dd135eaee3b 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -18,6 +18,7 @@ import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import { renderMarkdown } from '../utils'; +import { UPDATE_COMMENT_FORM } from '../i18n'; import { getStartLineNumber, getEndLineNumber, @@ -113,6 +114,7 @@ export default { isResolving: false, commentLineStart: {}, resolveAsThread: true, + oldContent: this.note.note_html, }; }, computed: { @@ -293,7 +295,7 @@ export default { updateSuccess() { this.isEditing = false; this.isRequesting = false; - this.oldContent = null; + this.oldContent = this.note.note_html; renderGFM(this.$refs.noteBody.$el); this.$emit('updateSuccess'); }, @@ -341,7 +343,6 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/298827 if (!isEmpty(position)) data.note.note.position = JSON.stringify(position); this.isRequesting = true; - this.oldContent = this.note.note_html; // eslint-disable-next-line vue/no-mutating-props this.note.note_html = renderMarkdown(noteText); @@ -350,8 +351,8 @@ export default { this.updateSuccess(); callback(); }) - .catch((response) => { - if (response.status === HTTP_STATUS_GONE) { + .catch((e) => { + if (e.status === HTTP_STATUS_GONE) { this.removeNote(this.note); this.updateSuccess(); callback(); @@ -360,17 +361,22 @@ export default { this.isEditing = true; this.setSelectedCommentPositionHover(); this.$nextTick(() => { - this.handleUpdateError(response); // The 'response' parameter is being used in JH, don't remove it - this.recoverNoteContent(noteText); + this.handleUpdateError(e); // The 'e' parameter is being used in JH, don't remove it + this.recoverNoteContent(); callback(); }); } }); }, - handleUpdateError() { - const msg = __('Something went wrong while editing your comment. Please try again.'); + handleUpdateError(e) { + const serverErrorMessage = e?.response?.data?.errors; + + const alertMessage = serverErrorMessage + ? sprintf(UPDATE_COMMENT_FORM.error, { reason: serverErrorMessage.toLowerCase() }, false) + : UPDATE_COMMENT_FORM.defaultError; + createAlert({ - message: msg, + message: alertMessage, parent: this.$el, }); }, @@ -391,22 +397,14 @@ export default { }); if (!confirmed) return; } - if (this.oldContent) { - // eslint-disable-next-line vue/no-mutating-props - this.note.note_html = this.oldContent; - this.oldContent = null; - } + this.recoverNoteContent(); this.isEditing = false; this.$emit('cancelForm'); }), - recoverNoteContent(noteText) { - // we need to do this to prevent noteForm inconsistent content warning - // this is something we intentionally do so we need to recover the content - // eslint-disable-next-line vue/no-mutating-props - this.note.note = noteText; - const { noteBody } = this.$refs; - if (noteBody) { - noteBody.note.note = noteText; + recoverNoteContent() { + if (this.oldContent) { + // eslint-disable-next-line vue/no-mutating-props + this.note.note_html = this.oldContent; } }, getLineClasses(lineNumber) { diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 4bf2a8d70a7..c25ca6b586d 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -4,6 +4,7 @@ export const COMMENT_FORM = { GENERIC_UNSUBMITTABLE_NETWORK: __( 'Your comment could not be submitted! Please check your network connection and try again.', ), + error: __('Your comment could not be submitted because %{reason}.'), note: __('Note'), comment: __('Comment'), internalComment: __('Add internal note'), @@ -54,3 +55,8 @@ export const EDITED_TEXT = { actionWithAuthor: __('%{actionText} %{actionDetail} %{timeago} by %{author}'), actionWithoutAuthor: __('%{actionText} %{actionDetail}'), }; + +export const UPDATE_COMMENT_FORM = { + error: __('Your comment could not be updated because %{reason}.'), + defaultError: __('Something went wrong while editing your comment. Please try again.'), +}; diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 0509ff24959..55a63212dc5 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -1,6 +1,10 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils'; -import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; +import { + TEXT_DIFF_POSITION_TYPE, + IMAGE_DIFF_POSITION_TYPE, + FILE_DIFF_POSITION_TYPE, +} from '~/diffs/constants'; import { createAlert } from '~/alert'; import { clearDraft } from '~/lib/utils/autosave'; import { s__ } from '~/locale'; @@ -15,10 +19,10 @@ export default { }), ...mapGetters('diffs', ['getDiffFileByHash']), ...mapGetters('batchComments', ['shouldRenderDraftRowInDiscussion', 'draftForDiscussion']), - ...mapState('diffs', ['commit']), + ...mapState('diffs', ['commit', 'showWhitespace']), }, methods: { - ...mapActions('diffs', ['cancelCommentForm']), + ...mapActions('diffs', ['cancelCommentForm', 'toggleFileCommentForm']), ...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']), addReplyToReview(noteText, isResolving) { const postData = getDraftReplyFormData({ @@ -47,14 +51,14 @@ export default { }); }); }, - addToReview(note) { + addToReview(note, positionType = null) { const lineRange = (this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) || {}; - const positionType = this.diffFileCommentForm - ? IMAGE_DIFF_POSITION_TYPE - : TEXT_DIFF_POSITION_TYPE; - const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash); + const position = + positionType || + (this.diffFileCommentForm ? IMAGE_DIFF_POSITION_TYPE : TEXT_DIFF_POSITION_TYPE); + const diffFile = this.diffFile || this.file; const postData = getDraftFormData({ note, notesData: this.notesData, @@ -62,23 +66,26 @@ export default { noteableType: this.noteableType, noteTargetLine: this.noteTargetLine, diffViewType: this.diffViewType, - diffFile: selectedDiffFile, + diffFile, linePosition: this.position, - positionType, + positionType: position, ...this.diffFileCommentForm, lineRange, + showWhitespace: this.showWhitespace, }); - const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha; + const diffFileHeadSha = this.commit && diffFile?.diff_refs?.head_sha; postData.data.note.commit_id = diffFileHeadSha || null; return this.saveDraft(postData) .then(() => { - if (positionType === IMAGE_DIFF_POSITION_TYPE) { + if (position === IMAGE_DIFF_POSITION_TYPE) { this.closeDiffFileCommentForm(this.diffFileHash); - } else { + } else if (this.line?.line_code) { this.handleClearForm(this.line.line_code); + } else if (position === FILE_DIFF_POSITION_TYPE) { + this.toggleFileCommentForm(diffFile.file_path); } }) .catch(() => { diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 90de7db8c1b..8e69f1ddc88 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -9,7 +9,7 @@ function getAllDiscussionElements() { const containerEl = isOverviewPage() ? '.tab-pane.notes' : '.diffs'; return Array.from( document.querySelectorAll( - `${containerEl} div[data-discussion-id]:not([data-discussion-resolved])`, + `${containerEl} div[data-discussion-id][data-discussion-resolvable]:not([data-discussion-resolved])`, ), ); } diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index dc7f1577bbb..1bb44988c4d 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -95,12 +95,19 @@ export const fetchDiscussions = ( { commit, dispatch, getters }, { path, filter, persistFilter }, ) => { - const config = + let config = filter !== undefined ? { params: { notes_filter: filter, persist_filter: persistFilter } } : null; if ( + window.gon?.features?.mrActivityFilters && + getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE + ) { + config = { params: { notes_filter: 0, persist_filter: false } }; + } + + if ( getters.noteableType === constants.ISSUE_NOTEABLE_TYPE || getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ) { @@ -548,36 +555,11 @@ export const saveNote = ({ commit, dispatch }, noteData) => { return res; }; - const processErrors = (error) => { - if (error.response) { - const { - response: { data = {} }, - } = error; - const { errors = {} } = data; - const { base = [] } = errors; - - // we handle only errors.base for now - if (base.length > 0) { - const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), { - error: base[0].toLowerCase(), - }); - createAlert({ - message: errorMsg, - parent: noteData.flashContainer, - }); - return { ...data, hasAlert: true }; - } - } - - throw error; - }; - return dispatch(methodToDispatch, postData, { root: true }) .then(processQuickActions) .then(processEmojiAward) .then(processTimeTracking) - .then(removePlaceholder) - .catch(processErrors); + .then(removePlaceholder); }; export const setFetchingState = ({ commit }, fetchingState) => diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 317fe6442d4..7eba491430b 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -1,58 +1,10 @@ -import { ASC, MR_FILTER_OPTIONS } from '../../constants'; import * as actions from '../actions'; import * as getters from '../getters'; import mutations from '../mutations'; +import createState from '../state'; export default () => ({ - state: { - discussions: [], - discussionSortOrder: ASC, - persistSortOrder: true, - convertedDisscussionIds: [], - targetNoteHash: null, - lastFetchedAt: null, - currentDiscussionId: null, - batchSuggestionsInfo: [], - currentlyFetchingDiscussions: false, - doneFetchingBatchDiscussions: false, - /** - * selectedCommentPosition & selectedCommentPositionHover structures are the same as `position.line_range`: - * { - * start: { line_code: string, new_line: number, old_line:number, type: string }, - * end: { line_code: string, new_line: number, old_line:number, type: string }, - * } - */ - selectedCommentPosition: null, - selectedCommentPositionHover: null, - - // View layer - isToggleStateButtonLoading: false, - isNotesFetched: false, - isLoading: true, - isLoadingDescriptionVersion: false, - isPromoteCommentToTimelineEventInProgress: false, - - // holds endpoints and permissions provided through haml - notesData: { - markdownDocsPath: '', - }, - userData: {}, - noteableData: { - discussion_locked: false, - confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes. - current_user: {}, - preview_note_path: 'path/to/preview', - }, - isResolvingDiscussion: false, - commentsDisabled: false, - resolvableDiscussionsCount: 0, - unresolvedDiscussionsCount: 0, - descriptionVersions: {}, - isTimelineEnabled: false, - isFetching: false, - isPollingInitialized: false, - mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value), - }, + state: createState(), actions, getters, mutations, diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index c3407936847..a67928c387b 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,6 +1,7 @@ import { isEqual } from 'lodash'; import { STATUS_CLOSED, STATUS_REOPENED } from '~/issues/constants'; import { isInMRPage } from '~/lib/utils/common_utils'; +import { uuids } from '~/lib/utils/uuids'; import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; @@ -82,7 +83,7 @@ export default { const note = discussions[i]; const children = note.notes; - if (children.length && !note.individual_note) { + if (children.length > 1) { // remove placeholder from discussions for (let j = children.length - 1; j >= 0; j -= 1) { if (children[j].isPlaceholderNote) { @@ -185,6 +186,7 @@ export default { } notesArr.push({ + id: uuids()[0], individual_note: true, isPlaceholderNote: true, placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, diff --git a/app/assets/javascripts/notes/stores/state.js b/app/assets/javascripts/notes/stores/state.js new file mode 100644 index 00000000000..8e49cd861a1 --- /dev/null +++ b/app/assets/javascripts/notes/stores/state.js @@ -0,0 +1,53 @@ +import { ASC, MR_FILTER_OPTIONS } from '../constants'; + +const createState = () => ({ + discussions: [], + discussionSortOrder: ASC, + persistSortOrder: true, + convertedDisscussionIds: [], + targetNoteHash: null, + lastFetchedAt: null, + currentDiscussionId: null, + batchSuggestionsInfo: [], + currentlyFetchingDiscussions: false, + doneFetchingBatchDiscussions: false, + /** + * selectedCommentPosition & selectedCommentPositionHover structures are the same as `position.line_range`: + * { + * start: { line_code: string, new_line: number, old_line:number, type: string }, + * end: { line_code: string, new_line: number, old_line:number, type: string }, + * } + */ + selectedCommentPosition: null, + selectedCommentPositionHover: null, + + // View layer + isToggleStateButtonLoading: false, + isNotesFetched: false, + isLoading: true, + isLoadingDescriptionVersion: false, + isPromoteCommentToTimelineEventInProgress: false, + + // holds endpoints and permissions provided through haml + notesData: { + markdownDocsPath: '', + }, + userData: {}, + noteableData: { + discussion_locked: false, + confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes. + current_user: {}, + preview_note_path: 'path/to/preview', + }, + isResolvingDiscussion: false, + commentsDisabled: false, + resolvableDiscussionsCount: 0, + unresolvedDiscussionsCount: 0, + descriptionVersions: {}, + isTimelineEnabled: false, + isFetching: false, + isPollingInitialized: false, + mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value), +}); + +export default createState; diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index ed1c80e7a6e..c5859a89182 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -2,6 +2,9 @@ import { marked } from 'marked'; import markedBidi from 'marked-bidi'; import { sanitize } from '~/lib/dompurify'; import { markdownConfig } from '~/lib/utils/text_utility'; +import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { sprintf } from '~/locale'; +import { COMMENT_FORM } from './i18n'; /** * Tracks snowplow event when User toggles timeline view @@ -19,3 +22,17 @@ marked.use(markedBidi()); export const renderMarkdown = (rawMarkdown) => { return sanitize(marked(rawMarkdown), markdownConfig); }; + +export const getErrorMessages = (data, status) => { + const errors = data?.errors; + + if (errors && status === HTTP_STATUS_UNPROCESSABLE_ENTITY) { + if (errors.commands_only?.length) { + return errors.commands_only; + } + + return [sprintf(COMMENT_FORM.error, { reason: errors.toLowerCase() }, false)]; + } + + return [COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]; +}; diff --git a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue deleted file mode 100644 index 7120ad511d3..00000000000 --- a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script> -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { s__ } from '~/locale'; -import { timezones } from '~/monitoring/format_date'; - -export default { - components: { - GlFormGroup, - GlFormSelect, - }, - computed: { - ...mapState(['dashboardTimezone']), - dashboardTimezoneModel: { - get() { - return this.dashboardTimezone.selected; - }, - set(selected) { - this.setDashboardTimezone(selected); - }, - }, - options() { - return [ - { - value: timezones.LOCAL, - text: s__("MetricsSettings|User's local timezone"), - }, - { - value: timezones.UTC, - text: s__('MetricsSettings|UTC (Coordinated Universal Time)'), - }, - ]; - }, - }, - methods: { - ...mapActions(['setDashboardTimezone']), - }, -}; -</script> - -<template> - <gl-form-group - :label="s__('MetricsSettings|Dashboard timezone')" - label-for="dashboard-timezone-setting" - > - <template #description> - {{ - s__( - "MetricsSettings|Choose whether to display dashboard metrics in UTC or the user's local timezone.", - ) - }} - </template> - - <gl-form-select - id="dashboard-timezone-setting" - v-model="dashboardTimezoneModel" - :options="options" - /> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue deleted file mode 100644 index 2ea5b4e01b1..00000000000 --- a/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import { GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; - -export default { - components: { - GlFormGroup, - GlFormInput, - }, - computed: { - ...mapState(['externalDashboard']), - userDashboardUrl: { - get() { - return this.externalDashboard.url; - }, - set(url) { - this.setExternalDashboardUrl(url); - }, - }, - }, - methods: { - ...mapActions(['setExternalDashboardUrl']), - }, -}; -</script> - -<template> - <gl-form-group - :label="s__('MetricsSettings|External dashboard URL')" - label-for="external-dashboard-url" - > - <template #description> - {{ - s__( - 'MetricsSettings|Add a button to the metrics dashboard linking directly to your existing external dashboard.', - ) - }} - </template> - <!-- placeholder with a url is a false positive --> - <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> - <gl-form-input - id="external-dashboard-url" - v-model="userDashboardUrl" - placeholder="https://my-org.gitlab.io/my-dashboards" - /> - <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue deleted file mode 100644 index 959fffa2629..00000000000 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ /dev/null @@ -1,55 +0,0 @@ -<script> -import { GlButton, GlLink } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import DashboardTimezone from './form_group/dashboard_timezone.vue'; -import ExternalDashboard from './form_group/external_dashboard.vue'; - -export default { - components: { - GlButton, - GlLink, - ExternalDashboard, - DashboardTimezone, - }, - computed: { - ...mapState(['helpPage']), - userDashboardUrl: { - get() { - return this.externalDashboard.url; - }, - set(url) { - this.setExternalDashboardUrl(url); - }, - }, - }, - methods: { - ...mapActions(['saveChanges']), - }, -}; -</script> - -<template> - <section class="settings no-animate"> - <div class="settings-header"> - <h4 - class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" - > - {{ s__('MetricsSettings|Metrics') }} - </h4> - <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> - <p class="js-section-sub-header"> - {{ s__('MetricsSettings|Manage metrics dashboard settings.') }} - <gl-link :href="helpPage">{{ __('Learn more.') }}</gl-link> - </p> - </div> - <div class="settings-content"> - <form> - <dashboard-timezone /> - <external-dashboard /> - <gl-button variant="confirm" category="primary" @click="saveChanges"> - {{ __('Save Changes') }} - </gl-button> - </form> - </div> - </section> -</template> diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js deleted file mode 100644 index e56583963ad..00000000000 --- a/app/assets/javascripts/operation_settings/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import MetricsSettingsForm from './components/metrics_settings.vue'; -import store from './store'; - -export default () => { - const el = document.querySelector('.js-operation-settings'); - - if (!el) return false; - - return new Vue({ - el, - store: store(el.dataset), - render(createElement) { - return createElement(MetricsSettingsForm); - }, - }); -}; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js deleted file mode 100644 index 7fa79da59c4..00000000000 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ /dev/null @@ -1,41 +0,0 @@ -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import * as mutationTypes from './mutation_types'; - -export const setExternalDashboardUrl = ({ commit }, url) => - commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url); - -export const setDashboardTimezone = ({ commit }, selected) => - commit(mutationTypes.SET_DASHBOARD_TIMEZONE, selected); - -export const saveChanges = ({ state, dispatch }) => - axios - .patch(state.operationsSettingsEndpoint, { - project: { - metrics_setting_attributes: { - dashboard_timezone: state.dashboardTimezone.selected, - external_dashboard_url: state.externalDashboard.url, - }, - }, - }) - .then(() => dispatch('receiveSaveChangesSuccess')) - .catch((error) => dispatch('receiveSaveChangesError', error)); - -export const receiveSaveChangesSuccess = () => { - /** - * The operations_controller currently handles successful requests - * by creating an alert banner message to notify the user. - */ - refreshCurrentPage(); -}; - -export const receiveSaveChangesError = (_, error) => { - const { response = {} } = error; - const message = response.data && response.data.message ? response.data.message : ''; - - createAlert({ - message: `${__('There was an error saving your changes.')} ${message}`, - }); -}; diff --git a/app/assets/javascripts/operation_settings/store/index.js b/app/assets/javascripts/operation_settings/store/index.js deleted file mode 100644 index a11bd8089fd..00000000000 --- a/app/assets/javascripts/operation_settings/store/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import mutations from './mutations'; -import createState from './state'; - -Vue.use(Vuex); - -export const createStore = (initialState) => - new Vuex.Store({ - state: createState(initialState), - actions, - mutations, - }); - -export default createStore; diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js deleted file mode 100644 index 92543fd7f03..00000000000 --- a/app/assets/javascripts/operation_settings/store/mutation_types.js +++ /dev/null @@ -1,2 +0,0 @@ -export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL'; -export const SET_DASHBOARD_TIMEZONE = 'SET_DASHBOARD_TIMEZONE'; diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js deleted file mode 100644 index f55717f6c98..00000000000 --- a/app/assets/javascripts/operation_settings/store/mutations.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_EXTERNAL_DASHBOARD_URL](state, url) { - state.externalDashboard.url = url; - }, - [types.SET_DASHBOARD_TIMEZONE](state, selected) { - state.dashboardTimezone.selected = selected; - }, -}; diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js deleted file mode 100644 index c0eca580848..00000000000 --- a/app/assets/javascripts/operation_settings/store/state.js +++ /dev/null @@ -1,10 +0,0 @@ -export default (initialState = {}) => ({ - operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, - helpPage: initialState.helpPage, - externalDashboard: { - url: initialState.externalDashboardUrl, - }, - dashboardTimezone: { - selected: initialState.dashboardTimezoneSetting, - }, -}); 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 7ac803a8ece..3a5992d182a 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 @@ -68,7 +68,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( export const CREATED_AT = s__('ContainerRegistry|Created %{time}'); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); -export const NOT_AVAILABLE_SIZE = __('0 bytes'); +export const NOT_AVAILABLE_SIZE = __('0 B'); export const CLEANUP_UNSCHEDULED_TEXT = s__('ContainerRegistry|Cleanup will run %{time}'); export const CLEANUP_SCHEDULED_TEXT = s__('ContainerRegistry|Cleanup pending'); diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js index 5b4b85ec31e..ce98be914ae 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js @@ -16,7 +16,7 @@ export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}'); export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}'); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); -export const NOT_AVAILABLE_SIZE = __('0 bytes'); +export const NOT_AVAILABLE_SIZE = __('0 B'); export const TOKEN_TYPE_TAG_NAME = 'tag_name'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue index e45b88bc6d5..ecd1bfb8ebe 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue @@ -1,5 +1,11 @@ <script> -import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui'; +import { + GlLink, + GlTable, + GlDisclosureDropdownItem, + GlDisclosureDropdown, + GlButton, +} from '@gitlab/ui'; import { last } from 'lodash'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; @@ -13,9 +19,8 @@ export default { components: { GlLink, GlTable, - GlIcon, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlButton, FileIcon, TimeAgoTooltip, @@ -136,14 +141,16 @@ export default { </template> <template #cell(actions)="{ item }"> - <gl-dropdown category="tertiary" right> - <template #button-content> - <gl-icon name="ellipsis_v" /> - </template> - <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)"> - {{ $options.i18n.deleteFile }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown category="tertiary" right no-caret icon="ellipsis_v"> + <gl-disclosure-dropdown-item + data-testid="delete-file" + @action="$emit('delete-file', item)" + > + <template #list-item> + {{ $options.i18n.deleteFile }} + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> <template #row-details="{ item }"> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 6ea1fff9ef0..37fc326f902 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -81,7 +81,6 @@ export default { const urlParams = new URLSearchParams(window.location.search); const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); if (showAlert) { - // to be refactored to use gl-alert createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO }); const cleanUrl = window.location.href.split('?')[0]; historyReplaceState(cleanUrl); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index 8eb8654cddd..3157653648b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -1,6 +1,17 @@ <script> -import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui'; -import { last } from 'lodash'; +import { + GlAlert, + GlLink, + GlTable, + GlDropdownItem, + GlDropdown, + GlButton, + GlFormCheckbox, + GlLoadingIcon, + GlModal, + GlSprintf, +} from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __, s__ } from '~/locale'; import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue'; @@ -9,69 +20,117 @@ import { packageTypeToTrackCategory } from '~/packages_and_registries/package_re import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { + FETCH_PACKAGE_FILES_ERROR_MESSAGE, + GRAPHQL_PACKAGE_FILES_PAGE_SIZE, REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION, SELECT_PACKAGE_FILE_TRACKING_ACTION, + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, + DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, TRACKING_LABEL_PACKAGE_ASSET, TRACKING_ACTION_EXPAND_PACKAGE_ASSET, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILES_ERROR_MESSAGE, + DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILES_TRACKING_ACTION, + DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, + DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT, } from '~/packages_and_registries/package_registry/constants'; +import getPackageFilesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql'; +import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; export default { name: 'PackageFiles', components: { + GlAlert, GlLink, GlTable, GlDropdown, GlDropdownItem, GlFormCheckbox, GlButton, + GlLoadingIcon, + GlModal, + GlSprintf, FileIcon, TimeAgoTooltip, FileSha, }, mixins: [Tracking.mixin()], + trackingActions: { + DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, + }, props: { canDelete: { type: Boolean, required: false, default: false, }, - isLoading: { - type: Boolean, - required: false, - default: false, + packageId: { + type: String, + required: true, + }, + packageType: { + type: String, + required: true, }, + projectPath: { + type: String, + required: true, + }, + }, + apollo: { packageFiles: { - type: Array, - required: false, - default: () => [], + query: getPackageFilesQuery, + context: { + isSingleRequest: true, + }, + variables() { + return this.queryVariables; + }, + update(data) { + return data.package?.packageFiles ?? {}; + }, + error() { + this.fetchPackageFilesError = true; + }, }, }, data() { return { + fetchPackageFilesError: false, + filesToDelete: [], + packageFiles: {}, + mutationLoading: false, selectedReferences: [], }; }, computed: { + files() { + return this.packageFiles?.nodes ?? []; + }, areFilesSelected() { return this.selectedReferences.length > 0; }, areAllFilesSelected() { - return this.packageFiles.every(this.isSelected); + return this.files.length > 0 && this.files.every(this.isSelected); }, filesTableRows() { - return this.packageFiles.map((pf) => ({ + return this.files.map((pf) => ({ ...pf, size: this.formatSize(pf.size), - pipeline: last(pf.pipelines), })); }, hasSelectedSomeFiles() { return this.areFilesSelected && !this.areAllFilesSelected; }, - showCommitColumn() { - // note that this is always false for now since we do not return - // pipelines associated to files for performance concerns - return this.filesTableRows.some((row) => Boolean(row.pipeline?.id)); + isLoading() { + return this.$apollo.queries.packageFiles.loading || this.mutationLoading; }, filesTableHeaderFields() { return [ @@ -86,11 +145,6 @@ export default { label: __('Name'), }, { - key: 'commit', - label: __('Commit'), - hide: !this.showCommitColumn, - }, - { key: 'size', label: __('Size'), }, @@ -108,11 +162,40 @@ export default { }, ].filter((c) => !c.hide); }, + queryVariables() { + return { + id: this.packageId, + first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, + }; + }, tracking() { return { category: packageTypeToTrackCategory(this.packageType), }; }, + refetchQueriesData() { + return [ + { + query: getPackageFilesQuery, + variables: this.queryVariables, + }, + ]; + }, + modalAction() { + return this.hasOneItem(this.filesToDelete) + ? this.$options.modal.fileDeletePrimaryAction + : this.$options.modal.filesDeletePrimaryAction; + }, + modalTitle() { + return this.hasOneItem(this.filesToDelete) + ? this.$options.i18n.deleteFileModalTitle + : this.$options.i18n.deleteFilesModalTitle; + }, + modalDescription() { + return this.hasOneItem(this.filesToDelete) + ? this.$options.i18n.deleteFileModalContent + : this.$options.i18n.deleteFilesModalContent; + }, }, methods: { formatSize(size) { @@ -135,13 +218,96 @@ export default { }, handleFileDeleteSelected() { this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION); - this.$emit('delete-files', this.selectedReferences); + this.handleFileDelete(this.selectedReferences); + }, + async deletePackageFiles(ids) { + this.mutationLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: destroyPackageFilesMutation, + variables: { + projectPath: this.projectPath, + ids, + }, + awaitRefetchQueries: true, + refetchQueries: this.refetchQueriesData, + }); + if (data?.destroyPackageFiles?.errors[0]) { + throw data.destroyPackageFiles.errors[0]; + } + createAlert({ + message: this.hasOneItem(ids) + ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE + : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + variant: VARIANT_SUCCESS, + }); + } catch (error) { + createAlert({ + message: this.hasOneItem(ids) + ? DELETE_PACKAGE_FILE_ERROR_MESSAGE + : DELETE_PACKAGE_FILES_ERROR_MESSAGE, + variant: VARIANT_WARNING, + captureError: true, + error, + }); + } finally { + this.mutationLoading = false; + this.filesToDelete = []; + this.selectedReferences = []; + } + }, + handleFileDelete(files) { + this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION); + if (files.length === this.files.length && !this.packageFiles?.pageInfo?.hasNextPage) { + this.$emit( + 'delete-all-files', + this.hasOneItem(files) + ? DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT + : DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, + ); + } else { + this.filesToDelete = files; + this.$refs.deleteFilesModal.show(); + } + }, + hasOneItem(items) { + return items.length === 1; + }, + confirmFilesDelete() { + if (this.hasOneItem(this.filesToDelete)) { + this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION); + } else { + this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION); + } + this.deletePackageFiles(this.filesToDelete.map((file) => file.id)); }, }, i18n: { - deleteFile: __('Delete asset'), + deleteFile: s__('PackageRegistry|Delete asset'), + deleteFileModalTitle: s__('PackageRegistry|Delete package asset'), + deleteFileModalContent: s__( + 'PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?', + ), + deleteFilesModalTitle: s__('PackageRegistry|Delete %{count} assets'), + deleteFilesModalContent: s__( + 'PackageRegistry|You are about to delete %{count} assets. This operation is irreversible.', + ), deleteSelected: s__('PackageRegistry|Delete selected'), moreActionsText: __('More actions'), + fetchPackageFilesErrorMessage: FETCH_PACKAGE_FILES_ERROR_MESSAGE, + }, + modal: { + fileDeletePrimaryAction: { + text: __('Delete'), + attributes: { variant: 'danger', category: 'primary' }, + }, + filesDeletePrimaryAction: { + text: s__('PackageRegistry|Permanently delete assets'), + attributes: { variant: 'danger', category: 'primary' }, + }, + cancelAction: { + text: __('Cancel'), + }, }, }; </script> @@ -151,7 +317,7 @@ export default { <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3> <gl-button - v-if="canDelete" + v-if="!fetchPackageFilesError && canDelete" :disabled="isLoading || !areFilesSelected" category="secondary" variant="danger" @@ -161,7 +327,16 @@ export default { {{ $options.i18n.deleteSelected }} </gl-button> </div> + <gl-alert + v-if="fetchPackageFilesError" + variant="danger" + @dismiss="fetchPackageFilesError = false" + > + {{ $options.i18n.fetchPackageFilesErrorMessage }} + </gl-alert> <gl-table + v-else + :busy="isLoading" :fields="filesTableHeaderFields" :items="filesTableRows" show-empty @@ -171,6 +346,9 @@ export default { :tbody-tr-attr="{ 'data-testid': 'file-row' }" @row-selected="updateSelectedReferences" > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> <template #head(checkbox)="{ selectAllRows, clearSelected }"> <gl-form-checkbox v-if="canDelete" @@ -207,7 +385,7 @@ export default { :href="item.downloadPath" class="gl-text-gray-500" data-testid="download-link" - @click="$emit('download-file')" + @click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)" > <file-icon :file-name="item.fileName" @@ -218,16 +396,6 @@ export default { </gl-link> </template> - <template #cell(commit)="{ item }"> - <gl-link - v-if="item.pipeline && item.pipeline" - :href="item.pipeline.commitPath" - class="gl-text-gray-500" - data-testid="commit-link" - >{{ item.pipeline.sha }} - </gl-link> - </template> - <template #cell(created)="{ item }"> <time-ago-tooltip :time="item.createdAt" /> </template> @@ -241,7 +409,7 @@ export default { no-caret right > - <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])"> + <gl-dropdown-item data-testid="delete-file" @click="handleFileDelete([item])"> {{ $options.i18n.deleteFile }} </gl-dropdown-item> </gl-dropdown> @@ -262,5 +430,34 @@ export default { </div> </template> </gl-table> + + <gl-modal + ref="deleteFilesModal" + size="sm" + modal-id="delete-files-modal" + :action-primary="modalAction" + :action-cancel="$options.modal.cancelAction" + data-testid="delete-files-modal" + @primary="confirmFilesDelete" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" + > + <template #modal-title> + <gl-sprintf :message="modalTitle"> + <template #count> + {{ filesToDelete.length }} + </template> + </gl-sprintf> + </template> + + <gl-sprintf :message="modalDescription"> + <template #filename> + <strong>{{ filesToDelete[0].fileName }}</strong> + </template> + + <template #count> + {{ filesToDelete.length }} + </template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index cee976656f9..5eabcea9e15 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -1,7 +1,6 @@ <script> import { GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __, s__, sprintf } from '~/locale'; import { formatDate } from '~/lib/utils/datetime_utility'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; @@ -61,13 +60,6 @@ export default { hasTagsToDisplay() { return Boolean(this.packageEntity.tags?.nodes && this.packageEntity.tags?.nodes.length); }, - totalSize() { - return this.packageEntity.packageFiles - ? numberToHumanSize( - this.packageEntity.packageFiles.nodes.reduce((acc, p) => acc + Number(p.size), 0), - ) - : '0'; - }, }, mounted() { this.checkBreakpoints(); @@ -126,10 +118,6 @@ export default { <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" /> </template> - <template #metadata-size> - <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> - </template> - <template v-if="isGroupPage && packagePipeline" #metadata-pipeline> <metadata-item data-testid="pipeline-project" 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 b4276d69ed6..80712c2991c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -102,6 +102,9 @@ export const FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE = s__( export const FETCH_PACKAGE_METADATA_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while fetching the package metadata.', ); +export const FETCH_PACKAGE_FILES_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while fetching package assets.', +); export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages'; export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages'; @@ -232,3 +235,4 @@ export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath( ); export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10; +export const GRAPHQL_PACKAGE_FILES_PAGE_SIZE = 100; 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 39e5da54509..d05ff5daad4 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 @@ -21,6 +21,9 @@ export const apolloProvider = new VueApollo({ keyArgs: false, merge: mergeVariables, }, + packageFiles: { + 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 984996b829a..4c71de9ee20 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 @@ -46,21 +46,6 @@ query getPackageDetails($id: PackagesPackageID!) { } } } - packageFiles(first: 100) { - pageInfo { - hasNextPage - } - nodes { - id - fileMd5 - fileName - fileSha1 - fileSha256 - size - createdAt - downloadPath - } - } versions { count } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql new file mode 100644 index 00000000000..e6f292ec1d3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql @@ -0,0 +1,20 @@ +query getPackageFiles($id: PackagesPackageID!, $first: Int) { + package(id: $id) { + id + packageFiles(first: $first) { + pageInfo { + hasNextPage + } + nodes { + id + fileMd5 + fileName + fileSha1 + fileSha256 + size + createdAt + downloadPath + } + } + } +} 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 6d4979ac785..d96418571e1 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 @@ -11,7 +11,7 @@ import { GlTabs, GlSprintf, } from '@gitlab/ui'; -import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; +import { createAlert } from '~/alert'; import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -21,10 +21,8 @@ import { packageTypeToTrackCategory } from '~/packages_and_registries/package_re import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; -import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; -import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue'; import { PACKAGE_TYPE_NUGET, @@ -35,27 +33,15 @@ import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, - DELETE_PACKAGE_FILE_TRACKING_ACTION, - DELETE_PACKAGE_FILES_TRACKING_ACTION, - REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, REQUEST_FORWARDING_HELP_PAGE_PATH, - CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, SHOW_DELETE_SUCCESS_ALERT, FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, - DELETE_PACKAGE_FILE_ERROR_MESSAGE, - DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, - DELETE_PACKAGE_FILES_ERROR_MESSAGE, - DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT, - DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, DELETE_MODAL_TITLE, DELETE_MODAL_CONTENT, - DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, - DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT, GRAPHQL_PAGE_SIZE, } from '~/packages_and_registries/package_registry/constants'; -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'; @@ -76,9 +62,13 @@ export default { PackageHistory, AdditionalMetadata, InstallationCommands, - PackageFiles, + PackageFiles: () => + import('~/packages_and_registries/package_registry/components/details/package_files.vue'), DeletePackages, - PackageVersionsList, + PackageVersionsList: () => + import( + '~/packages_and_registries/package_registry/components/details/package_versions_list.vue' + ), }, directives: { GlTooltip: GlTooltipDirective, @@ -90,10 +80,6 @@ export default { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, - DELETE_PACKAGE_FILE_TRACKING_ACTION, - REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, - CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, - DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, }, data() { return { @@ -147,18 +133,12 @@ export default { id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId), }; }, - packageFiles() { - return this.packageEntity.packageFiles?.nodes; - }, packageType() { return this.packageEntity.packageType; }, isLoading() { return this.$apollo.queries.packageEntity.loading; }, - packageFilesLoading() { - return this.isLoading || this.mutationLoading; - }, isValidPackage() { return this.isLoading || Boolean(this.packageEntity.name); }, @@ -194,14 +174,6 @@ export default { PACKAGE_TYPE_PYPI, ].includes(this.packageType); }, - refetchQueriesData() { - return [ - { - query: getPackageDetails, - variables: this.queryVariables, - }, - ]; - }, refetchVersionsQueryData() { return [ { @@ -228,71 +200,9 @@ export default { window.location.replace(`${returnTo}?${modalQuery}`); }, - async deletePackageFiles(ids) { - this.mutationLoading = true; - try { - const { data } = await this.$apollo.mutate({ - mutation: destroyPackageFilesMutation, - variables: { - projectPath: this.projectPath, - ids, - }, - awaitRefetchQueries: true, - refetchQueries: this.refetchQueriesData, - }); - if (data?.destroyPackageFiles?.errors[0]) { - throw data.destroyPackageFiles.errors[0]; - } - createAlert({ - message: this.isLastItem(ids) - ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE - : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, - variant: VARIANT_SUCCESS, - }); - } catch (error) { - createAlert({ - message: this.isLastItem(ids) - ? DELETE_PACKAGE_FILE_ERROR_MESSAGE - : DELETE_PACKAGE_FILES_ERROR_MESSAGE, - variant: VARIANT_WARNING, - captureError: true, - error, - }); - } - this.mutationLoading = false; - }, - handleFileDelete(files) { - this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION); - if ( - files.length === this.packageFiles.length && - !this.packageEntity.packageFiles?.pageInfo?.hasNextPage - ) { - if (this.isLastItem(files)) { - this.deletePackageModalContent = DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT; - } else { - this.deletePackageModalContent = DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT; - } - this.$refs.deleteModal.show(); - } else { - this.filesToDelete = files; - if (this.isLastItem(files)) { - this.$refs.deleteFileModal.show(); - } else if (files.length > 1) { - this.$refs.deleteFilesModal.show(); - } - } - }, - isLastItem(items) { - return items.length === 1; - }, - confirmFilesDelete() { - if (this.isLastItem(this.filesToDelete)) { - this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION); - } else { - this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION); - } - this.deletePackageFiles(this.filesToDelete.map((file) => file.id)); - this.filesToDelete = []; + handleAllFilesDelete(content) { + this.deletePackageModalContent = content; + this.$refs.deleteModal.show(); }, resetDeleteModalContent() { this.deletePackageModalContent = DELETE_MODAL_CONTENT; @@ -300,10 +210,6 @@ export default { }, i18n: { DELETE_MODAL_TITLE, - deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`), - deleteFileModalContent: s__( - `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, - ), otherVersionsTabTitle: s__('PackageRegistry|Other versions'), }, links: { @@ -358,7 +264,7 @@ export default { <gl-tabs> <gl-tab :title="__('Detail')"> - <div v-if="!isLoading" data-qa-selector="package_information_content"> + <div data-qa-selector="package_information_content"> <package-history :package-entity="packageEntity" :project-name="projectName" /> <installation-commands :package-entity="packageEntity" /> @@ -368,16 +274,16 @@ export default { :package-id="packageEntity.id" :package-type="packageType" /> - </div> - <package-files - v-if="showFiles" - :can-delete="packageEntity.canDestroy" - :is-loading="packageFilesLoading" - :package-files="packageFiles" - @download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)" - @delete-files="handleFileDelete" - /> + <package-files + v-if="showFiles" + :can-delete="packageEntity.canDestroy" + :package-id="packageEntity.id" + :package-type="packageType" + :project-path="projectPath" + @delete-all-files="handleAllFilesDelete" + /> + </div> </gl-tab> <gl-tab v-if="showDependencies"> @@ -468,51 +374,5 @@ export default { </gl-modal> </template> </delete-packages> - - <gl-modal - ref="deleteFileModal" - size="sm" - modal-id="delete-file-modal" - :action-primary="$options.modal.fileDeletePrimaryAction" - :action-cancel="$options.modal.cancelAction" - data-testid="delete-file-modal" - @primary="confirmFilesDelete" - @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" - > - <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template> - <gl-sprintf v-if="isLastItem(filesToDelete)" :message="$options.i18n.deleteFileModalContent"> - <template #filename> - <strong>{{ filesToDelete[0].fileName }}</strong> - </template> - </gl-sprintf> - </gl-modal> - - <gl-modal - ref="deleteFilesModal" - size="sm" - modal-id="delete-files-modal" - :action-primary="$options.modal.filesDeletePrimaryAction" - :action-cancel="$options.modal.cancelAction" - data-testid="delete-files-modal" - @primary="confirmFilesDelete" - @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" - > - <template #modal-title>{{ - n__( - `PackageRegistry|Delete 1 asset`, - `PackageRegistry|Delete %d assets`, - filesToDelete.length, - ) - }}</template> - <span v-if="filesToDelete.length > 0"> - {{ - n__( - `PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`, - `PackageRegistry|You are about to delete %d assets. This operation is irreversible.`, - filesToDelete.length, - ) - }} - </span> - </gl-modal> </div> </template> 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 044ce4e6413..14d617a7a3c 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 @@ -114,7 +114,6 @@ export default { const urlParams = new URLSearchParams(window.location.search); const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); if (showAlert) { - // to be refactored to use gl-alert createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO }); const cleanUrl = window.location.href.split('?')[0]; historyReplaceState(cleanUrl); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue index f95ec4336dc..80df8ef81e6 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -139,7 +139,7 @@ export default { :form-options="$options.formOptions.keepNDuplicatedPackageFiles" :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL" :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION" - dropdown-class="gl-md-max-w-50p gl-sm-pr-5" + dropdown-class="gl-md-max-w-50p" name="keep-n-duplicated-package-files" data-testid="keep-n-duplicated-package-files-dropdown" @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js index 524b2c6f66a..82051507276 100644 --- a/app/assets/javascripts/pages/admin/clusters/show/index.js +++ b/app/assets/javascripts/pages/admin/clusters/show/index.js @@ -1,7 +1,5 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initIntegrationForm from '~/clusters/forms/show'; -import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; new ClustersBundle(); // eslint-disable-line no-new -initClusterHealth(); initIntegrationForm(); diff --git a/app/assets/javascripts/pages/admin/jobs/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js index 8c4ea2cde92..4af8cb355fc 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js @@ -1,5 +1,5 @@ import { s__, __ } from '~/locale'; -import { DEFAULT_FIELDS, RAW_TEXT_WARNING } from '~/jobs/components/table/constants'; +import { RAW_TEXT_WARNING } from '~/jobs/components/table/constants'; export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.'); export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.'); @@ -19,11 +19,17 @@ export const RUNNER_EMPTY_TEXT = __('None'); export const RUNNER_NO_DESCRIPTION = s__('Runners|No description'); /* Admin Table constants */ +/* The field list is based on app/assets/javascripts/jobs/components/table/constants.js */ export const DEFAULT_FIELDS_ADMIN = [ - ...DEFAULT_FIELDS.slice(0, 2), + { key: 'status', label: __('Status'), columnClass: 'gl-w-15p' }, + { key: 'job', label: __('Job'), columnClass: 'gl-w-20p' }, { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, - ...DEFAULT_FIELDS.slice(2), + { key: 'pipeline', label: __('Pipeline'), columnClass: 'gl-w-10p' }, + { key: 'stage', label: __('Stage'), columnClass: 'gl-w-10p' }, + { key: 'name', label: __('Name'), columnClass: 'gl-w-15p' }, + { key: 'duration', label: __('Duration'), columnClass: 'gl-w-15p' }, + { key: 'actions', label: '', columnClass: 'gl-w-10p' }, ]; export const RAW_TEXT_WARNING_ADMIN = RAW_TEXT_WARNING; diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js index b2cbd52fb27..901fd9193a5 100644 --- a/app/assets/javascripts/pages/admin/topics/edit/index.js +++ b/app/assets/javascripts/pages/admin/topics/edit/index.js @@ -1,11 +1,10 @@ -import $ from 'jquery'; -import GLForm from '~/gl_form'; import initFilePickers from '~/file_pickers'; import ZenMode from '~/zen_mode'; import { initRemoveAvatar } from '~/admin/topics'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; -new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new initFilePickers(); new ZenMode(); // eslint-disable-line no-new initRemoveAvatar(); +mountMarkdownEditor(); diff --git a/app/assets/javascripts/pages/admin/topics/new/index.js b/app/assets/javascripts/pages/admin/topics/new/index.js index c4e05bbd092..fc9ca4fd4e6 100644 --- a/app/assets/javascripts/pages/admin/topics/new/index.js +++ b/app/assets/javascripts/pages/admin/topics/new/index.js @@ -1,8 +1,7 @@ -import $ from 'jquery'; -import GLForm from '~/gl_form'; import initFilePickers from '~/file_pickers'; import ZenMode from '~/zen_mode'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; -new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new initFilePickers(); new ZenMode(); // eslint-disable-line no-new +mountMarkdownEditor(); diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js index 5d202a8824f..487e7a14a16 100644 --- a/app/assets/javascripts/pages/groups/clusters/show/index.js +++ b/app/assets/javascripts/pages/groups/clusters/show/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; new ClustersBundle(); // eslint-disable-line no-new -initClusterHealth(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 2e71eced66f..df6ca8eab96 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,8 +1,6 @@ import { groupMemberRequestFormatter } from '~/groups/members/utils'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import { initMembersApp } from '~/members'; import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants'; @@ -60,7 +58,5 @@ const APP_OPTIONS = { initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS); -initInviteMembersModal(); initInviteGroupsModal(); -initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index 513f4968dbd..3ee15077d00 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -1,6 +1,6 @@ <script> -import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg'; -import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg'; +import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg?raw'; +import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg?raw'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; @@ -39,6 +39,11 @@ export default { required: false, default: false, }, + isSaas: { + type: Boolean, + required: false, + default: false, + }, }, computed: { initialBreadcrumbs() { @@ -93,6 +98,7 @@ export default { :initial-breadcrumbs="initialBreadcrumbs" :panels="panels" :title="s__('GroupsNew|Create new group')" + :is-saas="isSaas" persistence-key="new_group_last_active_tab" /> </template> diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 2e53324717c..84e031ae67a 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -27,6 +27,7 @@ function initNewGroupCreation(el) { parentGroupUrl, parentGroupName, importExistingGroupPath, + isSaas, } = el.dataset; const props = { @@ -36,6 +37,7 @@ function initNewGroupCreation(el) { parentGroupName, importExistingGroupPath, hasErrors: parseBoolean(hasErrors), + isSaas: parseBoolean(isSaas), }; const apolloProvider = new VueApollo({ diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index dba65c7e791..5d9eafe5672 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -1,6 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initNotificationsDropdown from '~/notifications'; import ProjectsList from '~/projects_list'; @@ -12,5 +11,4 @@ export default function initGroupDetails() { new ProjectsList(); // eslint-disable-line no-new initInviteMembersBanner(); - initInviteMembersModal(); } diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index 96ea7329e6e..69457adf94e 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,8 +1,11 @@ import emojiRegex from 'emoji-regex'; import { __ } from '~/locale'; import { initSetStatusForm } from '~/profile/profile'; +import { initProfileEdit } from '~/profile/edit'; initSetStatusForm(); +// It will do nothing for now when the feature flag is turned off +initProfileEdit(); const userNameInput = document.getElementById('user_name'); if (userNameInput) { diff --git a/app/assets/javascripts/pages/profiles/slacks/index.js b/app/assets/javascripts/pages/profiles/slacks/index.js new file mode 100644 index 00000000000..4066d0046ae --- /dev/null +++ b/app/assets/javascripts/pages/profiles/slacks/index.js @@ -0,0 +1,3 @@ +import initGitlabSlackApplication from '~/integrations/gitlab_slack_application'; + +initGitlabSlackApplication(); diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index ac5e0b28dd1..f5cd03ac48d 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -1,9 +1,9 @@ import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import BranchSortDropdown from '~/branches/branch_sort_dropdown'; import initDiverganceGraph from '~/branches/divergence_graph'; -import initDeleteBranchButton from '~/branches/init_delete_branch_button'; import initDeleteBranchModal from '~/branches/init_delete_branch_modal'; import initDeleteMergedBranches from '~/branches/init_delete_merged_branches'; +import initBranchMoreActions from '~/branches/init_branch_more_actions'; const { divergingCountsEndpoint, defaultBranch } = document.querySelector( '.js-branch-list', @@ -14,8 +14,6 @@ BranchSortDropdown(); initDeprecatedRemoveRowBehavior(); initDeleteMergedBranches(); -document - .querySelectorAll('.js-delete-branch-button') - .forEach((elem) => initDeleteBranchButton(elem)); +document.querySelectorAll('.js-branch-more-actions').forEach((elem) => initBranchMoreActions(elem)); initDeleteBranchModal(); diff --git a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js deleted file mode 100644 index 382d39645a9..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js +++ /dev/null @@ -1,18 +0,0 @@ -import monitoringApp from '~/monitoring/monitoring_app'; - -export default () => { - const el = document.getElementById('prometheus-graphs'); - - if (el && el.dataset) { - monitoringApp({ - ...el.dataset, - showLegend: false, - showHeader: false, - showPanels: false, - forceSmallGraph: true, - smallEmptyState: true, - currentEnvironmentName: '', - hasMetrics: true, - }); - } -}; diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index 0b34f374abc..5c5402a14b1 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,9 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initIntegrationForm from '~/clusters/forms/show'; import initGkeNamespace from '~/clusters/gke_cluster_namespace'; -import initClusterHealth from './cluster_health'; new ClustersBundle(); // eslint-disable-line no-new initGkeNamespace(); -initClusterHealth(); initIntegrationForm(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 38cc4337047..67dc3782a24 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,7 +1,9 @@ import mountNotesApp from 'ee_else_ce/mr_notes/mount_app'; import { initReportAbuse } from '~/projects/report_abuse'; +import { initMrMoreDropdown } from '~/mr_more_dropdown'; import { initMrPage } from '../page'; initMrPage(); mountNotesApp(); initReportAbuse(); +initMrMoreDropdown(); diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js index d3f46b7e025..44a384f03c6 100644 --- a/app/assets/javascripts/pages/projects/pipelines/show/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js @@ -2,4 +2,4 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; import initPipelines from '../init_pipelines'; initPipelines(); -initPipelineDetails(); +initPipelineDetails(gon.features.pipelineDetailsHeaderVue); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 79a4ed0f9c3..1e9111a3cc6 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,9 +1,7 @@ import initImportProjectMembersTrigger from '~/invite_members/init_import_project_members_trigger'; import initImportProjectMembersModal from '~/invite_members/init_import_project_members_modal'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import { initMembersApp } from '~/members'; import { MEMBER_TYPES } from '~/members/constants'; @@ -11,9 +9,7 @@ import { groupLinkRequestFormatter } from '~/members/utils'; import { projectMemberRequestFormatter } from '~/projects/members/utils'; initImportProjectMembersModal(); -initInviteMembersModal(); initInviteGroupsModal(); -initInviteMembersTrigger(); initInviteGroupTrigger(); initImportProjectMembersTrigger(); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 731b1373987..b2681267e06 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -2,6 +2,7 @@ import initArtifactsSettings from '~/artifacts_settings'; import SecretValues from '~/behaviors/secret_values'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initVariableList from '~/ci/ci_variable_list'; +import initInheritedGroupCiVariables from '~/ci/inherited_ci_variables'; import initDeployFreeze from '~/deploy_freeze'; import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; @@ -26,6 +27,7 @@ if (runnerToken) { } initVariableList(); +initInheritedGroupCiVariables(); // hide extra auto devops settings based checkbox state const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 3a46241e2eb..1b8657c5ec7 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,14 +1,10 @@ import mountAlertsSettings from '~/alerts_settings'; import mountErrorTrackingForm from '~/error_tracking_settings'; -import mountGrafanaIntegration from '~/grafana_integration'; import initIncidentsSettings from '~/incidents_settings'; -import mountOperationSettings from '~/operation_settings'; import initSettingsPanels from '~/settings_panels'; initIncidentsSettings(); mountErrorTrackingForm(); -mountOperationSettings(); -mountGrafanaIntegration(); if (!IS_EE) { initSettingsPanels(); } diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue new file mode 100644 index 00000000000..ed5ba3c2653 --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue @@ -0,0 +1,165 @@ +<script> +import { GlBadge, GlLink, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui'; +import { createAlert, VARIANT_INFO } from '~/alert'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +import getCiCatalogSettingsQuery from '../graphql/queries/get_ci_catalog_settings.query.graphql'; +import catalogResourcesCreate from '../graphql/mutations/catalog_resources_create.mutation.graphql'; + +export const i18n = { + badgeText: __('Experiment'), + catalogResourceQueryError: s__( + 'CiCatalog|There was a problem fetching the CI/CD Catalog setting.', + ), + catalogResourceMutationError: s__( + 'CiCatalog|There was a problem marking the project as a CI/CD Catalog resource.', + ), + catalogResourceMutationSuccess: s__('CiCatalog|This project is now a CI/CD Catalog resource.'), + ciCatalogLabel: s__('CiCatalog|CI/CD Catalog resource'), + ciCatalogHelpText: s__( + 'CiCatalog|Mark project as a CI/CD Catalog resource. %{linkStart}What is the CI/CD Catalog?%{linkEnd}', + ), + modal: { + actionPrimary: { + text: s__('CiCatalog|Mark project as a CI/CD Catalog resource'), + }, + actionCancel: { + text: __('Cancel'), + }, + body: s__( + 'CiCatalog|This project will be marked as a CI/CD Catalog resource and will be visible in the CI/CD Catalog. This action is not reversible.', + ), + title: s__('CiCatalog|Mark project as a CI/CD Catalog resource'), + }, + readMeHelpText: s__( + 'CiCatalog|The project must contain a README.md file and a template.yml file. When enabled, the repository is available in the CI/CD Catalog.', + ), +}; + +export const ciCatalogHelpPath = helpPagePath('ci/components/index', { + anchor: 'components-catalog', +}); + +export default { + i18n, + components: { + GlBadge, + GlLink, + GlLoadingIcon, + GlModal, + GlSprintf, + GlToggle, + }, + props: { + fullPath: { + type: String, + required: true, + }, + }, + data() { + return { + ciCatalogHelpPath, + isCatalogResource: false, + showCatalogResourceModal: false, + }; + }, + apollo: { + isCatalogResource: { + query: getCiCatalogSettingsQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ project }) { + return project?.isCatalogResource || false; + }, + error() { + createAlert({ message: this.$options.i18n.catalogResourceQueryError }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.isCatalogResource.loading; + }, + }, + methods: { + async markProjectAsCatalogResource() { + try { + const { + data: { + catalogResourcesCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: catalogResourcesCreate, + variables: { input: { projectPath: this.fullPath } }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + + this.isCatalogResource = true; + createAlert({ + message: this.$options.i18n.catalogResourceMutationSuccess, + variant: VARIANT_INFO, + }); + } catch (error) { + const message = error.message || this.$options.i18n.catalogResourceMutationError; + createAlert({ message }); + } + }, + onCatalogResourceEnabledToggled() { + this.showCatalogResourceModal = true; + }, + onModalCanceled() { + this.showCatalogResourceModal = false; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" /> + <div v-else data-testid="ci-catalog-settings"> + <div> + <label class="gl-mb-1 gl-mr-2"> + {{ $options.i18n.ciCatalogLabel }} + </label> + <gl-badge size="sm" variant="info"> {{ $options.i18n.badgeText }} </gl-badge> + </div> + <gl-sprintf :message="$options.i18n.ciCatalogHelpText"> + <template #link="{ content }"> + <gl-link :href="ciCatalogHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + <gl-toggle + class="gl-my-2" + :disabled="isCatalogResource" + :value="isCatalogResource" + :label="$options.i18n.ciCatalogLabel" + label-position="hidden" + name="ci_resource_enabled" + @change="onCatalogResourceEnabledToggled" + /> + <div class="gl-text-secondary"> + {{ $options.i18n.readMeHelpText }} + </div> + <gl-modal + :visible="showCatalogResourceModal" + modal-id="mark-as-catalog-resource" + size="sm" + :title="$options.i18n.modal.title" + :action-cancel="$options.i18n.modal.actionCancel" + :action-primary="$options.i18n.modal.actionPrimary" + @canceled="onModalCanceled" + @primary="markProjectAsCatalogResource" + > + {{ $options.i18n.modal.body }} + </gl-modal> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 64c363dd721..c54596488af 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -2,7 +2,6 @@ import { GlButton, GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, s__ } from '~/locale'; import { VISIBILITY_LEVEL_PRIVATE_INTEGER, @@ -16,10 +15,12 @@ import { featureAccessLevel, CVE_ID_REQUEST_BUTTON_I18N, featureAccessLevelDescriptions, + modelExperimentsHelpPath, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; import ProjectFeatureSetting from './project_feature_setting.vue'; import ProjectSettingRow from './project_setting_row.vue'; +import CiCatalogSettings from './ci_catalog_settings.vue'; const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')]; @@ -34,6 +35,7 @@ export default { ...CVE_ID_REQUEST_BUTTON_I18N, analyticsLabel: s__('ProjectSettings|Analytics'), containerRegistryLabel: s__('ProjectSettings|Container registry'), + ciCdLabel: __('CI/CD'), forksLabel: s__('ProjectSettings|Forks'), issuesLabel: s__('ProjectSettings|Issues'), lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'), @@ -57,8 +59,11 @@ export default { packageRegistryForEveryoneLabel: s__( 'ProjectSettings|Allow anyone to pull from Package Registry', ), + modelExperimentsLabel: s__('ProjectSettings|Model experiments'), + modelExperimentsHelpText: s__( + 'ProjectSettings|Track machine learning model experiments and artifacts.', + ), pagesLabel: s__('ProjectSettings|Pages'), - ciCdLabel: __('CI/CD'), repositoryLabel: s__('ProjectSettings|Repository'), requirementsLabel: s__('ProjectSettings|Requirements'), releasesLabel: s__('ProjectSettings|Releases'), @@ -77,8 +82,10 @@ export default { VISIBILITY_LEVEL_PRIVATE_INTEGER, VISIBILITY_LEVEL_INTERNAL_INTEGER, VISIBILITY_LEVEL_PUBLIC_INTEGER, + modelExperimentsHelpPath, components: { + CiCatalogSettings, ProjectFeatureSetting, ProjectSettingRow, GlButton, @@ -93,7 +100,7 @@ export default { 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue' ), }, - mixins: [settingsMixin, glFeatureFlagsMixin()], + mixins: [settingsMixin], props: { requestCveAvailable: { @@ -101,6 +108,11 @@ export default { required: false, default: false, }, + canAddCatalogResource: { + type: Boolean, + required: false, + default: false, + }, currentSettings: { type: Object, required: true, @@ -246,11 +258,11 @@ export default { forkingAccessLevel: featureAccessLevel.EVERYONE, mergeRequestsAccessLevel: featureAccessLevel.EVERYONE, packageRegistryAccessLevel: featureAccessLevel.EVERYONE, + modelExperimentsAccessLevel: featureAccessLevel.EVERYONE, buildsAccessLevel: featureAccessLevel.EVERYONE, wikiAccessLevel: featureAccessLevel.EVERYONE, snippetsAccessLevel: featureAccessLevel.EVERYONE, pagesAccessLevel: featureAccessLevel.EVERYONE, - metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, analyticsAccessLevel: featureAccessLevel.EVERYONE, requirementsAccessLevel: featureAccessLevel.EVERYONE, securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, @@ -392,15 +404,15 @@ export default { ) { this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS; } + this.modelExperimentsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.modelExperimentsAccessLevel, + ); this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel); this.snippetsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.snippetsAccessLevel, ); - this.metricsDashboardAccessLevel = Math.min( - featureAccessLevel.PROJECT_MEMBERS, - this.metricsDashboardAccessLevel, - ); this.analyticsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.analyticsAccessLevel, @@ -458,14 +470,14 @@ export default { this.buildsAccessLevel = featureAccessLevel.EVERYONE; if (this.wikiAccessLevel > featureAccessLevel.NOT_ENABLED) this.wikiAccessLevel = featureAccessLevel.EVERYONE; + if (this.modelExperimentsAccessLevel > featureAccessLevel.NOT_ENABLED) + this.modelExperimentsAccessLevel = featureAccessLevel.EVERYONE; if (this.snippetsAccessLevel > featureAccessLevel.NOT_ENABLED) this.snippetsAccessLevel = featureAccessLevel.EVERYONE; if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.pagesAccessLevel = featureAccessLevel.EVERYONE; if (this.analyticsAccessLevel > featureAccessLevel.NOT_ENABLED) this.analyticsAccessLevel = featureAccessLevel.EVERYONE; - if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS) - this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE; if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.requirementsAccessLevel = featureAccessLevel.EVERYONE; if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) @@ -503,22 +515,9 @@ export default { else if (oldValue === featureAccessLevel.NOT_ENABLED) toggleHiddenClassBySelector('.merge-requests-feature', false); }, - - monitorAccessLevel(value, oldValue) { - this.updateSubFeatureAccessLevel(value, oldValue); - }, }, methods: { - updateSubFeatureAccessLevel(value, oldValue) { - if (value < oldValue) { - // sub-features cannot have more permissive access level - this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value); - } else if (oldValue === 0) { - this.metricsDashboardAccessLevel = value; - } - }, - highlightChanges() { this.highlightChangesClass = true; this.$nextTick(() => { @@ -552,7 +551,7 @@ export default { <template> <div> <div - class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5" + class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-5" > <project-setting-row ref="project-visibility-settings" @@ -647,7 +646,7 @@ export default { </div> <div :class="{ 'highlight-changes': highlightChangesClass }" - class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5 gl-bg-gray-10" + class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-5 gl-bg-gray-10" > <project-setting-row ref="issues-settings" @@ -693,7 +692,7 @@ export default { name="project[project_feature_attributes][repository_access_level]" /> </project-setting-row> - <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"> + <div class="project-feature-setting-group gl-pl-5 gl-md-pl-7"> <project-setting-row ref="merge-request-settings" :label="$options.i18n.mergeRequestsLabel" @@ -875,7 +874,7 @@ export default { /> <div v-if="packageRegistryApiForEveryoneEnabledShown" - class="project-feature-setting-group gl-pl-7 gl-sm-pl-5 gl-my-3" + class="project-feature-setting-group gl-pl-5 gl-md-pl-7 gl-my-3" > <project-setting-row :label="$options.i18n.packageRegistryForEveryoneLabel" @@ -899,6 +898,19 @@ export default { /> </project-setting-row> <project-setting-row + ref="model-experiments-settings" + :label="$options.i18n.modelExperimentsLabel" + :help-text="$options.i18n.modelExperimentsHelpText" + :help-path="$options.modelExperimentsHelpPath" + > + <project-feature-setting + v-model="modelExperimentsAccessLevel" + :label="$options.i18n.modelExperimentsLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][model_experiments_access_level]" + /> + </project-setting-row> + <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" ref="pages-settings" :help-path="pagesHelpPath" @@ -930,23 +942,6 @@ export default { name="project[project_feature_attributes][monitor_access_level]" /> </project-setting-row> - <div - v-if="!glFeatures.removeMonitorMetrics" - class="project-feature-setting-group gl-pl-7 gl-sm-pl-5" - > - <project-setting-row - ref="metrics-visibility-settings" - :label="__('Metrics Dashboard')" - :help-text="s__('ProjectSettings|Visualize the project\'s performance metrics.')" - > - <project-feature-setting - v-model="metricsDashboardAccessLevel" - :show-toggle="false" - :options="monitorOperationsFeatureAccessLevelOptions" - name="project[project_feature_attributes][metrics_dashboard_access_level]" - /> - </project-setting-row> - </div> <project-setting-row ref="environments-settings" :label="$options.i18n.environmentsLabel" @@ -1000,6 +995,11 @@ export default { /> </project-setting-row> </div> + <ci-catalog-settings + v-if="canAddCatalogResource" + class="gl-mb-5" + :full-path="confirmationPhrase" + /> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index 4c687859344..522cc7cfc1a 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -4,6 +4,7 @@ import { VISIBILITY_LEVEL_INTERNAL_INTEGER, VISIBILITY_LEVEL_PUBLIC_INTEGER, } from '~/visibility_level/constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const visibilityLevelDescriptions = { [VISIBILITY_LEVEL_PRIVATE_INTEGER]: __( @@ -43,3 +44,7 @@ export const featureAccessLevelEveryone = [ export const CVE_ID_REQUEST_BUTTON_I18N = { cve_request_toggle_label: s__('CVE|Enable CVE ID requests in the issue sidebar'), }; + +export const modelExperimentsHelpPath = helpPagePath( + 'user/project/ml/experiment_tracking/index.md', +); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql b/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql new file mode 100644 index 00000000000..c3b73ebf248 --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql @@ -0,0 +1,5 @@ +mutation catalogResourcesCreate($input: CatalogResourcesCreateInput!) { + catalogResourcesCreate(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql b/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql new file mode 100644 index 00000000000..0de06028386 --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql @@ -0,0 +1,6 @@ +query getCiCatalogSettings($fullPath: ID!) { + project(fullPath: $fullPath) { + id + isCatalogResource + } +} diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js index de8b1cc400e..4b4589dc46c 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/index.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js @@ -1,8 +1,17 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + import { parseBoolean } from '~/lib/utils/common_utils'; import settingsPanel from './components/settings_panel.vue'; +Vue.use(VueApollo); + export default function initProjectPermissionsSettings() { + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + const mountPoint = document.querySelector('.js-project-permissions-form'); const componentPropsEl = document.querySelector('.js-project-permissions-form-data'); const componentProps = JSON.parse(componentPropsEl.innerHTML); @@ -19,6 +28,8 @@ export default function initProjectPermissionsSettings() { return new Vue({ el: mountPoint, + name: 'ProjectPermissionsRoot', + apolloProvider, provide: { additionalInformation, confirmDangerMessage, diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js index 43ff617dabe..ce36ff6a230 100644 --- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js @@ -1,7 +1,15 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility'; import WebIdeButton from '~/vue_shared/components/web_ide_link.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default ({ el, router }) => { if (!el) return; @@ -15,6 +23,10 @@ export default ({ el, router }) => { new Vue({ el, router, + apolloProvider, + provide: { + projectPath, + }, render(h) { return h(WebIdeButton, { props: { diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 33d4090011f..e17f5255c54 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,7 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert'; import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; @@ -42,8 +40,6 @@ initVueNotificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new initUploadFileTrigger(); -initInviteMembersModal(); -initInviteMembersTrigger(); initClustersDeprecationAlert(); initTerraformNotification(); diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js index 6eef2352e2c..11c257611f0 100644 --- a/app/assets/javascripts/pages/projects/work_items/index.js +++ b/app/assets/javascripts/pages/projects/work_items/index.js @@ -1,5 +1,3 @@ import { initWorkItemsRoot } from '~/work_items/index'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; initWorkItemsRoot(); -initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 3b38d715ea5..4f68c7984e8 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -107,7 +107,7 @@ export default { MarkdownEditor, }, mixins: [trackingMixin], - inject: ['formatOptions', 'pageInfo'], + inject: ['formatOptions', 'pageInfo', 'drawioUrl'], data() { return { editingMode: 'source', @@ -183,6 +183,9 @@ export default { disableSubmitButton() { return this.noContent || !this.title; }, + drawioEnabled() { + return typeof this.drawioUrl === 'string' && this.drawioUrl.length > 0; + }, }, mounted() { if (!this.commitMessage) this.updateCommitMessage(); @@ -356,7 +359,7 @@ export default { :autofocus="pageInfo.persisted" :enable-autocomplete="true" :autocomplete-data-sources="autocompleteDataSources" - :drawio-enabled="true" + :drawio-enabled="drawioEnabled" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" @keydown.ctrl.enter="submitFormShortcut" diff --git a/app/assets/javascripts/pages/shared/wikis/edit.js b/app/assets/javascripts/pages/shared/wikis/edit.js index 02878633916..0044575de62 100644 --- a/app/assets/javascripts/pages/shared/wikis/edit.js +++ b/app/assets/javascripts/pages/shared/wikis/edit.js @@ -70,6 +70,7 @@ const createWikiFormApp = () => { provide: { formatOptions: JSON.parse(formatOptions), pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)), + drawioUrl: gon.diagramsnet_url, }, render(createElement) { return createElement(wikiForm); diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index 30c351359e4..af55a5dc01a 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -2,11 +2,16 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; import { initReportAbuse } from '~/users/profile'; +import { initProfileTabs } from '~/profile'; import UserTabs from './user_tabs'; function initUserProfile(action) { - // eslint-disable-next-line no-new - new UserTabs({ parentEl: '.user-profile', action }); + if (gon.features?.profileTabsVue) { + initProfileTabs(); + } else { + // eslint-disable-next-line no-new + new UserTabs({ parentEl: '.user-profile', action }); + } // hide project limit message $('.hide-project-limit-message').on('click', (e) => { diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js index c213753257d..47424ec1dd3 100644 --- a/app/assets/javascripts/pages/users/show/index.js +++ b/app/assets/javascripts/pages/users/show/index.js @@ -1,7 +1,3 @@ -import { initProfileTabs, initUserAchievements } from '~/profile'; - -if (gon.features?.profileTabsVue) { - initProfileTabs(); -} +import { initUserAchievements } from '~/profile'; initUserAchievements(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 3130fe42c3c..e7f2662ae09 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -11,7 +11,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-eoa-bronze-plan-banner', '.js-security-newsletter-callout', '.js-approaching-seat-count-threshold', - '.js-storage-enforcement-banner', + '.js-storage-pre-enforcement-alert', '.js-user-over-limit-free-plan-alert', '.js-minute-limit-banner', '.js-submit-license-usage-data-banner', @@ -24,6 +24,9 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-geo-migrate-hashed-storage-callout', '.js-unlimited-members-during-trial-alert', '.js-branch-rules-info-callout', + '.js-new-navigation-callout', + '.js-code-suggestions-third-party-callout', + '.js-namespace-over-storage-users-combined-alert', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue new file mode 100644 index 00000000000..8fe6707028a --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue @@ -0,0 +1,629 @@ +<script> +import { + GlAlert, + GlBadge, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlModal, + GlModalDirective, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { __, s__, sprintf, formatNumber } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { + LOAD_FAILURE, + POST_FAILURE, + DELETE_FAILURE, + DEFAULT, + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, +} from '../constants'; +import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql'; +import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; +import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import TimeAgo from './pipelines_list/time_ago.vue'; +import { getQueryHeaders } from './graph/utils'; + +const DELETE_MODAL_ID = 'pipeline-delete-modal'; +const POLL_INTERVAL = 10000; + +export default { + name: 'PipelineDetailsHeader', + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, + pipelineCancel: 'pipelineCancel', + pipelineRetry: 'pipelineRetry', + finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], + components: { + CiBadgeLink, + ClipboardButton, + GlAlert, + GlBadge, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlModal, + GlSprintf, + TimeAgo, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + i18n: { + scheduleBadgeText: s__('Pipelines|Scheduled'), + scheduleBadgeTooltip: __('This pipeline was triggered by a schedule'), + childBadgeText: s__('Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})'), + childBadgeTooltip: __('This is a child pipeline within the parent pipeline'), + latestBadgeText: s__('Pipelines|latest'), + latestBadgeTooltip: __('Latest pipeline for the most recent commit on this branch'), + mergeTrainBadgeText: s__('Pipelines|merge train'), + mergeTrainBadgeTooltip: s__( + 'Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.', + ), + invalidBadgeText: s__('Pipelines|yaml invalid'), + failedBadgeText: s__('Pipelines|error'), + autoDevopsBadgeText: s__('Pipelines|Auto DevOps'), + autoDevopsBadgeTooltip: __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by Auto DevOps.', + ), + detachedBadgeText: s__('Pipelines|merge request'), + detachedBadgeTooltip: s__( + "Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.", + ), + stuckBadgeText: s__('Pipelines|stuck'), + stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'), + computeCreditsTooltip: s__('Pipelines|Total amount of compute credits used for the pipeline'), + totalJobsTooltip: s__('Pipelines|Total number of jobs for the pipeline'), + retryPipelineText: __('Retry'), + cancelPipelineText: __('Cancel pipeline'), + deletePipelineText: __('Delete'), + clipboardTooltip: __('Copy commit SHA'), + }, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), + [POST_FAILURE]: __('An error occurred while making the request.'), + [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'), + [DEFAULT]: __('An unknown error occurred.'), + }, + modal: { + id: DELETE_MODAL_ID, + title: __('Delete pipeline'), + deleteConfirmationText: __( + 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', + ), + actionPrimary: { + text: __('Delete pipeline'), + attributes: { + variant: 'danger', + }, + }, + actionCancel: { + text: __('Cancel'), + }, + }, + inject: { + graphqlResourceEtag: { + default: '', + }, + paths: { + default: {}, + }, + pipelineIid: { + default: '', + }, + }, + props: { + name: { + type: String, + required: false, + default: '', + }, + totalJobs: { + type: String, + required: false, + default: '', + }, + computeCredits: { + type: String, + required: false, + default: '', + }, + yamlErrors: { + type: String, + required: false, + default: '', + }, + failureReason: { + type: String, + required: false, + default: '', + }, + refText: { + type: String, + required: false, + default: '', + }, + badges: { + type: Object, + required: false, + default: () => {}, + }, + }, + apollo: { + pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getPipelineQuery, + variables() { + return { + fullPath: this.paths.fullProject, + iid: this.pipelineIid, + }; + }, + update(data) { + return data.project.pipeline; + }, + error() { + this.reportFailure(LOAD_FAILURE); + }, + pollInterval: POLL_INTERVAL, + watchLoading(isLoading) { + if (!isLoading) { + // To ensure apollo has updated the cache, + // we only remove the loading state in sync with GraphQL + this.isCanceling = false; + this.isRetrying = false; + } + }, + }, + }, + data() { + return { + pipeline: null, + failureMessages: [], + failureType: null, + isCanceling: false, + isRetrying: false, + isDeleting: false, + }; + }, + computed: { + loading() { + return this.$apollo.queries.pipeline.loading; + }, + hasError() { + return this.failureType; + }, + hasPipelineData() { + return Boolean(this.pipeline); + }, + isLoadingInitialQuery() { + return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + }, + detailedStatus() { + return this.pipeline?.detailedStatus || {}; + }, + status() { + return this.pipeline?.status; + }, + isFinished() { + return this.$options.finishedStatuses.includes(this.status); + }, + shouldRenderContent() { + return !this.isLoadingInitialQuery && this.hasPipelineData; + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case POST_FAILURE: + return { + text: this.$options.errorTexts[POST_FAILURE], + variant: 'danger', + }; + case DELETE_FAILURE: + return { + text: this.$options.errorTexts[DELETE_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, + user() { + return this.pipeline?.user; + }, + userId() { + return getIdFromGraphQLId(this.user?.id); + }, + shortId() { + return this.pipeline?.commit?.shortId || ''; + }, + commitPath() { + return this.pipeline?.commit?.webPath || ''; + }, + commitTitle() { + return this.pipeline?.commit?.title || ''; + }, + totalJobsText() { + return sprintf(__('%{jobs} Jobs'), { + jobs: this.totalJobs, + }); + }, + triggeredText() { + return sprintf(__('triggered pipeline for commit %{linkStart}%{shortId}%{linkEnd}'), { + shortId: this.shortId, + }); + }, + inProgress() { + return this.status === 'RUNNING'; + }, + duration() { + return this.pipeline?.duration || 0; + }, + showDuration() { + return this.duration && this.isFinished; + }, + durationFormatted() { + return timeIntervalInWords(this.duration); + }, + queuedDuration() { + return this.pipeline?.queuedDuration || 0; + }, + inProgressText() { + return sprintf(__('In progress, queued for %{queuedDuration} seconds'), { + queuedDuration: formatNumber(this.queuedDuration), + }); + }, + durationText() { + return sprintf(__('%{duration}, queued for %{queuedDuration} seconds'), { + duration: this.durationFormatted, + queuedDuration: formatNumber(this.queuedDuration), + }); + }, + canRetryPipeline() { + const { retryable, userPermissions } = this.pipeline; + + return retryable && userPermissions.updatePipeline; + }, + canCancelPipeline() { + const { cancelable, userPermissions } = this.pipeline; + + return cancelable && userPermissions.updatePipeline; + }, + showComputeCredits() { + return this.isFinished && this.computeCredits !== '0.0'; + }, + }, + methods: { + reportFailure(errorType, errorMessages = []) { + this.failureType = errorType; + this.failureMessages = errorMessages; + }, + async postPipelineAction(name, mutation) { + try { + const { + data: { + [name]: { errors }, + }, + } = await this.$apollo.mutate({ + mutation, + variables: { id: this.pipeline.id }, + }); + + if (errors.length > 0) { + this.isRetrying = false; + + this.reportFailure(POST_FAILURE, errors); + } else { + await this.$apollo.queries.pipeline.refetch(); + if (!this.isFinished) { + this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); + } + } + } catch { + this.isRetrying = false; + + this.reportFailure(POST_FAILURE); + } + }, + cancelPipeline() { + this.isCanceling = true; + this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation); + }, + retryPipeline() { + this.isRetrying = true; + this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation); + }, + async deletePipeline() { + this.isDeleting = true; + this.$apollo.queries.pipeline.stopPolling(); + + try { + const { + data: { + pipelineDestroy: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deletePipelineMutation, + variables: { + id: this.pipeline.id, + }, + }); + + if (errors.length > 0) { + this.reportFailure(DELETE_FAILURE, errors); + this.isDeleting = false; + } else { + redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated + } + } catch { + this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); + this.reportFailure(DELETE_FAILURE); + this.isDeleting = false; + } + }, + }, +}; +</script> + +<template> + <div class="gl-my-4"> + <gl-alert + v-if="hasError" + class="gl-mb-4" + :title="failure.text" + :variant="failure.variant" + :dismissible="false" + > + <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`"> + {{ failureMessage }} + </div> + </gl-alert> + <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> + <div + v-else + class="gl-display-flex gl-justify-content-space-between" + data-qa-selector="pipeline_details_header" + > + <div> + <h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3> + <h3 v-else class="gl-mt-0 gl-mb-2" data-testid="pipeline-commit-title"> + {{ commitTitle }} + </h3> + <div> + <ci-badge-link :status="detailedStatus" /> + <div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6"> + <gl-link + v-if="user" + :href="user.webUrl" + class="gl-display-inline-block gl-text-gray-900 gl-font-weight-bold js-user-link" + :data-user-id="userId" + :data-username="user.username" + data-testid="pipeline-user-link" + > + {{ user.name }} + </gl-link> + <gl-sprintf :message="triggeredText"> + <template #link="{ content }"> + <gl-link + :href="commitPath" + class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2" + data-testid="commit-link" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <clipboard-button + :text="shortId" + category="tertiary" + :title="$options.i18n.clipboardTooltip" + size="small" + /> + <time-ago + v-if="isFinished" + :pipeline="pipeline" + class="gl-display-inline gl-mb-0" + :display-calendar-icon="false" + font-size="gl-font-md" + /> + </div> + </div> + <div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div> + <div> + <gl-badge + v-if="badges.schedule" + v-gl-tooltip + :title="$options.i18n.scheduleBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.scheduleBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.child" + v-gl-tooltip + :title="$options.i18n.childBadgeTooltip" + variant="info" + size="sm" + > + <gl-sprintf :message="$options.i18n.childBadgeText"> + <template #link="{ content }"> + <gl-link :href="paths.triggeredByPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-badge> + <gl-badge + v-if="badges.latest" + v-gl-tooltip + :title="$options.i18n.latestBadgeTooltip" + variant="success" + size="sm" + > + {{ $options.i18n.latestBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.mergeTrainPipeline" + v-gl-tooltip + :title="$options.i18n.mergeTrainBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.mergeTrainBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.invalid" + v-gl-tooltip + :title="yamlErrors" + variant="danger" + size="sm" + > + {{ $options.i18n.invalidBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.failed" + v-gl-tooltip + :title="failureReason" + variant="danger" + size="sm" + > + {{ $options.i18n.failedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.autoDevops" + v-gl-tooltip + :title="$options.i18n.autoDevopsBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.autoDevopsBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.detached" + v-gl-tooltip + :title="$options.i18n.detachedBadgeTooltip" + variant="info" + size="sm" + data-qa-selector="merge_request_badge_tag" + > + {{ $options.i18n.detachedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.stuck" + v-gl-tooltip + :title="$options.i18n.stuckBadgeTooltip" + variant="warning" + size="sm" + > + {{ $options.i18n.stuckBadgeText }} + </gl-badge> + <span + v-gl-tooltip + :title="$options.i18n.totalJobsTooltip" + class="gl-ml-2" + data-testid="total-jobs" + > + <gl-icon name="pipeline" /> + {{ totalJobsText }} + </span> + <span + v-if="showComputeCredits" + v-gl-tooltip + :title="$options.i18n.computeCreditsTooltip" + class="gl-ml-2" + data-testid="compute-credits" + > + <gl-icon name="quota" /> + {{ computeCredits }} + </span> + <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text"> + <gl-icon name="timer" /> + {{ inProgressText }} + </span> + <span v-if="showDuration" class="gl-ml-2" data-testid="pipeline-duration-text"> + <gl-icon name="timer" /> + {{ durationText }} + </span> + </div> + </div> + <div> + <gl-button + v-if="canRetryPipeline" + v-gl-tooltip + :aria-label="$options.BUTTON_TOOLTIP_RETRY" + :title="$options.BUTTON_TOOLTIP_RETRY" + :loading="isRetrying" + :disabled="isRetrying" + variant="confirm" + data-testid="retry-pipeline" + class="js-retry-button" + @click="retryPipeline()" + > + {{ $options.i18n.retryPipelineText }} + </gl-button> + + <gl-button + v-if="canCancelPipeline" + v-gl-tooltip + :aria-label="$options.BUTTON_TOOLTIP_CANCEL" + :title="$options.BUTTON_TOOLTIP_CANCEL" + :loading="isCanceling" + :disabled="isCanceling" + class="gl-ml-3" + variant="danger" + data-testid="cancel-pipeline" + @click="cancelPipeline()" + > + {{ $options.i18n.cancelPipelineText }} + </gl-button> + + <gl-button + v-if="pipeline.userPermissions.destroyPipeline" + v-gl-modal="$options.modal.id" + :loading="isDeleting" + :disabled="isDeleting" + class="gl-ml-3" + variant="danger" + category="secondary" + data-testid="delete-pipeline" + > + {{ $options.i18n.deletePipelineText }} + </gl-button> + </div> + </div> + <gl-modal + :modal-id="$options.modal.id" + :title="$options.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + @primary="deletePipeline()" + > + <p> + {{ $options.modal.deleteConfirmationText }} + </p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue new file mode 100644 index 00000000000..91630d4cfd4 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue @@ -0,0 +1,149 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; +import { + getQueryHeaders, + toggleQueryPollingByVisibility, +} from '~/pipelines/components/graph/utils'; +import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants'; +import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; +import PipelineMiniGraph from './pipeline_mini_graph.vue'; + +export default { + i18n: { + linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'), + stagesFetchError: __('There was a problem fetching the pipeline stages.'), + }, + components: { + GlLoadingIcon, + PipelineMiniGraph, + }, + props: { + pipelineEtag: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pollInterval: { + type: Number, + required: false, + default: PIPELINE_MINI_GRAPH_POLL_INTERVAL, + }, + }, + data() { + return { + linkedPipelines: null, + pipelineStages: [], + }; + }, + apollo: { + linkedPipelines: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getLinkedPipelinesQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline || this.linkedpipelines; + }, + error() { + createAlert({ message: this.$options.i18n.linkedPipelinesFetchError }); + }, + }, + pipelineStages: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineStagesQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline?.stages?.nodes || this.pipelineStages; + }, + error() { + createAlert({ message: this.$options.i18n.stagesFetchError }); + }, + }, + }, + computed: { + downstreamPipelines() { + return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes); + }, + formattedStages() { + return this.pipelineStages.map((stage) => { + const { name, detailedStatus } = stage; + return { + // TODO: Once we fetch stage by ID with GraphQL, + // this method will change. + // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853 + id: stage.id, + dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, + name, + path: `${this.pipelinePath}#${name}`, + status: { + details_path: `${this.pipelinePath}#${name}`, + has_details: detailedStatus?.hasDetails || false, + ...detailedStatus, + }, + title: `${name}: ${detailedStatus?.text || ''}`, + }; + }); + }, + pipelinePath() { + return this.linkedPipelines?.path || ''; + }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines); + toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages); + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" /> + <pipeline-mini-graph + v-else + data-testid="graphql-pipeline-mini-graph" + :downstream-pipelines="downstreamPipelines" + :is-merge-train="isMergeTrain" + :pipeline-path="pipelinePath" + :stages="formattedStages" + :upstream-pipeline="upstreamPipeline" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue new file mode 100644 index 00000000000..fce0b5f525e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue @@ -0,0 +1,143 @@ +<script> +import { + GlButton, + GlCollapse, + GlIcon, + GlLink, + GlLoadingIcon, + GlPopover, + GlSprintf, +} from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__ } from '~/locale'; +import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import WidgetFailedJobRow from './widget_failed_job_row.vue'; +import { sortJobsByStatus } from './utils'; + +const JOB_ID_HEADER = __('Job ID'); +const JOB_NAME_HEADER = __('Job name'); +const STAGE_HEADER = __('Stage'); + +export default { + components: { + GlButton, + GlCollapse, + GlIcon, + GlLink, + GlLoadingIcon, + GlPopover, + GlSprintf, + WidgetFailedJobRow, + }, + inject: ['fullPath'], + props: { + pipelineIid: { + required: true, + type: Number, + }, + pipelinePath: { + required: true, + type: String, + }, + }, + data() { + return { + failedJobs: [], + isExpanded: false, + }; + }, + apollo: { + failedJobs: { + query: getPipelineFailedJobs, + skip() { + return !this.isExpanded; + }, + variables() { + return { + fullPath: this.fullPath, + pipelineIid: this.pipelineIid, + }; + }, + update(data) { + const jobs = data?.project?.pipeline?.jobs?.nodes || []; + return sortJobsByStatus(jobs); + }, + error(e) { + createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); + }, + }, + }, + computed: { + bodyClasses() { + return this.isExpanded ? '' : 'gl-display-none'; + }, + failedJobsCount() { + return this.failedJobs.length; + }, + iconName() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + isLoading() { + return this.$apollo.queries.failedJobs.loading; + }, + }, + methods: { + toggleWidget() { + this.isExpanded = !this.isExpanded; + }, + }, + columns: [ + { text: JOB_NAME_HEADER, class: 'col-6' }, + { text: STAGE_HEADER, class: 'col-2' }, + { text: JOB_ID_HEADER, class: 'col-2' }, + ], + i18n: { + additionalInfoPopover: s__( + 'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.', + ), + additionalInfoTitle: __('Limitation on this view'), + fetchError: __('There was a problem fetching failed jobs'), + showFailedJobs: __('Show failed jobs'), + }, +}; +</script> +<template> + <div class="gl-border-none!"> + <gl-button variant="link" @click="toggleWidget"> + <gl-icon :name="iconName" /> + {{ $options.i18n.showFailedJobs }} + <gl-icon id="target" name="information-o" /> + <gl-popover target="target" placement="top"> + <template #title> {{ $options.i18n.additionalInfoTitle }} </template> + <slot> + <gl-sprintf :message="$options.i18n.additionalInfoPopover"> + <template #link="{ content }"> + <gl-link class="gl-font-sm" :href="pipelinePath"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </slot> + </gl-popover> + </gl-button> + <gl-loading-icon v-if="isLoading" /> + <gl-collapse + v-else + v-model="isExpanded" + class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3" + > + <div class="container-fluid gl-grid-tpl-rows-auto"> + <div class="row gl-mb-6 gl-text-gray-900"> + <div + v-for="col in $options.columns" + :key="col.text" + class="gl-font-weight-bold gl-text-left" + :class="col.class" + data-testid="header" + > + {{ col.text }} + </div> + </div> + </div> + <widget-failed-job-row v-for="job in failedJobs" :key="job.id" :job="job" /> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js new file mode 100644 index 00000000000..3f395fff7e0 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js @@ -0,0 +1,15 @@ +export const isFailedJob = (job = {}) => { + return job?.detailedStatus?.group === 'failed' || false; +}; + +export const sortJobsByStatus = (jobs = []) => { + const newJobs = [...jobs]; + + return newJobs.sort((a) => { + if (isFailedJob(a)) { + return -1; + } + + return 1; + }); +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue new file mode 100644 index 00000000000..e40e30f2b8d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue @@ -0,0 +1,107 @@ +<script> +import { GlCollapse, GlIcon, GlLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + components: { + CiIcon, + GlCollapse, + GlIcon, + GlLink, + }, + directives: { + SafeHtml, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + data() { + return { + isJobLogVisible: false, + isHovered: false, + }; + }, + computed: { + activeClass() { + return this.isHovered ? 'gl-bg-gray-50' : ''; + }, + isVisibleId() { + return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`; + }, + jobChevronName() { + return this.isJobLogVisible ? 'chevron-down' : 'chevron-right'; + }, + jobTrace() { + return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText; + }, + parsedJobId() { + return getIdFromGraphQLId(this.job.id); + }, + tooltipText() { + return sprintf(this.$options.i18n.jobActionTooltipText, { jobName: this.job.name }); + }, + }, + methods: { + setActiveRow() { + this.isHovered = true; + }, + resetActiveRow() { + this.isHovered = false; + }, + toggleJobLog(e) { + // Do not toggle the log visibility when clicking on a link + if (e.target.tagName === 'A') { + return; + } + + this.isJobLogVisible = !this.isJobLogVisible; + }, + }, + i18n: { + jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'), + noTraceText: s__('Job|No job log'), + }, +}; +</script> +<template> + <div class="container-fluid gl-grid-tpl-rows-auto"> + <div + class="row gl-py-4 gl-cursor-pointer gl-display-flex gl-align-items-center" + :class="activeClass" + :aria-pressed="isJobLogVisible" + role="button" + tabindex="0" + data-testid="widget-row" + @click="toggleJobLog" + @keyup.enter="toggleJobLog" + @keyup.space="toggleJobLog" + @mouseover="setActiveRow" + @mouseout="resetActiveRow" + > + <div class="col-6 gl-text-gray-900 gl-font-weight-bold gl-text-left"> + <gl-icon :name="jobChevronName" class="gl-fill-blue-500" /> + <ci-icon :status="job.detailedStatus" /> + {{ job.name }} + </div> + <div class="col-2 gl-text-left">{{ job.stage.name }}</div> + <div class="col-2 gl-text-left"> + <gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link> + </div> + </div> + <div class="row"> + <gl-collapse :visible="isJobLogVisible" class="gl-w-full"> + <pre + v-safe-html="jobTrace" + class="gl-bg-gray-900 gl-text-white" + :data-testid="isVisibleId" + ></pre> + </gl-collapse> + </div> + </div> +</template> 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 7ad12d397e5..ff1a01d5037 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -32,6 +32,11 @@ export default { type: String, required: true, }, + refClass: { + type: String, + required: false, + default: '', + }, }, computed: { mergeRequestRef() { @@ -134,7 +139,12 @@ export default { :title="pipelineName" class="gl-flex-grow-1 gl-text-truncate gl-text-gray-900" > - {{ pipelineName }} + <gl-link + :href="pipeline.path" + class="gl-text-blue-600!" + data-testid="pipeline-url-link" + >{{ pipelineName }}</gl-link + > </tooltip-on-truncate> </span> </div> @@ -144,7 +154,7 @@ export default { <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate"> <gl-link :href="commitUrl" - class="commit-row-message gl-font-weight-bold gl-text-gray-900" + class="commit-row-message gl-text-blue-600!" data-testid="commit-title" @click="trackClick('click_commit_title')" >{{ commitTitle }}</gl-link @@ -158,53 +168,61 @@ export default { <div class="gl-mb-2"> <gl-link :href="pipeline.path" - class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3" + class="gl-mr-1 gl-text-blue-500!" data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" @click="trackClick('click_pipeline_id')" >#{{ pipeline[pipelineKey] }}</gl-link > <!--Commit row--> - <div class="icon-container gl-display-inline-block gl-mr-1"> + <div class="gl-display-inline-flex gl-rounded-base gl-px-2 gl-bg-gray-50 gl-text-gray-700"> + <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> + <gl-icon + v-gl-tooltip + :name="commitIcon" + :title="commitIconTooltipTitle" + :size="12" + data-testid="commit-icon-type" + /> + <gl-link + v-if="mergeRequestRef" + :href="mergeRequestRef.path" + class="gl-font-sm gl-font-monospace gl-text-gray-700! gl-hover-text-gray-900!" + :class="refClass" + data-testid="merge-request-ref" + @click="trackClick('click_mr_ref')" + >{{ mergeRequestRef.iid }}</gl-link + > + <gl-link + v-else + :href="refUrl" + class="gl-font-sm gl-font-monospace gl-text-gray-700! gl-hover-text-gray-900!" + :class="refClass" + data-testid="commit-ref-name" + @click="trackClick('click_commit_name')" + >{{ commitRef.name }}</gl-link + > + </tooltip-on-truncate> + </div> + <div + class="gl-display-inline-block gl-rounded-base gl-font-sm gl-px-2 gl-bg-gray-50 gl-text-black-normal" + > <gl-icon v-gl-tooltip - :name="commitIcon" - :title="commitIconTooltipTitle" - data-testid="commit-icon-type" + name="commit" + class="commit-icon gl-mr-1" + :title="__('Commit')" + :size="12" + data-testid="commit-icon" /> - </div> - <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> - <gl-link - v-if="mergeRequestRef" - :href="mergeRequestRef.path" - class="ref-name gl-mr-3" - data-testid="merge-request-ref" - @click="trackClick('click_mr_ref')" - >{{ mergeRequestRef.iid }}</gl-link - > <gl-link - v-else - :href="refUrl" - class="ref-name gl-mr-3" - data-testid="commit-ref-name" - @click="trackClick('click_commit_name')" - >{{ commitRef.name }}</gl-link + :href="commitUrl" + class="gl-font-sm gl-font-monospace gl-mr-0 gl-text-gray-700!" + data-testid="commit-short-sha" + @click="trackClick('click_commit_sha')" + >{{ commitShortSha }}</gl-link > - </tooltip-on-truncate> - <gl-icon - v-gl-tooltip - name="commit" - class="commit-icon gl-mr-1" - :title="__('Commit')" - data-testid="commit-icon" - /> - <gl-link - :href="commitUrl" - class="commit-sha mr-0" - data-testid="commit-short-sha" - @click="trackClick('click_commit_sha')" - >{{ commitShortSha }}</gl-link - > + </div> <user-avatar-link v-if="commitAuthor" :link-href="commitAuthor.path" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index bcbf655a737..7d41700c492 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,12 +1,15 @@ <script> import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { isEqual } from 'lodash'; +import * as Sentry from '@sentry/browser'; import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, @@ -113,6 +116,11 @@ export default { required: false, default: null, }, + defaultVisibilityPipelineIdType: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -123,7 +131,7 @@ export default { page: getParameterByName('page') || '1', requestData: {}, isResetCacheButtonLoading: false, - selectedPipelineKeyOption: this.$options.PipelineKeyOptions[0], + visibilityPipelineIdType: this.defaultVisibilityPipelineIdType, }; }, stateMap: { @@ -232,6 +240,12 @@ export default { validatedParams() { return validateParams(this.params); }, + selectedPipelineKeyOption() { + return ( + this.$options.PipelineKeyOptions.find((e) => this.visibilityPipelineIdType === e.value) || + this.$options.PipelineKeyOptions[0] + ); + }, }, created() { this.service = new PipelinesService(this.endpoint); @@ -317,8 +331,26 @@ export default { this.updateContent({ ...this.requestData, page: '1' }); }, - changeVisibilityPipelineID(val) { - this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value); + changeVisibilityPipelineIDType(idType) { + this.visibilityPipelineIdType = idType; + this.saveVisibilityPipelineIDType(idType); + }, + saveVisibilityPipelineIDType(idType) { + if (!isLoggedIn()) return; + + this.$apollo + .mutate({ + mutation: setSortPreferenceMutation, + variables: { input: { visibilityPipelineIdType: idType.toUpperCase() } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); }, }, }; @@ -359,10 +391,11 @@ export default { @filterPipelines="filterPipelines" /> <gl-collapsible-listbox + v-model="visibilityPipelineIdType" data-testid="pipeline-key-collapsible-box" :toggle-text="selectedPipelineKeyOption.text" :items="$options.PipelineKeyOptions" - @select="changeVisibilityPipelineID" + @select="changeVisibilityPipelineIDType" /> </div> </div> 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 b2da0df17c0..d884935d95b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -2,8 +2,10 @@ import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; import PipelineOperations from './pipeline_operations.vue'; @@ -12,7 +14,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelineUrl from './pipeline_url.vue'; 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!'; @@ -20,6 +21,7 @@ const DEFAULT_TH_CLASSES = export default { components: { GlTableLite, + PipelineFailedJobsWidget, PipelineMiniGraph, PipelineOperations, PipelinesStatusBadge, @@ -27,51 +29,15 @@ export default { PipelineTriggerer, PipelineUrl, }, - tableFields: [ - { - key: 'status', - label: s__('Pipeline|Status'), - thClass: DEFAULT_TH_CLASSES, - columnClass: 'gl-w-15p', - tdClass: DEFAULT_TD_CLASS, - thAttr: { 'data-testid': 'status-th' }, - }, - { - key: 'pipeline', - label: __('Pipeline'), - thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS}`, - columnClass: 'gl-w-30p', - thAttr: { 'data-testid': 'pipeline-th' }, - }, - { - key: 'triggerer', - label: s__('Pipeline|Triggerer'), - thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', - thAttr: { 'data-testid': 'triggerer-th' }, - }, - { - key: 'stages', - label: s__('Pipeline|Stages'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-quarter', - thAttr: { 'data-testid': 'stages-th' }, - }, - { - key: 'actions', - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', - thAttr: { 'data-testid': 'actions-th' }, - }, - ], directives: { GlTooltip: GlTooltipDirective, }, - mixins: [Tracking.mixin()], + mixins: [Tracking.mixin(), glFeatureFlagMixin()], + inject: { + withFailedJobsDetails: { + default: false, + }, + }, props: { pipelines: { type: Array, @@ -104,6 +70,63 @@ export default { cancelingPipeline: null, }; }, + computed: { + tableFields() { + return [ + { + key: 'status', + label: s__('Pipeline|Status'), + thClass: DEFAULT_TH_CLASSES, + columnClass: 'gl-w-15p', + tdClass: this.tdClasses, + thAttr: { 'data-testid': 'status-th' }, + }, + { + key: 'pipeline', + label: __('Pipeline'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${this.tdClasses}`, + columnClass: 'gl-w-30p', + thAttr: { 'data-testid': 'pipeline-th' }, + }, + { + key: 'triggerer', + label: s__('Pipeline|Triggerer'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${this.tdClasses} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'triggerer-th' }, + }, + { + key: 'stages', + label: s__('Pipeline|Stages'), + thClass: DEFAULT_TH_CLASSES, + tdClass: this.tdClasses, + columnClass: 'gl-w-quarter', + thAttr: { 'data-testid': 'stages-th' }, + }, + { + key: 'actions', + thClass: DEFAULT_TH_CLASSES, + tdClass: this.tdClasses, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'actions-th' }, + }, + ]; + }, + tdClasses() { + return this.withFailedJobsDetails ? 'gl-pb-0! gl-border-none!' : 'pl-p-5!'; + }, + pipelinesWithDetails() { + if (this.withFailedJobsDetails) { + return this.pipelines.map((p) => { + return { ...p, _showDetails: true }; + }); + } + + return this.pipelines; + }, + }, watch: { pipelines() { this.cancelingPipeline = null; @@ -120,11 +143,17 @@ export default { const downstream = pipeline.triggered; return keepLatestDownstreamPipelines(downstream); }, + hasFailedJobs(pipeline) { + return pipeline?.failed_builds?.length > 0 || false; + }, setModalData(data) { this.pipelineId = data.pipeline.id; this.pipeline = data.pipeline; this.endpoint = data.endpoint; }, + showFailedJobsWidget(item) { + return this.glFeatures.ciJobFailuresInMr && this.hasFailedJobs(item); + }, onSubmit() { eventHub.$emit('postAction', this.endpoint); this.cancelingPipeline = this.pipelineId; @@ -142,9 +171,8 @@ export default { <template> <div class="ci-table"> <gl-table-lite - :fields="$options.tableFields" - :items="pipelines" - tbody-tr-class="commit" + :fields="tableFields" + :items="pipelinesWithDetails" :tbody-tr-attr="$options.TBODY_TR_ATTR" stacked="lg" fixed @@ -167,6 +195,7 @@ export default { :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" :pipeline-key="pipelineKeyOption.value" + ref-color="gl-text-black-normal" /> </template> @@ -188,6 +217,14 @@ export default { <template #cell(actions)="{ item }"> <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" /> </template> + + <template #row-details="{ item }"> + <pipeline-failed-jobs-widget + v-if="showFailedJobsWidget(item)" + :pipeline-iid="item.iid" + :pipeline-path="item.path" + /> + </template> </gl-table-lite> <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" /> 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 d068eb16ed4..bdecbb88a58 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -14,6 +14,17 @@ export default { type: Object, required: true, }, + displayCalendarIcon: { + type: Boolean, + required: false, + default: true, + }, + fontSize: { + type: String, + required: false, + default: 'gl-font-sm', + validator: (fontSize) => ['gl-font-sm', 'gl-font-md'].includes(fontSize), + }, }, computed: { duration() { @@ -23,47 +34,29 @@ export default { return formatTime(this.duration * 1000); }, finishedTime() { - return this.pipeline?.details?.finished_at; - }, - showInProgress() { - return !this.duration && !this.finishedTime && !this.skipped; - }, - showSkipped() { - return !this.duration && !this.finishedTime && this.skipped; - }, - skipped() { - return this.pipeline?.details?.status?.label === 'skipped'; - }, - stuck() { - return this.pipeline.flags.stuck; + return this.pipeline?.details?.finished_at || this.pipeline?.finishedAt; }, }, }; </script> <template> - <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" - data-testid="pipeline-in-progress" - > - <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" /> - <gl-icon v-else name="hourglass" class="gl-mr-2" :size="12" data-testid="hourglass-icon" /> - {{ s__('Pipeline|In progress') }} - </span> - - <span v-if="showSkipped" data-testid="pipeline-skipped"> - <gl-icon name="status_skipped_borderless" /> - {{ s__('Pipeline|Skipped') }} - </span> - + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-flex-end gl-lg-align-items-flex-start" + :class="fontSize" + > <p v-if="duration" class="duration gl-display-inline-flex gl-align-items-center"> <gl-icon name="timer" class="gl-mr-2" :size="12" /> {{ durationFormatted }} </p> <p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center"> - <gl-icon name="calendar" class="gl-mr-2" :size="12" /> + <gl-icon + v-if="displayCalendarIcon" + name="calendar" + class="gl-mr-2" + :size="12" + data-testid="calendar-icon" + /> <time v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d092c3ca630..a6dd835bb15 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -110,3 +110,7 @@ export const TRACKING_CATEGORIES = { tabs: 'pipelines_filter_tabs', search: 'pipelines_filtered_search', }; + +// Pipeline Mini Graph + +export const PIPELINE_MINI_GRAPH_POLL_INTERVAL = 5000; diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql index 5bdafa15f72..c1f994ece24 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql @@ -3,7 +3,7 @@ query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) { id pipeline(iid: $pipelineIid) { id - jobs(statuses: FAILED, retried: false) { + jobs(statuses: FAILED, retried: false, jobKind: BUILD) { nodes { status detailedStatus { diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql index 9257cc7de7b..9257cc7de7b 100644 --- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql new file mode 100644 index 00000000000..2c842f1ac77 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql @@ -0,0 +1,34 @@ +query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + jobs(statuses: [FAILED], retried: false, jobKind: BUILD) { + nodes { + id + allowFailure + detailedStatus { + id + group + icon + action { + id + path + icon + } + } + name + retried + stage { + id + name + } + trace { + htmlSummary + } + webPath + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index 47bc167ca52..eb5643126a2 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -32,6 +32,15 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { emoji } } + commit { + id + shortId + title + webPath + } + finishedAt + queuedDuration + duration } } } diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql index 69a29947b16..69a29947b16 100644 --- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 61847affa1f..5b9bfd53b13 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,27 +2,33 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { pipelineTabName } from './constants'; -import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineHeaderApp, createPipelineDetailsHeaderApp } from './pipeline_details_header'; import { apolloProvider } from './pipeline_shared_client'; const SELECTORS = { PIPELINE_HEADER: '#js-pipeline-header-vue', + PIPELINE_DETAILS_HEADER: '#js-pipeline-details-header-vue', PIPELINE_TABS: '#js-pipeline-tabs', }; -export default async function initPipelineDetailsBundle() { - const { dataset: headerDataset } = document.querySelector(SELECTORS.PIPELINE_HEADER); - - try { - createPipelineHeaderApp( - SELECTORS.PIPELINE_HEADER, - apolloProvider, - headerDataset.graphqlResourceEtag, - ); - } catch { - createAlert({ - message: __('An error occurred while loading a section of this page.'), - }); +export default async function initPipelineDetailsBundle(flagEnabled) { + const headerSelector = flagEnabled + ? SELECTORS.PIPELINE_DETAILS_HEADER + : SELECTORS.PIPELINE_HEADER; + const headerApp = flagEnabled ? createPipelineDetailsHeaderApp : createPipelineHeaderApp; + + const headerEl = document.querySelector(headerSelector); + + if (headerEl) { + const { dataset: headerDataset } = headerEl; + + try { + headerApp(headerSelector, apolloProvider, headerDataset.graphqlResourceEtag); + } catch { + createAlert({ + message: __('An error occurred while loading a section of this page.'), + }); + } } const tabsEl = document.querySelector(SELECTORS.PIPELINE_TABS); diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index c9e60756407..807ef225edd 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; import PipelineHeader from './components/header_component.vue'; +import PipelineDetailsHeader from './components/pipeline_details_header.vue'; Vue.use(VueApollo); @@ -33,3 +35,72 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou }, }); }; + +export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { + fullPath, + pipelineIid, + pipelinesPath, + name, + totalJobs, + computeCredits, + yamlErrors, + failureReason, + triggeredByPath, + schedule, + child, + latest, + mergeTrainPipeline, + invalid, + failed, + autoDevops, + detached, + stuck, + refText, + } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'PipelineDetailsHeaderApp', + apolloProvider, + provide: { + paths: { + fullProject: fullPath, + graphqlResourceEtag, + pipelinesPath, + triggeredByPath, + }, + pipelineIid, + }, + render(createElement) { + return createElement(PipelineDetailsHeader, { + props: { + name, + totalJobs, + computeCredits, + yamlErrors, + failureReason, + refText, + badges: { + schedule: parseBoolean(schedule), + child: parseBoolean(child), + latest: parseBoolean(latest), + mergeTrainPipeline: parseBoolean(mergeTrainPipeline), + invalid: parseBoolean(invalid), + failed: parseBoolean(failed), + autoDevops: parseBoolean(autoDevops), + detached: parseBoolean(detached), + stuck: parseBoolean(stuck), + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 49e2e1644e2..20fd0915e28 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -48,6 +48,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { iosRunnersAvailable, registrationToken, fullPath, + visibilityPipelineIdType, } = el.dataset; return new Vue({ @@ -91,6 +92,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { defaultBranchName, params: JSON.parse(params), registrationToken, + defaultVisibilityPipelineIdType: visibilityPipelineIdType, }, }); }, diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue new file mode 100644 index 00000000000..7bab8a1c30d --- /dev/null +++ b/app/assets/javascripts/profile/components/follow.vue @@ -0,0 +1,88 @@ +<script> +import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { DEFAULT_PER_PAGE } from '~/api'; +import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; + +export default { + i18n: { + prev: PREV, + next: NEXT, + }, + components: { + GlAvatarLabeled, + GlAvatarLink, + GlLoadingIcon, + GlPagination, + }, + props: { + /** + * Expected format: + * + * { + * avatar_url: string; + * id: number; + * name: string; + * state: string; + * username: string; + * web_url: string; + * }[] + */ + users: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + page: { + type: Number, + required: true, + }, + totalItems: { + type: Number, + required: true, + }, + perPage: { + type: Number, + required: false, + default: DEFAULT_PER_PAGE, + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <div v-else> + <div class="gl-my-n3 gl-mx-n3 gl-display-flex gl-flex-wrap"> + <div v-for="user in users" :key="user.id" class="gl-p-3 gl-w-full gl-md-w-half gl-lg-w-25p"> + <gl-avatar-link + :href="user.web_url" + class="js-user-link gl-border gl-rounded-base gl-w-full gl-p-5" + :data-user-id="user.id" + :data-username="user.username" + > + <gl-avatar-labeled + :src="user.avatar_url" + :size="48" + :entity-id="user.id" + :entity-name="user.name" + :label="user.name" + :sub-label="user.username" + /> + </gl-avatar-link> + </div> + </div> + <gl-pagination + align="center" + class="gl-mt-5" + :value="page" + :total-items="totalItems" + :per-page="perPage" + :prev-text="$options.i18n.prev" + :next-text="$options.i18n.next" + @input="$emit('pagination-input', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue index 5b69f835294..1fa579bc611 100644 --- a/app/assets/javascripts/profile/components/followers_tab.vue +++ b/app/assets/javascripts/profile/components/followers_tab.vue @@ -1,16 +1,59 @@ <script> import { GlBadge, GlTab } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getUserFollowers } from '~/rest_api'; +import { createAlert } from '~/alert'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import Follow from './follow.vue'; export default { i18n: { title: s__('UserProfile|Followers'), + errorMessage: s__( + 'UserProfile|An error occurred loading the followers. Please refresh the page to try again.', + ), }, components: { GlBadge, GlTab, + Follow, + }, + inject: ['followersCount', 'userId'], + data() { + return { + followers: [], + loading: true, + totalItems: 0, + page: 1, + }; + }, + watch: { + page: { + async handler() { + this.loading = true; + + try { + const { data: followers, headers } = await getUserFollowers(this.userId, { + page: this.page, + }); + const { total } = parseIntPagination(normalizeHeaders(headers)); + + this.followers = followers; + this.totalItems = total; + } catch (error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + } finally { + this.loading = false; + } + }, + immediate: true, + }, + }, + methods: { + onPaginationInput(page) { + this.page = page; + }, }, - inject: ['followers'], }; </script> @@ -18,7 +61,14 @@ export default { <gl-tab> <template #title> <span>{{ $options.i18n.title }}</span> - <gl-badge size="sm" class="gl-ml-2">{{ followers }}</gl-badge> + <gl-badge size="sm" class="gl-ml-2">{{ followersCount }}</gl-badge> </template> + <follow + :users="followers" + :loading="loading" + :page="page" + :total-items="totalItems" + @pagination-input="onPaginationInput" + /> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue index d39d15a08f3..8ee878e3dcc 100644 --- a/app/assets/javascripts/profile/components/following_tab.vue +++ b/app/assets/javascripts/profile/components/following_tab.vue @@ -10,7 +10,7 @@ export default { GlBadge, GlTab, }, - inject: ['followees'], + inject: ['followeesCount'], }; </script> @@ -18,7 +18,7 @@ export default { <gl-tab> <template #title> <span>{{ $options.i18n.title }}</span> - <gl-badge size="sm" class="gl-ml-2">{{ followees }}</gl-badge> + <gl-badge size="sm" class="gl-ml-2">{{ followeesCount }}</gl-badge> </template> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql b/app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql new file mode 100644 index 00000000000..ec743b8747f --- /dev/null +++ b/app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql @@ -0,0 +1,39 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getUserSnippets( + $id: UserID! + $first: Int + $last: Int + $afterToken: String + $beforeToken: String +) { + user(id: $id) { + id + avatarUrl + name + username + snippets(first: $first, last: $last, before: $beforeToken, after: $afterToken) { + pageInfo { + ...PageInfo + } + nodes { + id + title + webUrl + visibilityLevel + createdAt + updatedAt + blobs { + nodes { + name + } + } + notes { + nodes { + id + } + } + } + } + } +} diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue index 21f8a2d3500..8cfa3fb3eea 100644 --- a/app/assets/javascripts/profile/components/overview_tab.vue +++ b/app/assets/javascripts/profile/components/overview_tab.vue @@ -1,16 +1,24 @@ <script> import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { createAlert } from '~/alert'; import { s__ } from '~/locale'; import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import ContributionEvents from '~/contribution_events/components/contribution_events.vue'; import ActivityCalendar from './activity_calendar.vue'; export default { i18n: { title: s__('UserProfile|Overview'), personalProjects: s__('UserProfile|Personal projects'), + activity: s__('UserProfile|Activity'), viewAll: s__('UserProfile|View all'), + eventsErrorMessage: s__( + 'UserProfile|An error occurred loading the activity. Please refresh the page to try again.', + ), }, - components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList }, + components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList, ContributionEvents }, + inject: ['userActivityPath'], props: { personalProjects: { type: Array, @@ -21,15 +29,44 @@ export default { required: true, }, }, + data() { + return { + events: [], + eventsLoading: false, + }; + }, + async mounted() { + this.eventsLoading = true; + + try { + const { data: events } = await axios.get(this.userActivityPath, { + params: { limit: 10 }, + }); + this.events = events; + } catch (error) { + createAlert({ message: this.$options.i18n.eventsErrorMessage, error, captureError: true }); + } finally { + this.eventsLoading = false; + } + }, }; </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-mx-n5 gl-display-flex gl-flex-wrap"> + <div class="gl-px-5 gl-w-full gl-lg-w-half" data-testid="activity-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.activity }}</h4> + <gl-link href="">{{ $options.i18n.viewAll }}</gl-link> + </div> + <gl-loading-icon v-if="eventsLoading" class="gl-mt-5" size="md" /> + <contribution-events v-else :events="events" /> + </div> + <div class="gl-px-5 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" > diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue index 8e52a98803d..3a30c3bdc9b 100644 --- a/app/assets/javascripts/profile/components/profile_tabs.vue +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -11,7 +11,7 @@ import GroupsTab from './groups_tab.vue'; import ContributedProjectsTab from './contributed_projects_tab.vue'; import PersonalProjectsTab from './personal_projects_tab.vue'; import StarredProjectsTab from './starred_projects_tab.vue'; -import SnippetsTab from './snippets_tab.vue'; +import SnippetsTab from './snippets/snippets_tab.vue'; import FollowersTab from './followers_tab.vue'; import FollowingTab from './following_tab.vue'; @@ -91,7 +91,7 @@ export default { </script> <template> - <gl-tabs nav-class="gl-bg-gray-10" align="center"> + <gl-tabs nav-class="gl-bg-gray-10" content-class="gl-bg-white gl-pt-5" align="center"> <component :is="component" v-for="{ key, component } in $options.tabs" diff --git a/app/assets/javascripts/profile/components/snippets/snippet_row.vue b/app/assets/javascripts/profile/components/snippets/snippet_row.vue new file mode 100644 index 00000000000..19e0e9dc7fd --- /dev/null +++ b/app/assets/javascripts/profile/components/snippets/snippet_row.vue @@ -0,0 +1,119 @@ +<script> +import { GlAvatar, GlLink, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__, sprintf, n__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { SNIPPET_VISIBILITY } from '~/snippets/constants'; + +export default { + name: 'SnippetRow', + i18n: { + snippetInfo: s__('UserProfile|%{id} · created %{created} by %{author}'), + updatedInfo: s__('UserProfile|updated %{updated}'), + blobTooltip: s__('UserProfile|%{count} %{file}'), + }, + components: { + GlAvatar, + GlLink, + GlSprintf, + GlIcon, + TimeAgo, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + snippet: { + type: Object, + required: true, + }, + userInfo: { + type: Object, + required: true, + }, + }, + computed: { + formattedId() { + return `$${getIdFromGraphQLId(this.snippet.id)}`; + }, + profilePath() { + return `${gon.relative_url_root || ''}/${this.userInfo.username}`; + }, + blobCount() { + return this.snippet.blobs?.nodes?.length || 0; + }, + commentsCount() { + return this.snippet.notes?.nodes?.length || 0; + }, + visibilityIcon() { + return SNIPPET_VISIBILITY[this.snippet.visibilityLevel]?.icon; + }, + blobTooltip() { + return sprintf(this.$options.i18n.blobTooltip, { + count: this.blobCount, + file: n__('file', 'files', this.blobCount), + }); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-py-5"> + <gl-avatar :size="48" :src="userInfo.avatarUrl" class="gl-mr-3" /> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-flex-start"> + <gl-link + data-testid="snippet-url" + :href="snippet.webUrl" + class="gl-text-gray-900 gl-font-weight-bold gl-mb-2" + >{{ snippet.title }}</gl-link + > + <span class="gl-text-gray-500"> + <gl-sprintf :message="$options.i18n.snippetInfo"> + <template #id> + <span data-testid="snippet-id">{{ formattedId }}</span> + </template> + <template #created> + <time-ago data-testid="snippet-created-at" :time="snippet.createdAt" /> + </template> + <template #author> + <gl-link data-testid="snippet-author" :href="profilePath" class="gl-text-gray-900">{{ + userInfo.name + }}</gl-link> + </template> + </gl-sprintf> + </span> + </div> + <div class="gl-ml-auto gl-display-flex gl-flex-direction-column gl-align-items-flex-end"> + <div class="gl-display-flex gl-align-items-center gl-mb-2"> + <span + v-gl-tooltip + data-testid="snippet-blob" + :title="blobTooltip" + class="gl-mr-4" + :class="{ 'gl-opacity-5': blobCount === 0 }" + > + <gl-icon name="documents" /> + <span>{{ blobCount }}</span> + </span> + <gl-link + data-testid="snippet-comments" + :href="`${snippet.webUrl}#notes`" + class="gl-mr-4 gl-text-gray-900" + :class="{ 'gl-opacity-5': commentsCount === 0 }" + > + <gl-icon name="comments" /> + <span>{{ commentsCount }}</span> + </gl-link> + <gl-icon data-testid="snippet-visibility" :name="visibilityIcon" /> + </div> + <span class="gl-text-gray-500"> + <gl-sprintf :message="$options.i18n.updatedInfo"> + <template #updated> + <time-ago data-testid="snippet-updated-at" :time="snippet.updatedAt" /> + </template> + </gl-sprintf> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue new file mode 100644 index 00000000000..fce5e2f5e78 --- /dev/null +++ b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue @@ -0,0 +1,110 @@ +<script> +import { GlTab, GlKeysetPagination, GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; +import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants'; +import getUserSnippets from '../graphql/get_user_snippets.query.graphql'; +import SnippetRow from './snippet_row.vue'; + +export default { + name: 'SnippetsTab', + i18n: { + title: s__('UserProfile|Snippets'), + noSnippets: s__('UserProfiles|No snippets found.'), + }, + components: { + GlTab, + GlKeysetPagination, + GlEmptyState, + SnippetRow, + }, + inject: ['userId', 'snippetsEmptyState'], + data() { + return { + userInfo: {}, + pageInfo: {}, + cursor: { + first: SNIPPET_MAX_LIST_COUNT, + last: null, + }, + }; + }, + apollo: { + userSnippets: { + query: getUserSnippets, + variables() { + return { + id: convertToGraphQLId(TYPENAME_USER, this.userId), + ...this.cursor, + }; + }, + update(data) { + this.userInfo = { + avatarUrl: data.user?.avatarUrl, + name: data.user?.name, + username: data.user?.username, + }; + this.pageInfo = data?.user?.snippets?.pageInfo; + return data?.user?.snippets?.nodes || []; + }, + error() { + return []; + }, + }, + }, + computed: { + hasSnippets() { + return this.userSnippets?.length; + }, + }, + methods: { + isLastSnippet(index) { + return index === this.userSnippets.length - 1; + }, + nextPage() { + this.cursor = { + first: SNIPPET_MAX_LIST_COUNT, + last: null, + afterToken: this.pageInfo.endCursor, + }; + }, + prevPage() { + this.cursor = { + first: null, + last: SNIPPET_MAX_LIST_COUNT, + beforeToken: this.pageInfo.startCursor, + }; + }, + }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <template v-if="hasSnippets"> + <snippet-row + v-for="(snippet, index) in userSnippets" + :key="snippet.id" + :snippet="snippet" + :user-info="userInfo" + :class="{ 'gl-border-b': !isLastSnippet(index) }" + /> + <div class="gl-display-flex gl-justify-content-center gl-mt-6"> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + @prev="prevPage" + @next="nextPage" + /> + </div> + </template> + <template v-if="!hasSnippets"> + <gl-empty-state class="gl-mt-5" :svg-height="75" :svg-path="snippetsEmptyState"> + <template #title> + <p class="gl-font-weight-bold gl-mt-n5">{{ $options.i18n.noSnippets }}</p> + </template> + </gl-empty-state> + </template> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets_tab.vue deleted file mode 100644 index d64c5b900a5..00000000000 --- a/app/assets/javascripts/profile/components/snippets_tab.vue +++ /dev/null @@ -1,17 +0,0 @@ -<script> -import { GlTab } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - i18n: { - title: s__('UserProfile|Snippets'), - }, - components: { GlTab }, -}; -</script> - -<template> - <gl-tab :title="$options.i18n.title"> - <!-- placeholder --> - </gl-tab> -</template> diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue index fd42b64f4c5..13a1b797a83 100644 --- a/app/assets/javascripts/profile/components/user_achievements.vue +++ b/app/assets/javascripts/profile/components/user_achievements.vue @@ -1,6 +1,7 @@ <script> -import { GlPopover, GlSprintf } from '@gitlab/ui'; -import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { GlAvatar, GlBadge, GlPopover, GlSprintf } from '@gitlab/ui'; +import { groupBy } from 'lodash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -8,7 +9,7 @@ import getUserAchievements from './graphql/get_user_achievements.query.graphql'; export default { name: 'UserAchievements', - components: { GlPopover, GlSprintf }, + components: { GlAvatar, GlBadge, GlPopover, GlSprintf }, mixins: [timeagoMixin], inject: ['rootUrl', 'userId'], apollo: { @@ -29,25 +30,39 @@ export default { }, methods: { processNodes(nodes) { - return nodes.slice(0, 3).map(({ achievement, createdAt, achievement: { namespace } }) => { - return { - id: `user-achievement-${getIdFromGraphQLId(achievement.id)}`, - name: achievement.name, - timeAgo: this.timeFormatted(createdAt), - avatarUrl: achievement.avatarUrl || gon.gitlab_logo, - description: achievement.description, - namespace: namespace && { - fullPath: namespace.fullPath, - webUrl: this.rootUrl + namespace.fullPath, - }, - }; - }); + return Object.entries(groupBy(nodes, 'achievement.id')) + .slice(0, 3) + .map(([id, values]) => { + const { + achievement: { name, avatarUrl, description, namespace }, + createdAt, + } = values[0]; + const count = values.length; + return { + id: `user-achievement-${id}`, + name, + timeAgo: this.timeFormatted(createdAt), + avatarUrl: avatarUrl || gon.gitlab_logo, + description, + namespace: namespace && { + fullPath: namespace.fullPath, + webUrl: this.rootUrl + namespace.fullPath, + }, + count, + }; + }); }, achievementAwardedMessage(userAchievement) { return userAchievement.namespace ? this.$options.i18n.awardedBy : this.$options.i18n.awardedByUnknownNamespace; }, + showCountBadge(count) { + return count > 1; + }, + getCountBadge(count) { + return `${count}x`; + }, }, i18n: { awardedBy: s__('Achievements|Awarded %{timeAgo} by %{namespace}'), @@ -61,18 +76,28 @@ export default { <div v-for="userAchievement in userAchievements" :key="userAchievement.id" - class="gl-display-inline-block" + class="gl-display-inline-block gl-vertical-align-top" data-testid="user-achievement" > - <img + <gl-avatar :id="userAchievement.id" :src="userAchievement.avatarUrl" - :alt="''" + :size="32" tabindex="0" - class="gl-avatar gl-avatar-s32 gl-mx-2" + shape="rect" + class="gl-mx-2" /> - <gl-popover triggers="hover focus" placement="top" :target="userAchievement.id"> - <div class="gl-font-weight-bold">{{ userAchievement.name }}</div> + <br /> + <gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{ + getCountBadge(userAchievement.count) + }}</gl-badge> + <gl-popover :target="userAchievement.id"> + <div> + <span class="gl-font-weight-bold">{{ userAchievement.name }}</span> + <gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{ + getCountBadge(userAchievement.count) + }}</gl-badge> + </div> <div> <gl-sprintf :message="achievementAwardedMessage(userAchievement)"> <template #timeAgo> diff --git a/app/assets/javascripts/profile/constants.js b/app/assets/javascripts/profile/constants.js index e19994c6784..9d3dcd648a8 100644 --- a/app/assets/javascripts/profile/constants.js +++ b/app/assets/javascripts/profile/constants.js @@ -5,3 +5,5 @@ export const CALENDAR_PERIOD_12_MONTHS = 12; * (see activity_calendar.js) */ export const OVERVIEW_CALENDAR_BREAKPOINT = 918; + +export const SNIPPET_MAX_LIST_COUNT = 20; diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue new file mode 100644 index 00000000000..ab29d94c41c --- /dev/null +++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue @@ -0,0 +1,10 @@ +<script> +export default {}; +</script> + +<template> + <!-- This is left empty intensionally --> + <!-- It will be implemented in the upcoming MRs --> + <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 --> + <div></div> +</template> diff --git a/app/assets/javascripts/profile/edit/index.js b/app/assets/javascripts/profile/edit/index.js new file mode 100644 index 00000000000..b46a395d6f5 --- /dev/null +++ b/app/assets/javascripts/profile/edit/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import ProfileEditApp from './components/profile_edit_app.vue'; + +export const initProfileEdit = () => { + const mountEl = document.querySelector('.js-user-profile'); + + if (!mountEl) return false; + + return new Vue({ + el: mountEl, + name: 'ProfileEditRoot', + render(createElement) { + return createElement(ProfileEditApp); + }, + }); +}; diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js index 101e52c873e..198ffdb434b 100644 --- a/app/assets/javascripts/profile/index.js +++ b/app/assets/javascripts/profile/index.js @@ -13,17 +13,32 @@ export const initProfileTabs = () => { if (!el) return false; - const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset; + const { + followeesCount, + followersCount, + userCalendarPath, + userActivityPath, + utcOffset, + userId, + snippetsEmptyState, + } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); return new Vue({ el, + apolloProvider, name: 'ProfileRoot', provide: { - followees: parseInt(followers, 10), - followers: parseInt(followees, 10), + followeesCount: parseInt(followeesCount, 10), + followersCount: parseInt(followersCount, 10), userCalendarPath, + userActivityPath, utcOffset, userId, + snippetsEmptyState, }, 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 a4edc988d67..7c00ce45b3a 100644 --- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue @@ -1,14 +1,17 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdown } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '../constants'; import eventHub from '../event_hub'; export default { + i18n: { + gitlabTag: s__('CreateTag|Tag'), + }, + components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlDropdownSectionHeader, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, }, inject: { newProjectTagPath: { @@ -43,66 +46,117 @@ export default { showDivider() { return this.canRevert || this.canCherryPick || this.canTag; }, + cherryPickItem() { + return { + text: s__('ChangeTypeAction|Cherry-pick'), + extraAttrs: { + 'data-testid': 'cherry-pick-link', + 'data-qa-selector': 'cherry_pick_button', + }, + action: () => this.showModal(OPEN_CHERRY_PICK_MODAL), + }; + }, + + revertLinkItem() { + return { + text: s__('ChangeTypeAction|Revert'), + extraAttrs: { + 'data-testid': 'revert-link', + 'data-qa-selector': 'revert_button', + }, + action: () => this.showModal(OPEN_REVERT_MODAL), + }; + }, + + tagLinkItem() { + return { + text: s__('CreateTag|Tag'), + href: this.newProjectTagPath, + extraAttrs: { + 'data-testid': 'tag-link', + }, + }; + }, + plainDiffItem() { + return { + text: s__('DownloadCommit|Plain Diff'), + href: this.plainDiffPath, + extraAttrs: { + download: '', + rel: 'nofollow', + 'data-testid': 'plain-diff-link', + 'data-qa-selector': 'plain_diff', + }, + }; + }, + patchesItem() { + return { + text: __('Patches'), + href: this.emailPatchesPath, + extraAttrs: { + download: '', + rel: 'nofollow', + 'data-testid': 'email-patches-link', + 'data-qa-selector': 'email_patches', + }, + }; + }, + + downloadsGroup() { + const items = []; + if (this.canEmailPatches) { + items.push(this.patchesItem); + } + items.push(this.plainDiffItem); + return { + name: __('Downloads'), + items, + }; + }, + + optionsGroup() { + const items = []; + if (this.canRevert) { + items.push(this.revertLinkItem); + } + if (this.canCherryPick) { + items.push(this.cherryPickItem); + } + if (this.canTag) { + items.push(this.tagLinkItem); + } + return { + items, + }; + }, }, + methods: { showModal(modalId) { eventHub.$emit(modalId); }, + closeDropdown() { + this.$refs.userDropdown.close(); + }, }, - openRevertModal: OPEN_REVERT_MODAL, - openCherryPickModal: OPEN_CHERRY_PICK_MODAL, }; </script> <template> - <gl-dropdown - :text="__('Options')" + <gl-disclosure-dropdown + ref="userDropdown" + :toggle-text="__('Options')" right data-testid="commit-options-dropdown" data-qa-selector="options_button" - class="gl-xs-w-full" + class="gl-xs-w-full gl-line-height-20" > - <gl-dropdown-item - v-if="canRevert" - data-testid="revert-link" - data-qa-selector="revert_button" - @click="showModal($options.openRevertModal)" - > - {{ s__('ChangeTypeAction|Revert') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="canCherryPick" - data-testid="cherry-pick-link" - data-qa-selector="cherry_pick_button" - @click="showModal($options.openCherryPickModal)" - > - {{ s__('ChangeTypeAction|Cherry-pick') }} - </gl-dropdown-item> - <gl-dropdown-item v-if="canTag" :href="newProjectTagPath" data-testid="tag-link"> - {{ s__('CreateTag|Tag') }} - </gl-dropdown-item> - <gl-dropdown-divider v-if="showDivider" /> - <gl-dropdown-section-header> - {{ __('Download') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-if="canEmailPatches" - :href="emailPatchesPath" - download - rel="nofollow" - data-testid="email-patches-link" - data-qa-selector="email_patches" - > - {{ __('Patches') }} - </gl-dropdown-item> - <gl-dropdown-item - :href="plainDiffPath" - download - rel="nofollow" - data-testid="plain-diff-link" - data-qa-selector="plain_diff" - > - {{ s__('DownloadCommit|Plain Diff') }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-group :group="optionsGroup" @action="closeDropdown" /> + + <gl-disclosure-dropdown-group + :bordered="showDivider" + :group="downloadsGroup" + @action="closeDropdown" + /> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 54d13ecc9c8..84e7edb48c1 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -7,10 +7,12 @@ import { toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; +import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; import { formatStages } from '../utils'; -import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql'; import { COMMIT_BOX_POLL_INTERVAL } from '../constants'; export default { @@ -21,8 +23,10 @@ export default { }, components: { GlLoadingIcon, + GraphqlPipelineMiniGraph, PipelineMiniGraph, }, + mixins: [glFeatureFlagsMixin()], inject: { fullPath: { default: '', @@ -47,15 +51,15 @@ export default { }, query: getLinkedPipelinesQuery, pollInterval: COMMIT_BOX_POLL_INTERVAL, + skip() { + return !this.fullPath || !this.iid || this.isUsingPipelineMiniGraphQueries; + }, variables() { return { fullPath: this.fullPath, iid: this.iid, }; }, - skip() { - return !this.fullPath || !this.iid; - }, update({ project }) { return project?.pipeline; }, @@ -69,6 +73,9 @@ export default { }, query: getPipelineStagesQuery, pollInterval: COMMIT_BOX_POLL_INTERVAL, + skip() { + return this.isUsingPipelineMiniGraphQueries; + }, variables() { return { fullPath: this.fullPath, @@ -95,6 +102,9 @@ export default { const downstream = this.pipeline?.downstream?.nodes; return keepLatestDownstreamPipelines(downstream); }, + isUsingPipelineMiniGraphQueries() { + return this.glFeatures.ciGraphqlPipelineMiniGraph; + }, pipelinePath() { return this.pipeline?.path ?? ''; }, @@ -128,13 +138,22 @@ export default { <template> <div> <gl-loading-icon v-if="$apollo.queries.pipeline.loading" /> - <pipeline-mini-graph - v-else - data-testid="commit-box-pipeline-mini-graph" - :downstream-pipelines="downstreamPipelines" - :pipeline-path="pipelinePath" - :stages="formattedStages" - :upstream-pipeline="upstreamPipeline" - /> + <template v-else> + <graphql-pipeline-mini-graph + v-if="isUsingPipelineMiniGraphQueries" + data-testid="commit-box-pipeline-mini-graph" + :pipeline-etag="graphqlResourceEtag" + :full-path="fullPath" + :iid="iid" + /> + <pipeline-mini-graph + v-else + data-testid="commit-box-pipeline-mini-graph" + :downstream-pipelines="downstreamPipelines" + :pipeline-path="pipelinePath" + :stages="formattedStages" + :upstream-pipeline="upstreamPipeline" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue new file mode 100644 index 00000000000..25af4cc8082 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue @@ -0,0 +1,134 @@ +<script> +import { createAlert } from '~/alert'; +import { joinPaths } from '~/lib/utils/url_utility'; +import commitReferencesQuery from '../graphql/queries/commit_references.query.graphql'; +import containingBranchesQuery from '../graphql/queries/commit_containing_branches.query.graphql'; +import containingTagsQuery from '../graphql/queries/commit_containing_tags.query.graphql'; +import { + BRANCHES, + TAGS, + FETCH_CONTAINING_REFS_EVENT, + FETCH_COMMIT_REFERENCES_ERROR, + BRANCHES_REF_TYPE, + TAGS_REF_TYPE, +} from '../constants'; +import RefsList from './refs_list.vue'; + +export default { + name: 'CommitRefs', + components: { + RefsList, + }, + inject: ['fullPath', 'commitSha'], + apollo: { + project: { + query: commitReferencesQuery, + variables() { + return this.queryVariables; + }, + update({ + project: { + commitReferences: { tippingTags, tippingBranches, containingBranches, containingTags }, + }, + }) { + this.tippingTags = tippingTags.names; + this.tippingBranches = tippingBranches.names; + this.hasContainingBranches = Boolean(containingBranches.names.length); + this.hasContainingTags = Boolean(containingTags.names.length); + }, + error() { + createAlert({ + message: this.$options.i18n.errorMessage, + captureError: true, + }); + }, + }, + }, + data() { + return { + containingTags: [], + containingBranches: [], + tippingTags: [], + tippingBranches: [], + hasContainingBranches: false, + hasContainingTags: false, + }; + }, + computed: { + hasBranches() { + return this.tippingBranches.length || this.hasContainingBranches; + }, + hasTags() { + return this.tippingTags.length || this.hasContainingTags; + }, + queryVariables() { + return { + fullPath: this.fullPath, + commitSha: this.commitSha, + }; + }, + commitsUrlPart() { + const urlPart = joinPaths(gon.relative_url_root || '', `/${this.fullPath}`, `/-/commits/`); + return urlPart; + }, + }, + methods: { + async fetchContainingRefs({ query, namespace }) { + try { + const { data } = await this.$apollo.query({ + query, + variables: this.queryVariables, + }); + this[namespace] = data.project.commitReferences[namespace].names; + return data.project.commitReferences[namespace].names; + } catch { + return createAlert({ + message: this.$options.i18n.errorMessage, + captureError: true, + }); + } + }, + fetchContainingBranches() { + this.fetchContainingRefs({ query: containingBranchesQuery, namespace: 'containingBranches' }); + }, + fetchContainingTags() { + this.fetchContainingRefs({ query: containingTagsQuery, namespace: 'containingTags' }); + }, + }, + i18n: { + branches: BRANCHES, + tags: TAGS, + errorMessage: FETCH_COMMIT_REFERENCES_ERROR, + }, + FETCH_CONTAINING_REFS_EVENT, + BRANCHES_REF_TYPE, + TAGS_REF_TYPE, +}; +</script> + +<template> + <div class="gl-ml-7"> + <refs-list + v-if="hasBranches" + :has-containing-refs="hasContainingBranches" + :is-loading="$apollo.queries.project.loading" + :tipping-refs="tippingBranches" + :containing-refs="containingBranches" + :namespace="$options.i18n.branches" + :url-part="commitsUrlPart" + :ref-type="$options.BRANCHES_REF_TYPE" + @[$options.FETCH_CONTAINING_REFS_EVENT]="fetchContainingBranches" + /> + <refs-list + v-if="hasTags" + :has-containing-refs="hasContainingTags" + :is-loading="$apollo.queries.project.loading" + :tipping-refs="tippingTags" + :containing-refs="containingTags" + :namespace="$options.i18n.tags" + :url-part="commitsUrlPart" + :ref-type="$options.TAGS_REF_TYPE" + @[$options.FETCH_CONTAINING_REFS_EVENT]="fetchContainingTags" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue new file mode 100644 index 00000000000..8ceab9cb60b --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue @@ -0,0 +1,112 @@ +<script> +import { GlCollapse, GlBadge, GlButton, GlIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { CONTAINING_COMMIT, FETCH_CONTAINING_REFS_EVENT } from '../constants'; + +export default { + name: 'RefsList', + components: { + GlCollapse, + GlSkeletonLoader, + GlBadge, + GlButton, + GlIcon, + }, + props: { + urlPart: { + type: String, + required: true, + }, + refType: { + type: String, + required: true, + }, + containingRefs: { + type: Array, + required: false, + default: () => [], + }, + tippingRefs: { + type: Array, + required: false, + default: () => [], + }, + namespace: { + type: String, + required: true, + }, + hasContainingRefs: { + type: Boolean, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isContainingRefsVisible: false, + }; + }, + computed: { + collapseIcon() { + return this.isContainingRefsVisible ? 'chevron-down' : 'chevron-right'; + }, + isLoadingRefs() { + return this.isLoading && !this.containingRefs.length; + }, + }, + methods: { + toggleCollapse() { + this.isContainingRefsVisible = !this.isContainingRefsVisible; + }, + showRefs() { + this.toggleCollapse(); + this.$emit(FETCH_CONTAINING_REFS_EVENT); + }, + getRefUrl(ref) { + return `${this.urlPart}${ref}?ref_type=${this.refType}`; + }, + }, + i18n: { + containingCommit: CONTAINING_COMMIT, + }, +}; +</script> + +<template> + <div class="gl-pt-4"> + <span data-testid="title" class="gl-mr-2">{{ namespace }}</span> + <gl-badge + v-for="ref in tippingRefs" + :key="ref" + :href="getRefUrl(ref)" + class="gl-mt-2 gl-mr-2" + size="sm" + >{{ ref }}</gl-badge + > + <gl-button + v-if="hasContainingRefs" + class="gl-mr-2 gl-font-sm!" + variant="link" + size="small" + @click="showRefs" + > + <gl-icon :name="collapseIcon" :size="14" /> + {{ namespace }} {{ $options.i18n.containingCommit }} + </gl-button> + <gl-collapse :visible="isContainingRefsVisible"> + <gl-skeleton-loader v-if="isLoadingRefs" :lines="1" /> + <template v-else> + <gl-badge + v-for="ref in containingRefs" + :key="ref" + :href="getRefUrl(ref)" + class="gl-mt-3 gl-mr-2" + size="sm" + >{{ ref }}</gl-badge + > + </template> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/projects/commit_box/info/constants.js b/app/assets/javascripts/projects/commit_box/info/constants.js index be0bf715314..4b74fbe19e1 100644 --- a/app/assets/javascripts/projects/commit_box/info/constants.js +++ b/app/assets/javascripts/projects/commit_box/info/constants.js @@ -1,7 +1,23 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const COMMIT_BOX_POLL_INTERVAL = 10000; export const PIPELINE_STATUS_FETCH_ERROR = __( 'There was a problem fetching the latest pipeline status.', ); + +export const BRANCHES = s__('Commit|Branches'); + +export const TAGS = s__('Commit|Tags'); + +export const CONTAINING_COMMIT = s__('Commit|containing commit'); + +export const FETCH_CONTAINING_REFS_EVENT = 'fetch-containing-refs'; + +export const FETCH_COMMIT_REFERENCES_ERROR = s__( + 'Commit|There was an error fetching the commit references. Please try again later.', +); + +export const BRANCHES_REF_TYPE = 'heads'; + +export const TAGS_REF_TYPE = 'tags'; diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql new file mode 100644 index 00000000000..ea74efdbc46 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql @@ -0,0 +1,10 @@ +query CommitContainingBranches($fullPath: ID!, $commitSha: String!) { + project(fullPath: $fullPath) { + id + commitReferences(commitSha: $commitSha) { + containingBranches(excludeTipped: true) { + names + } + } + } +} diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql new file mode 100644 index 00000000000..d736dc3ab66 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql @@ -0,0 +1,10 @@ +query CommitContainingTags($fullPath: ID!, $commitSha: String!) { + project(fullPath: $fullPath) { + id + commitReferences(commitSha: $commitSha) { + containingTags(excludeTipped: true) { + names + } + } + } +} diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql new file mode 100644 index 00000000000..71d911c2acc --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql @@ -0,0 +1,19 @@ +query CommitReferences($fullPath: ID!, $commitSha: String!) { + project(fullPath: $fullPath) { + id + commitReferences(commitSha: $commitSha) { + containingBranches(excludeTipped: true, limit: 1) { + names + } + containingTags(excludeTipped: true, limit: 1) { + names + } + tippingBranches { + names + } + tippingTags { + names + } + } + } +} diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js index 7c4b76fd62f..8f09c8e1e11 100644 --- a/app/assets/javascripts/projects/commit_box/info/index.js +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -1,12 +1,10 @@ import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph'; -import { loadBranches } from './load_branches'; import initCommitPipelineStatus from './init_commit_pipeline_status'; +import initCommitReferences from './init_commit_references'; export const initCommitBoxInfo = () => { // Display commit related branches - loadBranches(); - // Related merge requests to this commit fetchCommitMergeRequests(); @@ -14,4 +12,6 @@ export const initCommitBoxInfo = () => { initCommitPipelineMiniGraph(); initCommitPipelineStatus(); + + initCommitReferences(); }; diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_references.js b/app/assets/javascripts/projects/commit_box/info/init_commit_references.js new file mode 100644 index 00000000000..c8497187211 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_references.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import CommitBranches from './components/commit_refs.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default (selector = 'js-commit-branches-and-tags') => { + const el = document.getElementById(selector); + + if (!el) { + return false; + } + + const { fullPath, commitSha } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + fullPath, + commitSha, + }, + render(createElement) { + return createElement(CommitBranches); + }, + }); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js deleted file mode 100644 index 8333e70b951..00000000000 --- a/app/assets/javascripts/projects/commit_box/info/load_branches.js +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'axios'; -import { sanitize } from '~/lib/dompurify'; -import { __ } from '~/locale'; -import { initDetailsButton } from './init_details_button'; - -export const loadBranches = (containerSelector = '.js-commit-box-info') => { - const containerEl = document.querySelector(containerSelector); - if (!containerEl) { - return; - } - - const { commitPath } = containerEl.dataset; - const branchesEl = containerEl.querySelector('.commit-info.branches'); - axios - .get(commitPath) - .then(({ data }) => { - branchesEl.innerHTML = sanitize(data); - - initDetailsButton(); - }) - .catch(() => { - branchesEl.textContent = __('Failed to load branches. Please try again.'); - }); -}; diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index c00e75db722..4c0b5d0b1f6 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -1,11 +1,9 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; export default { components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, + GlCollapsibleListbox, }, props: { paramsName: { @@ -25,6 +23,7 @@ export default { data() { return { searchTerm: '', + selectedProjectId: this.selectedProject.id, }; }, computed: { @@ -32,49 +31,45 @@ export default { return this.projects === null; }, filteredRepos() { - const lowerCaseSearchTerm = this.searchTerm.toLowerCase(); + if (this.disableRepoDropdown) return []; - return this?.projects.filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm)); + const lowerCaseSearchTerm = this.searchTerm.toLowerCase(); + return this.projects + .filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm)) + .map((project) => ({ text: project.name, value: project.id })); }, inputName() { return `${this.paramsName}_project_id`; }, }, methods: { - onClick(project) { - this.emitTargetProject(project); - }, - emitTargetProject(project) { + emitTargetProject(projectId) { + if (this.disableRepoDropdown) return; + const project = this.projects.find(({ id }) => id === projectId); this.$emit('selectProject', { direction: this.paramsName, project }); }, + onSearch(searchTerm) { + this.searchTerm = searchTerm; + }, }, }; </script> <template> <div> - <input type="hidden" :name="inputName" :value="selectedProject.id" /> - <gl-dropdown - :text="selectedProject.name" + <input type="hidden" :name="inputName" :value="selectedProjectId" /> + <gl-collapsible-listbox + v-model="selectedProjectId" + :toggle-text="selectedProject.name" :header-text="s__(`CompareRevisions|Select target project`)" - class="gl-w-full gl-font-monospace" + class="gl-font-monospace" toggle-class="gl-min-w-0" :disabled="disableRepoDropdown" - > - <template #header> - <gl-search-box-by-type v-if="!disableRepoDropdown" v-model.trim="searchTerm" /> - </template> - <template v-if="!disableRepoDropdown"> - <gl-dropdown-item - v-for="repo in filteredRepos" - :key="repo.id" - is-check-item - :is-checked="selectedProject.id === repo.id" - @click="onClick(repo)" - > - {{ repo.name }} - </gl-dropdown-item> - </template> - </gl-dropdown> + :items="filteredRepos" + block + searchable + @select="emitTargetProject" + @search="onSearch" + /> </div> </template> diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 2f58d4468be..6ca83b0b500 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -1,8 +1,8 @@ <script> -import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg'; -import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg'; -import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg'; -import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg'; +import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?raw'; +import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?raw'; +import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?raw'; +import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?raw'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 99ea02aaa4f..33320f59b0f 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -295,7 +295,7 @@ const bindEvents = () => { }); $newProjectForm.on('submit', () => { - $projectPath.val($projectPath.val().trim()); + $projectPath.value = $projectPath.value.trim(); }); const updateUrlPathWarningVisibility = async () => { diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue index 3dcacf9eb34..6494456d560 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue @@ -55,7 +55,7 @@ export default { }, searchInputDelay: 250, wildcardsHelpPath: helpPagePath('user/project/protected_branches', { - anchor: 'configure-multiple-protected-branches-by-using-a-wildcard', + anchor: 'protect-multiple-branches-with-wildcard-rules', }), props: { projectPath: { diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js index b71c33d2b91..a45ed5c68af 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -49,7 +49,7 @@ export const BRANCH_PARAM_NAME = 'branch'; export const ALL_BRANCHES_WILDCARD = '*'; export const WILDCARDS_HELP_PATH = - 'user/project/protected_branches#configure-multiple-protected-branches-by-using-a-wildcard'; + 'user/project/protected_branches#protect-multiple-branches-with-wildcard-rules'; export const PROTECTED_BRANCHES_HELP_PATH = 'user/project/protected_branches'; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 08a1c586f69..a2e4827cbfa 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -63,6 +63,11 @@ export default { required: false, default: () => [], }, + items: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -143,11 +148,37 @@ export default { query: debounce(function debouncedSearch() { return this.getData(); }, 500), + items(items) { + this.setDataForSave(items); + }, }, created() { this.getData({ initial: true }); }, methods: { + setDataForSave(items) { + this.selected = items.reduce( + (selected, item) => { + if (item.group_id) { + selected[LEVEL_TYPES.GROUP].push({ id: item.group_id, ...item }); + } else if (item.user_id) { + selected[LEVEL_TYPES.USER].push({ id: item.user_id, ...item }); + } else if (item.access_level) { + const level = this.accessLevelsData.find(({ id }) => item.access_level === id); + selected[LEVEL_TYPES.ROLE].push(level); + } else if (item.deploy_key_id) { + selected[LEVEL_TYPES.DEPLOY_KEY].push({ id: item.deploy_key_id, ...item }); + } + return selected; + }, + { + [LEVEL_TYPES.GROUP]: [], + [LEVEL_TYPES.USER]: [], + [LEVEL_TYPES.ROLE]: [], + [LEVEL_TYPES.DEPLOY_KEY]: [], + }, + ); + }, focusInput() { this.$refs.search.focusInput(); }, 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 79ece99e6ec..650b60cba4f 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 @@ -23,6 +23,9 @@ export default { initialIsEnabled: { default: false, }, + isIssueTrackerEnabled: { + default: false, + }, endpoint: { default: '', }, @@ -163,6 +166,7 @@ export default { </gl-alert> <service-desk-setting :is-enabled="isEnabled" + :is-issue-tracker-enabled="isIssueTrackerEnabled" :incoming-email="incomingEmail" :custom-email="updatedCustomEmail" :custom-email-enabled="customEmailEnabled" 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 5a3930b5df4..38a2c12d137 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 @@ -8,6 +8,7 @@ import { GlFormGroup, GlFormInput, GlLink, + GlAlert, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; @@ -17,6 +18,9 @@ import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue'; export default { i18n: { toggleLabel: __('Activate Service Desk'), + issueTrackerEnableMessage: __( + 'To use Service Desk in this project, you must %{linkStart}activate the issue tracker%{linkEnd}.', + ), }, components: { ClipboardButton, @@ -28,6 +32,7 @@ export default { GlFormGroup, GlFormInputGroup, GlLink, + GlAlert, ServiceDeskTemplateDropdown, }, props: { @@ -35,6 +40,10 @@ export default { type: Boolean, required: true, }, + isIssueTrackerEnabled: { + type: Boolean, + required: true, + }, incomingEmail: { type: String, required: false, @@ -110,6 +119,11 @@ export default { anchor: 'use-a-custom-email-address', }); }, + issuesHelpPagePath() { + return helpPagePath('user/project/settings/index.md', { + anchor: 'configure-project-visibility-features-and-permissions', + }); + }, }, methods: { onCheckboxToggle(isChecked) { @@ -141,9 +155,24 @@ export default { <template> <div> + <gl-alert v-if="!isIssueTrackerEnabled" class="mb-3" variant="info" :dismissible="false"> + <gl-sprintf :message="$options.i18n.issueTrackerEnableMessage"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + data-testid="issue-help-page" + :href="issuesHelpPagePath" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> <gl-toggle id="service-desk-checkbox" :value="isEnabled" + :disabled="!isIssueTrackerEnabled" class="d-inline-block align-middle mr-1" :label="$options.i18n.toggleLabel" label-position="hidden" @@ -194,6 +223,7 @@ export default { :label="__('Email address suffix')" :state="!projectKeyError" data-testid="suffix-form-group" + :disabled="!isIssueTrackerEnabled" > <gl-form-input v-if="hasProjectKeySupport" @@ -249,6 +279,7 @@ export default { :label="__('Template to append to all Service Desk issues')" :state="!projectKeyError" class="mt-3" + :disabled="!isIssueTrackerEnabled" > <service-desk-template-dropdown :selected-template="selectedTemplate" @@ -268,6 +299,7 @@ export default { id="service-desk-email-from-name" v-model.trim="outgoingName" data-testid="email-from-name" + :disabled="!isIssueTrackerEnabled" /> <template #description> @@ -280,7 +312,7 @@ export default { class="gl-mt-5" data-testid="save_service_desk_settings_button" data-qa-selector="save_service_desk_settings_button" - :disabled="isTemplateSaving" + :disabled="isTemplateSaving || !isIssueTrackerEnabled" @click="onSaveTemplate" > {{ __('Save changes') }} diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index 26435a5fac9..84229175c0b 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -13,6 +13,7 @@ export default () => { customEmail, customEmailEnabled, enabled, + issueTrackerEnabled, endpoint, incomingEmail, outgoingName, @@ -31,6 +32,7 @@ export default () => { endpoint, initialIncomingEmail: incomingEmail, initialIsEnabled: parseBoolean(enabled), + isIssueTrackerEnabled: parseBoolean(issueTrackerEnabled), outgoingName, projectKey, selectedTemplate, diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index cdbe39fd5e0..a11201627a4 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -95,11 +95,7 @@ export default class ProtectedBranchCreate { } hasProtectedBranchSuccessAlert() { - return ( - window.gon?.features?.branchRules && - this.isLocalStorageAvailable && - localStorage.getItem(IS_PROTECTED_BRANCH_CREATED) - ); + return this.isLocalStorageAvailable && localStorage.getItem(IS_PROTECTED_BRANCH_CREATED); } createSuccessAlert() { diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 7ecc39a56e7..b3033ddf3b6 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -218,7 +218,7 @@ export default { type="submit" size="small" class="gl-mr-2" - data-qa-selector="add_issue_button" + data-testid="add_issue_button" > {{ __('Add') }} </gl-button> diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 1846b9cf8f4..f92c81a7eb2 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -217,7 +217,7 @@ export default { :aria-label="inputPlaceholder" type="text" class="gl-w-full gl-border-none gl-outline-0" - data-qa-selector="add_issue_field" + data-testid="add_issue_field" autocomplete="off" @input="onInput" @focus="onFocus" 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 24b350c7f18..f672acda062 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -220,7 +220,6 @@ export default { <gl-button v-if="canAdmin" size="small" - data-qa-selector="related_issues_plus_button" data-testid="related-issues-plus-button" :aria-label="addIssuableButtonText" class="gl-ml-3" diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 63452f3eace..8d26917f749 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -104,7 +104,7 @@ export default { {{ heading }} </h4> <div class="related-issues-token-body" :class="{ 'sortable-container': canReorder }"> - <div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder"> + <div v-if="isFetching" class="gl-mb-2" data-testid="related_issues_loading_placeholder"> <gl-loading-icon ref="loadingIcon" size="sm" @@ -146,7 +146,7 @@ export default { :locked-message="issue.lockedMessage" :work-item-type="issue.type" event-namespace="relatedIssue" - data-qa-selector="related_issuable_content" + data-testid="related_issuable_content" class="gl-mx-n2" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" /> diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js new file mode 100644 index 00000000000..fd4d111b4b0 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js @@ -0,0 +1,24 @@ +import iconUrl from 'leaflet/dist/images/marker-icon.png'; +import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; +import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; +import { __ } from '~/locale'; + +export const RENDER_ERROR_MSG = __( + 'The map can not be displayed because there was an error loading the GeoJSON file.', +); + +export const OPEN_STREET_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; +export const ICON_CONFIG = { iconUrl, iconRetinaUrl, shadowUrl }; +export const MAP_ATTRIBUTION = __('Map data from'); +export const OPEN_STREET_COPYRIGHT_LINK = + '<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>'; + +export const POPUP_CONTENT_TEMPLATE = ` +<div class="gl-pt-4"> + <% eachFunction(popupProperties, function(value, label) { %> + <div> + <strong><%- label %>:</strong> <span><%- value %></span> + </div> + <% }); %> +</div> +`; diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue new file mode 100644 index 00000000000..1c9fccc2c19 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue @@ -0,0 +1,32 @@ +<script> +import { createAlert } from '~/alert'; +import { RENDER_ERROR_MSG } from './constants'; +import { initLeafletMap } from './utils'; + +export default { + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + hasError: false, + loading: true, + }; + }, + mounted() { + try { + initLeafletMap(this.$refs.map, JSON.parse(this.blob.rawTextBlob)); + } catch (error) { + createAlert({ message: RENDER_ERROR_MSG }); + this.hasError = true; + } + }, +}; +</script> + +<template> + <div v-if="!hasError" ref="map" class="gl-h-100vh gl-z-index-0" data-testid="map"></div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js b/app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js new file mode 100644 index 00000000000..615f7fd2b47 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js @@ -0,0 +1,47 @@ +import { map, tileLayer, geoJson, featureGroup, Icon } from 'leaflet'; +import { template, each } from 'lodash'; +import { + OPEN_STREET_TILE_URL, + MAP_ATTRIBUTION, + OPEN_STREET_COPYRIGHT_LINK, + ICON_CONFIG, + POPUP_CONTENT_TEMPLATE, +} from './constants'; + +const generateOpenStreetMapTiles = () => { + const attribution = `${MAP_ATTRIBUTION} ${OPEN_STREET_COPYRIGHT_LINK}`; + return tileLayer(OPEN_STREET_TILE_URL, { attribution }); +}; + +export const popupContent = (popupProperties) => { + return template(POPUP_CONTENT_TEMPLATE)({ + eachFunction: each, + popupProperties, + }); +}; + +const loadGeoJsonGroupAndBounds = (geoJsonData) => { + const layers = []; + const geoJsonGroup = geoJson(geoJsonData, { + onEachFeature: (feature, layer) => { + layers.push(layer); + if (feature.properties) { + layer.bindPopup(popupContent(feature.properties)); + } + }, + }); + + return { geoJsonGroup, bounds: featureGroup(layers).getBounds() }; +}; + +export const initLeafletMap = (el, geoJsonData) => { + if (!el || !geoJsonData) return; + + import('leaflet/dist/leaflet.css'); + Icon.Default.mergeOptions(ICON_CONFIG); + const leafletMap = map(el, { layers: [generateOpenStreetMapTiles()] }); + const { bounds, geoJsonGroup } = loadGeoJsonGroupAndBounds(geoJsonData); + + geoJsonGroup.addTo(leafletMap); + leafletMap.fitBounds(bounds); +}; diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index 68b2cf6f3da..b749702972f 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -4,7 +4,7 @@ const viewers = { image: () => import('./image_viewer.vue'), video: () => import('./video_viewer.vue'), empty: () => import('./empty_viewer.vue'), - text: () => import('~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'), + text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'), pdf: () => import('./pdf_viewer.vue'), lfs: () => import('./lfs_viewer.vue'), audio: () => import('./audio_viewer.vue'), @@ -12,6 +12,7 @@ const viewers = { sketch: () => import('./sketch_viewer.vue'), notebook: () => import('./notebook_viewer.vue'), openapi: () => import('./openapi_viewer.vue'), + geo_json: () => import('./geo_json/geo_json_viewer.vue'), }; export const loadViewer = (type, isUsingLfs) => { diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 1da445a7906..42108e8dfba 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -3,7 +3,6 @@ import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } import { s__, sprintf, n__ } from '~/locale'; 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, @@ -43,7 +42,6 @@ export default { ConflictsModal, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], apollo: { project: { query: forkDetailsQuery, @@ -198,7 +196,6 @@ export default { }, hasUpdateButton() { return ( - this.glFeatures.synchronizeFork && this.canSyncBranch && ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence) ); @@ -314,7 +311,7 @@ export default { > {{ $options.i18n.inaccessibleProject }} </div> - <div class="gl-display-flex gl-xs-display-none!"> + <div class="gl-display-none gl-sm-display-flex"> <gl-button v-if="hasCreateMrButton" class="gl-ml-4" diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 1e4b1e36514..f5684cebbf9 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -15,7 +15,7 @@ export const initSearchApp = () => { const store = createStore({ query, navigation, - useNewNavigation: gon.use_new_navigation, + useSidebarNavigation: 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 317145d4cd1..cd289be4c05 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -1,23 +1,24 @@ <script> import { mapState, mapGetters } from 'vuex'; -import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; -import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue'; +import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; +import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_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 IssuesFilters from './issues_filters.vue'; import LanguageFilter from './language_filter/index.vue'; export default { name: 'GlobalSearchSidebar', components: { - ResultsFilters, - ScopeNavigation, - ScopeNewNavigation, + IssuesFilters, + ScopeLegacyNavigation, + ScopeSidebarNavigation, LanguageFilter, SidebarPortal, }, computed: { - ...mapState(['urlQuery', 'useNewNavigation']), + // useSidebarNavigation refers to whether the new left sidebar navigation is enabled + ...mapState(['useSidebarNavigation']), ...mapGetters(['currentScope']), showIssueAndMergeFilters() { return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS; @@ -25,7 +26,12 @@ export default { showBlobFilter() { return this.currentScope === SCOPE_BLOB; }, - showOldNavigation() { + showLabelFilter() { + return this.currentScope === SCOPE_ISSUES; + }, + showScopeNavigation() { + // showScopeNavigation refers to whether the scope navigation should be shown + // while the legacy navigation is being used and there are no search results the scope navigation has to be hidden return Boolean(this.currentScope); }, }, @@ -33,19 +39,19 @@ export default { </script> <template> - <section v-if="useNewNavigation"> + <section v-if="useSidebarNavigation"> <sidebar-portal> - <scope-new-navigation /> - <results-filters v-if="showIssueAndMergeFilters" /> + <scope-sidebar-navigation /> + <issues-filters v-if="showIssueAndMergeFilters" /> <language-filter v-if="showBlobFilter" /> </sidebar-portal> </section> <section - v-else + v-else-if="showScopeNavigation" class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5" > - <scope-navigation /> - <results-filters v-if="showIssueAndMergeFilters" /> + <scope-legacy-navigation /> + <issues-filters v-if="showIssueAndMergeFilters" /> <language-filter v-if="showBlobFilter" /> </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index 56e44d454a1..2a7988cd4c6 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -19,7 +19,7 @@ export default { <template> <div> - <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" /> + <radio-filter :filter-data="$options.confidentialFilterData" /> <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue new file mode 100644 index 00000000000..8928f80d83a --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -0,0 +1,85 @@ +<script> +import { GlButton, GlLink } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + HR_DEFAULT_CLASSES, + TRACKING_ACTION_CLICK, + TRACKING_LABEL_APPLY, + TRACKING_CATEGORY, + TRACKING_LABEL_RESET, +} from '../constants/index'; +import { confidentialFilterData } from '../constants/confidential_filter_data'; +import { stateFilterData } from '../constants/state_filter_data'; +import ConfidentialityFilter from './confidentiality_filter.vue'; +import { labelFilterData } from './label_filter/data'; +import LabelFilter from './label_filter/index.vue'; +import StatusFilter from './status_filter.vue'; + +export default { + name: 'IssuesFilters', + components: { + GlButton, + GlLink, + StatusFilter, + ConfidentialityFilter, + LabelFilter, + }, + mixins: [glFeatureFlagsMixin()], + computed: { + ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), + ...mapGetters(['currentScope']), + showReset() { + return this.urlQuery.state || this.urlQuery.confidential || this.urlQuery.labels; + }, + showConfidentialityFilter() { + return Object.values(confidentialFilterData.scopes).includes(this.currentScope); + }, + showStatusFilter() { + return Object.values(stateFilterData.scopes).includes(this.currentScope); + }, + showLabelFilter() { + return ( + Object.values(labelFilterData.scopes).includes(this.currentScope) && + this.glFeatures.searchIssueLabelAggregation + ); + }, + hrClasses() { + return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; + }, + }, + methods: { + ...mapActions(['applyQuery', 'resetQuery']), + applyQueryWithTracking() { + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { + label: TRACKING_CATEGORY, + }); + this.applyQuery(); + }, + resetQueryWithTracking() { + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, { + label: TRACKING_CATEGORY, + }); + this.resetQuery(); + }, + }, +}; +</script> + +<template> + <form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking"> + <hr v-if="!useNewNavigation" :class="hrClasses" /> + <status-filter v-if="showStatusFilter" class="gl-mb-5" /> + <confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" /> + <label-filter v-if="showLabelFilter" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4"> + <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> + {{ __('Apply') }} + </gl-button> + <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQueryWithTracking">{{ + __('Reset filters') + }}</gl-link> + </div> + </form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/data.js b/app/assets/javascripts/search/sidebar/components/label_filter/data.js new file mode 100644 index 00000000000..654357da902 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/data.js @@ -0,0 +1,23 @@ +import { __ } from '~/locale'; + +export const FIRST_DROPDOWN_INDEX = 0; + +export const SEARCH_BOX_INDEX = 0; + +export const SEARCH_INPUT_DESCRIPTION = 'label-search-input-description'; + +export const SEARCH_RESULTS_DESCRIPTION = 'label-search-results-description'; + +const header = __('Labels'); + +const scopes = { + ISSUES: 'issues', +}; + +const filterParam = 'labels'; + +export const labelFilterData = { + header, + scopes, + filterParam, +}; diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue new file mode 100644 index 00000000000..74855482b5d --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -0,0 +1,291 @@ +<script> +import { + GlSearchBoxByType, + GlLabel, + GlLoadingIcon, + GlDropdownDivider, + GlDropdownSectionHeader, + GlFormCheckboxGroup, + GlDropdownForm, + GlAlert, + GlOutsideDirective as Outside, +} from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { uniq } from 'lodash'; +import { rgbFromHex } from '@gitlab/ui/dist/utils/utils'; +import { slugify } from '~/lib/utils/text_utility'; +import { s__, sprintf } from '~/locale'; + +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; + +import { + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_RESULTS_LOADING, +} from '~/vue_shared/global_search/constants'; + +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; +import LabelDropdownItems from './label_dropdown_items.vue'; + +import { + FIRST_DROPDOWN_INDEX, + SEARCH_BOX_INDEX, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_INPUT_DESCRIPTION, + labelFilterData, +} from './data'; + +import { trackSelectCheckbox, trackOpenDropdown } from './tracking'; + +export default { + name: 'LabelFilter', + directives: { Outside }, + components: { + DropdownKeyboardNavigation, + GlSearchBoxByType, + LabelDropdownItems, + GlLabel, + GlDropdownDivider, + GlDropdownSectionHeader, + GlFormCheckboxGroup, + GlDropdownForm, + GlLoadingIcon, + GlAlert, + }, + data() { + return { + currentFocusIndex: SEARCH_BOX_INDEX, + isFocused: false, + }; + }, + i18n: { + SEARCH_LABELS: s__('GlobalSearch|Search labels'), + DROPDOWN_HEADER: s__('GlobalSearch|Label(s)'), + AGGREGATIONS_ERROR_MESSAGE: s__('GlobalSearch|Fetching aggregations error.'), + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_RESULTS_LOADING, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + }, + computed: { + ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']), + ...mapGetters([ + 'filteredLabels', + 'filteredUnselectedLabels', + 'filteredAppliedSelectedLabels', + 'appliedSelectedLabels', + 'filteredUnappliedSelectedLabels', + ]), + searchInputDescribeBy() { + if (this.isLoggedIn) { + return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; + } + return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + }, + dropdownResultsDescription() { + if (!this.showSearchDropdown) { + return ''; // This allows aria-live to see register an update when the dropdown is shown + } + + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { + count: this.filteredLabels.length, + }); + } + + return this.loading + ? this.$options.i18n.SEARCH_RESULTS_LOADING + : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { + count: this.filteredLabels.length, + }); + }, + currentFocusedOption() { + return this.filteredLabels[this.currentFocusIndex] || null; + }, + currentFocusedId() { + return `${slugify(this.currentFocusedOption?.parent_full_name || 'undefined-name')}_${slugify( + this.currentFocusedOption?.title || 'undefined-title', + )}`; + }, + defaultIndex() { + if (this.showDefaultItems) { + return SEARCH_BOX_INDEX; + } + return FIRST_DROPDOWN_INDEX; + }, + hasSelectedLabels() { + return this.filteredAppliedSelectedLabels.length > 0; + }, + hasUnselectedLabels() { + return this.filteredUnselectedLabels.length > 0; + }, + dividerClasses() { + return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; + }, + labelSearchBox() { + return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]'); + }, + combinedSelectedFilters() { + const appliedSelectedLabelKeys = this.appliedSelectedLabels.map((label) => label.key); + const { labels = [] } = this.query; + + return uniq([...appliedSelectedLabelKeys, ...labels]); + }, + searchLabels: { + get() { + return this.searchLabelString; + }, + set(value) { + this.setLabelFilterSearch({ value }); + }, + }, + selectedFilters: { + get() { + return this.combinedSelectedFilters; + }, + set(value) { + this.setQuery({ key: this.$options.labelFilterData?.filterParam, value }); + + trackSelectCheckbox(value); + }, + }, + }, + async created() { + await this.fetchAllAggregation(); + }, + methods: { + ...mapActions(['fetchAllAggregation', 'setQuery', 'closeLabel', 'setLabelFilterSearch']), + openDropdown() { + this.isFocused = true; + + trackOpenDropdown(); + }, + closeDropdown(event) { + const { target } = event; + + if (this.labelSearchBox !== target) { + this.isFocused = false; + } + }, + onLabelClose(event) { + if (!event?.target?.closest('.gl-label')?.dataset) { + return; + } + + const { key } = event.target.closest('.gl-label').dataset; + this.closeLabel({ key }); + }, + reactiveLabelColor(label) { + const { color, key } = label; + + return this.query?.labels?.some((labelKey) => labelKey === key) + ? color + : `rgba(${rgbFromHex(color)}, 0.3)`; + }, + isLabelClosable(label) { + const { key } = label; + return this.query?.labels?.some((labelKey) => labelKey === key); + }, + }, + FIRST_DROPDOWN_INDEX, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_INPUT_DESCRIPTION, + labelFilterData, +}; +</script> + +<template> + <div class="gl-pb-0 gl-md-pt-0 label-filter gl-relative"> + <h5 + class="gl-my-0" + data-testid="label-filter-title" + :class="{ 'gl-font-sm': useSidebarNavigation }" + > + {{ $options.labelFilterData.header }} + </h5> + <div class="gl-my-5"> + <gl-label + v-for="label in appliedSelectedLabels" + :key="label.key" + class="gl-mr-2 gl-mb-2 gl-bg-gray-10" + :data-key="label.key" + :background-color="reactiveLabelColor(label)" + :title="label.title" + :show-close-button="isLabelClosable(label)" + @close="onLabelClose" + /> + </div> + <gl-search-box-by-type + ref="searchLabelInputBox" + v-model="searchLabels" + role="searchbox" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_LABELS" + :aria-activedescendant="currentFocusedId" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + @focusin="openDropdown" + @keydown.esc="closeDropdown" + /> + <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" + > + {{ dropdownResultsDescription }} + </span> + <div + v-if="isFocused" + v-outside="closeDropdown" + 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 gl-z-index-1" + :class="{ + 'gl-max-w-none!': useSidebarNavigation, + 'gl-min-w-full!': useSidebarNavigation, + 'gl-w-full!': useSidebarNavigation, + }" + > + <div class="header-search-dropdown-content gl-py-2"> + <dropdown-keyboard-navigation + v-model="currentFocusIndex" + :max="filteredLabels.length - 1" + :min="$options.FIRST_DROPDOWN_INDEX" + :default-index="defaultIndex" + :enable-cycle="true" + /> + <div v-if="!aggregations.error"> + <gl-dropdown-section-header v-if="hasSelectedLabels || hasUnselectedLabels">{{ + $options.i18n.DROPDOWN_HEADER + }}</gl-dropdown-section-header> + <gl-dropdown-form> + <gl-form-checkbox-group v-model="selectedFilters"> + <label-dropdown-items + v-if="hasSelectedLabels" + data-testid="selected-lavel-items" + :labels="filteredAppliedSelectedLabels" + /> + <gl-dropdown-divider v-if="hasSelectedLabels && hasUnselectedLabels" /> + <label-dropdown-items + v-if="hasUnselectedLabels" + data-testid="unselected-lavel-items" + :labels="filteredUnselectedLabels" + /> + </gl-form-checkbox-group> + </gl-dropdown-form> + </div> + <gl-alert v-else :dismissible="false" variant="danger"> + {{ $options.i18n.AGGREGATIONS_ERROR_MESSAGE }} + </gl-alert> + <gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" /> + </div> + </div> + <hr v-if="!useSidebarNavigation" :class="dividerClasses" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue new file mode 100644 index 00000000000..7a9e6a2e4fc --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue @@ -0,0 +1,43 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + name: 'LabelDropdownItems', + components: { + GlFormCheckbox, + }, + props: { + labels: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <ul class="gl-list-style-none gl-px-0"> + <li + v-for="label in labels" + :id="label.key" + :ref="label.key" + :key="label.key" + :aria-label="label.title" + tabindex="-1" + class="gl-px-5 gl-py-3 label-filter-menu-item" + > + <gl-form-checkbox + class="label-with-color-checkbox gl-display-inline-flex gl-h-5 gl-min-h-5" + :value="label.key" + > + <span + data-testid="label-color-indicator" + class="gl-rounded-base gl-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3" + :style="{ 'background-color': label.color }" + ></span> + <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title">{{ + label.title + }}</span></gl-form-checkbox + > + </li> + </ul> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js new file mode 100644 index 00000000000..c38922a559c --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js @@ -0,0 +1,21 @@ +import Tracking from '~/tracking'; + +export const TRACKING_CATEGORY = 'Language filters'; +export const TRACKING_LABEL_FILTER = 'Label Key'; + +export const TRACKING_LABEL_DROPDOWN = 'Dropdown'; +export const TRACKING_LABEL_CHECKBOX = 'Label Checkbox'; + +export const TRACKING_ACTION_SELECT = 'search:agreggations:label:select'; +export const TRACKING_ACTION_SHOW = 'search:agreggations:label:show'; + +export const trackSelectCheckbox = (value) => + Tracking.event(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, { + label: TRACKING_LABEL_FILTER, + property: value, + }); + +export const trackOpenDropdown = () => + Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, { + label: TRACKING_LABEL_DROPDOWN, + }); diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue new file mode 100644 index 00000000000..b820ca837bc --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue @@ -0,0 +1,91 @@ +<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: { + GlFormCheckboxGroup, + GlFormCheckbox, + }, + props: { + filtersData: { + type: Object, + required: true, + }, + trackingNamespace: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['query', 'useNewNavigation']), + ...mapGetters(['queryLanguageFilters']), + dataFilters() { + return Object.values(this.filtersData?.filters || []); + }, + flatDataFilterValues() { + return this.dataFilters.map(({ value }) => value); + }, + selectedFilter: { + get() { + return intersection(this.flatDataFilterValues, this.queryLanguageFilters); + }, + async set(value) { + this.setQuery({ key: this.filtersData?.filterParam, value }); + + await Vue.nextTick(); + this.trackSelectCheckbox(); + }, + }, + labelCountClasses() { + return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500']; + }, + }, + methods: { + ...mapActions(['setQuery']), + 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, +}; +</script> + +<template> + <gl-form-checkbox-group v-model="selectedFilter"> + <gl-form-checkbox + v-for="f in dataFilters" + :key="f.label" + :value="f.label" + class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" + :class="$options.LABEL_DEFAULT_CLASSES" + > + <span + class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" + > + <span data-testid="label"> + {{ f.label }} + </span> + <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount"> + {{ getFormattedCount(f.count) }} + </span> + </span> + </gl-form-checkbox> + </gl-form-checkbox-group> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index 40b50f657f0..c10b14bd116 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -2,10 +2,9 @@ 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 { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; import { convertFiltersData } from '../../utils'; -import CheckboxFilter from '../checkbox_filter.vue'; +import CheckboxFilter from './checkbox_filter.vue'; import { trackShowMore, trackShowHasOverMax, @@ -14,7 +13,7 @@ import { TRACKING_ACTION_SELECT, } from './tracking'; -import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data'; +import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH, languageFilterData } from './data'; export default { name: 'LanguageFilter', @@ -24,7 +23,6 @@ export default { GlAlert, GlForm, }, - mixins: [glFeatureFlagsMixin()], data() { return { showAll: false, @@ -65,22 +63,25 @@ export default { hasOverMax() { return this.languageAggregationBuckets.length > MAX_ITEM_LENGTH; }, - dividerClasses() { + dividerClassesTop() { return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; }, + dividerClassesBottom() { + return [...HR_DEFAULT_CLASSES, 'gl-mt-5']; + }, hasQueryFilters() { return this.queryLanguageFilters.length > 0; }, }, async created() { - await this.fetchLanguageAggregation(); + await this.fetchAllAggregation(); }, methods: { ...mapActions([ 'applyQuery', 'resetLanguageQuery', 'resetLanguageQueryWithRedirect', - 'fetchLanguageAggregation', + 'fetchAllAggregation', ]), onShowMore() { this.showAll = true; @@ -108,69 +109,73 @@ export default { }, HR_DEFAULT_CLASSES, TRACKING_ACTION_SELECT, + languageFilterData, }; </script> <template> - <gl-form - v-if="hasBuckets" - class="gl-pt-5 gl-md-pt-0 language-filter-checkbox" - @submit.prevent="submitQuery" - > - <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 }" + <div> + <gl-form + v-if="hasBuckets" + class="gl-m-5 gl-my-0 language-filter-checkbox" + @submit.prevent="submitQuery" > - <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> - </div> - <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{ - $options.i18n.loadError - }}</gl-alert> - <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all"> - <gl-button - data-testid="show-more-button" - category="tertiary" - variant="link" - size="small" - button-text-classes="gl-font-sm" - @click="onShowMore" - > - {{ $options.i18n.showMore }} - </gl-button> - </div> - <div v-if="!aggregations.error"> - <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> + <hr v-if="!useNewNavigation" :class="dividerClassesTop" /> + <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }"> + {{ $options.languageFilterData.header }} + </h5> <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5" + v-if="!aggregations.error" + class="gl-overflow-x-hidden gl-overflow-y-auto" + :class="{ 'language-filter-max-height': showAll }" > + <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> + </div> + <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{ + $options.i18n.loadError + }}</gl-alert> + <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all"> <gl-button - category="primary" - variant="confirm" - type="submit" - :disabled="!sidebarDirty" - data-testid="apply-button" - > - {{ $options.i18n.apply }} - </gl-button> - <gl-button + data-testid="show-more-button" category="tertiary" variant="link" size="small" - :disabled="!hasQueryFilters && !sidebarDirty" - data-testid="reset-button" - @click="cleanResetFilters" + button-text-classes="gl-font-sm" + @click="onShowMore" > - {{ $options.i18n.reset }} + {{ $options.i18n.showMore }} </gl-button> </div> - </div> - </gl-form> + <div v-if="!aggregations.error"> + <hr v-if="!useNewNavigation" :class="dividerClassesBottom" /> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4"> + <gl-button + category="primary" + variant="confirm" + type="submit" + :disabled="!sidebarDirty" + data-testid="apply-button" + > + {{ $options.i18n.apply }} + </gl-button> + <gl-button + v-if="hasQueryFilters && sidebarDirty" + category="tertiary" + variant="link" + size="small" + data-testid="reset-button" + @click="cleanResetFilters" + > + {{ $options.i18n.reset }} + </gl-button> + </div> + </div> + </gl-form> + </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue index 477ba37dab7..10ece1b82eb 100644 --- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -56,7 +56,9 @@ export default { <template> <div> - <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filterData.header }}</h5> + <h5 class="gl-mt-0 gl-mb-5" :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/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue index fc41baee831..e682369d60b 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue @@ -8,7 +8,7 @@ import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../con import { slugifyWithUnderscore } from '../../../lib/utils/text_utility'; export default { - name: 'ScopeNavigation', + name: 'ScopeLegacyNavigation', i18n: { countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), }, diff --git a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue index 86b7cc577a6..3707e152e47 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue @@ -6,7 +6,7 @@ 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', + name: 'ScopeSidebarNavigation', i18n: { countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), }, diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index 44d6b537b7b..2a3d9ede982 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -19,7 +19,7 @@ export default { <template> <div> - <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" /> + <radio-filter :filter-data="$options.stateFilterData" /> <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 9519154a571..99d8821db61 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -12,7 +12,10 @@ export const NAV_LINK_DEFAULT_CLASSES = [ 'gl-justify-content-space-between', ]; 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 HR_DEFAULT_CLASSES = ['hr-x', 'gl-border-gray-100']; export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block']; -export const TRACKING_LABEL_CHECKBOX = 'Checkbox'; +export const TRACKING_ACTION_CLICK = 'search:filters:click'; +export const TRACKING_LABEL_APPLY = 'Apply Filters'; +export const TRACKING_LABEL_RESET = 'Reset Filters'; +export const TRACKING_CATEGORY = 'Issue filters'; diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue index 2bf144705c4..9f28d2bfc99 100644 --- a/app/assets/javascripts/search/sort/components/app.vue +++ b/app/assets/javascripts/search/sort/components/app.vue @@ -1,21 +1,14 @@ <script> -import { - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownItem, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlCollapsibleListbox, GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { SORT_DIRECTION_UI } from '../constants'; export default { name: 'GlobalSearchSort', components: { + GlCollapsibleListbox, GlButtonGroup, GlButton, - GlDropdown, - GlDropdownItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -26,8 +19,19 @@ export default { required: true, }, }, + data() { + return { + selectedSortOptionTitle: '', + }; + }, computed: { ...mapState(['query']), + listboxOptions() { + return this.searchSortOptions.map((option) => ({ + text: option.title, + value: option.title, + })); + }, selectedSortOption: { get() { const { sort } = this.query; @@ -60,14 +64,23 @@ export default { return this.query?.sort?.includes('asc') ? SORT_DIRECTION_UI.asc : SORT_DIRECTION_UI.desc; }, }, + watch: { + selectedSortOption: { + handler() { + this.selectedSortOptionTitle = this.selectedSortOption.title; + }, + immediate: true, + }, + }, methods: { ...mapActions(['applyQuery', 'setQuery']), - handleSortChange(option) { - if (!option.sortable) { - this.selectedSortOption = option.sortParam; + handleSortChange(value) { + const selectedOption = this.searchSortOptions.find((option) => option.title === value); + if (!selectedOption.sortable) { + this.selectedSortOption = selectedOption.sortParam; } else { // Default new sort options to desc - this.selectedSortOption = option.sortParam.desc; + this.selectedSortOption = selectedOption.sortParam.desc; } }, handleSortDirectionChange() { @@ -82,16 +95,15 @@ export default { <template> <gl-button-group> - <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> - <gl-dropdown-item - v-for="sortOption in searchSortOptions" - :key="sortOption.title" - is-check-item - :is-checked="sortOption.title === selectedSortOption.title" - @click="handleSortChange(sortOption)" - >{{ sortOption.title }}</gl-dropdown-item - > - </gl-dropdown> + <gl-collapsible-listbox + v-model="selectedSortOptionTitle" + placement="right" + class="gl-z-index-1" + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + :toggle-text="selectedSortOptionTitle" + :items="listboxOptions" + @select="handleSortChange" + /> <gl-button v-gl-tooltip :disabled="!selectedSortOption.sortable" diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 3d6ca2a6eee..077c46bbe22 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -5,6 +5,7 @@ import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; import { @@ -108,10 +109,24 @@ export const applyQuery = ({ state }) => { export const resetQuery = ({ state }) => { visitUrl( - setUrlParams({ ...state.query, page: null, state: null, confidential: null }, undefined, true), + setUrlParams( + { ...state.query, page: null, state: null, confidential: null, labels: null }, + undefined, + true, + ), ); }; +export const closeLabel = ({ state, commit }, { key }) => { + const labels = state?.query?.labels.filter((labelKey) => labelKey !== key); + + setQuery({ state, commit }, { key: labelFilterData.filterParam, value: labels }); +}; + +export const setLabelFilterSearch = ({ commit }, { value }) => { + commit(types.SET_LABEL_SEARCH_STRING, value); +}; + export const resetLanguageQueryWithRedirect = ({ state }) => { visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true)); }; @@ -136,7 +151,7 @@ export const fetchSidebarCount = ({ commit, state }) => { return Promise.all(promises); }; -export const fetchLanguageAggregation = ({ commit, state }) => { +export const fetchAllAggregation = ({ commit, state }) => { commit(types.REQUEST_AGGREGATIONS); return axios .get(getAggregationsUrl()) diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index c8ee0a3f9d9..91c16616f02 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,6 +1,7 @@ import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; export const MAX_FREQUENT_ITEMS = 5; @@ -14,6 +15,7 @@ export const SIDEBAR_PARAMS = [ stateFilterData.filterParam, confidentialFilterData.filterParam, languageFilterData.filterParam, + labelFilterData.filterParam, ]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index 135c9a3d67c..c7cb595f42f 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,5 +1,6 @@ import { findKey, has } from 'lodash'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants'; @@ -20,6 +21,43 @@ export const languageAggregationBuckets = (state) => { ); }; +export const labelAggregationBuckets = (state) => { + return ( + state?.aggregations?.data?.find( + (aggregation) => aggregation.name === labelFilterData.filterParam, + )?.buckets || [] + ); +}; + +export const filteredLabels = (state) => { + if (state.searchLabelString === '') { + return labelAggregationBuckets(state); + } + return labelAggregationBuckets(state).filter((label) => { + return label.title.toLowerCase().includes(state.searchLabelString.toLowerCase()); + }); +}; + +export const filteredAppliedSelectedLabels = (state) => + filteredLabels(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); + +export const appliedSelectedLabels = (state) => { + return labelAggregationBuckets(state)?.filter((label) => + state?.urlQuery?.labels?.includes(label.key), + ); +}; + +export const filteredUnappliedSelectedLabels = (state) => + filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key)); + +export const filteredUnselectedLabels = (state) => { + if (!state?.urlQuery?.labels) { + return filteredLabels(state); + } + + return filteredLabels(state)?.filter((label) => !state?.urlQuery?.labels?.includes(label.key)); +}; + export const currentScope = (state) => findKey(state.navigation, { active: true }); export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || []; diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index 634f8f7a7fa..2478518c157 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, useNewNavigation }) => ({ +export const getStoreConfig = (storeInitValues) => ({ actions, getters, mutations, - state: createState({ query, navigation, useNewNavigation }), + state: createState(storeInitValues), }); const createStore = (config) => new Vuex.Store(getStoreConfig(config)); diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index 4ffbadcd083..021dd01ca93 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -15,3 +15,5 @@ export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT'; export const REQUEST_AGGREGATIONS = 'REQUEST_AGGREGATIONS'; export const RECEIVE_AGGREGATIONS_SUCCESS = 'RECEIVE_AGGREGATIONS_SUCCESS'; export const RECEIVE_AGGREGATIONS_ERROR = 'RECEIVE_AGGREGATIONS_ERROR'; + +export const SET_LABEL_SEARCH_STRING = 'SET_LABEL_SEARCH_STRING'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index b2f9f5ab225..65bb21f1b8a 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -45,4 +45,7 @@ export default { [types.RECEIVE_AGGREGATIONS_ERROR](state) { state.aggregations = { fetching: false, error: true, data: [] }; }, + [types.SET_LABEL_SEARCH_STRING](state, value) { + state.searchLabelString = value; + }, }; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index a62b6728819..5407b08fa83 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, useNewNavigation }) => ({ +const createState = ({ query, navigation, useSidebarNavigation }) => ({ urlQuery: cloneDeep(query), query, groups: [], @@ -14,12 +14,13 @@ const createState = ({ query, navigation, useNewNavigation }) => ({ }, sidebarDirty: false, navigation, - useNewNavigation, + useSidebarNavigation, aggregations: { error: false, fetching: false, data: [], }, + searchLabelString: '', }); export default createState; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index d57b3fda342..e7d97989195 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -164,7 +164,7 @@ export default { <gl-tabs content-class="gl-pt-0" - data-qa-selector="security_configuration_container" + data-testid="security-configuration-container" sync-active-tab-with-query-params lazy > @@ -196,12 +196,9 @@ export default { {{ $options.i18n.description }} </p> <p v-if="canViewCiHistory"> - <gl-link - data-testid="security-view-history-link" - data-qa-selector="security_configuration_history_link" - :href="gitlabCiHistoryPath" - >{{ $options.i18n.configurationHistory }}</gl-link - > + <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{ + $options.i18n.configurationHistory + }}</gl-link> </p> </template> diff --git a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue index 315f676e659..c01df3573c5 100644 --- a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue +++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue @@ -28,7 +28,7 @@ export default { variant="info" :primary-button-link="autoDevopsPath" :primary-button-text="$options.i18n.primaryButtonText" - data-qa-selector="autodevops_container" + data-testid="autodevops-container" @dismiss="dismissMethod" > <gl-sprintf :message="$options.i18n.body"> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 1b86d7d0a2b..1c2be99b393 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -15,9 +15,9 @@ import { REPORT_TYPE_API_FUZZING, } from '~/vue_shared/security_reports/constants'; -import kontraLogo from 'images/vulnerability/kontra-logo.svg'; -import scwLogo from 'images/vulnerability/scw-logo.svg'; -import secureflagLogo from 'images/vulnerability/secureflag-logo.svg'; +import kontraLogo from 'images/vulnerability/kontra-logo.svg?raw'; +import scwLogo from 'images/vulnerability/scw-logo.svg?raw'; +import secureflagLogo from 'images/vulnerability/secureflag-logo.svg?raw'; import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql'; import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql'; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index d1b705fe2fc..a757657339b 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -122,7 +122,7 @@ export default { v-if="isNotSastIACTemporaryHack" :class="statusClasses" data-testid="feature-status" - :data-qa-selector="`${feature.type}_status`" + :data-qa-feature="`${feature.type}_${enabled}_status`" > <feature-card-badge v-if="hasBadge" @@ -164,7 +164,7 @@ export default { :href="feature.configurationPath" variant="confirm" :category="configurationButton.category" - :data-qa-selector="`${feature.type}_enable_button`" + :data-testid="`${feature.type}_enable_button`" class="gl-mt-5" > {{ configurationButton.text }} @@ -176,7 +176,7 @@ export default { variant="confirm" :category="manageViaMrButtonCategory" class="gl-mt-5" - :data-qa-selector="`${feature.type}_mr_button`" + :data-testid="`${feature.type}_mr_button`" @error="onError" /> diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 6dae8e50908..578d7c8a18c 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -247,6 +247,8 @@ export default { :label="__('Training mode')" label-position="hidden" :disabled="!securityTrainingEnabled" + data-qa-selector="security_training_toggle" + :data-qa-training-provider="provider.name" @change="toggleProvider(provider)" /> <div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4"> diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js index ea835945aa9..cf6a79fe939 100644 --- a/app/assets/javascripts/sentry/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -13,7 +13,7 @@ const index = function index() { process.env.NODE_ENV === 'production' ? [gon.gitlab_url] : [gon.gitlab_url, 'webpack-internal://'], - release: gon.revision, + release: gon?.version, tags: { revision: gon?.revision, feature_category: gon?.feature_category, diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index c61c02c8b3a..ea9702258b7 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -24,6 +24,11 @@ export default { required: false, default: TYPE_ISSUE, }, + selected: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isBusy() { @@ -53,6 +58,7 @@ export default { name="warning-solid" aria-hidden="true" class="merge-icon" + :class="{ 'gl-left-6!': selected }" :size="12" /> <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2"> diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 06876546fa4..1d9233db361 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,9 +1,14 @@ <script> -import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { + GlIcon, + GlLoadingIcon, + GlDisclosureDropdownItem, + GlTooltipDirective, + GlOutsideDirective as Outside, +} from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { createAlert } from '~/alert'; import toast from '~/vue_shared/plugins/global_toast'; @@ -15,17 +20,17 @@ export default { icon: 'lock', class: 'value', iconClass: 'is-active', - displayText: __('Locked'), }, unlocked: { class: ['no-value hide-collapsed'], icon: 'lock-open', iconClass: '', - displayText: __('Unlocked'), }, components: { EditForm, GlIcon, + GlLoadingIcon, + GlDisclosureDropdownItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -39,8 +44,23 @@ export default { type: Boolean, }, }, + i18n: { + issue: __('issue'), + issueCapitalized: __('Issue'), + mergeRequest: __('merge request'), + mergeRequestCapitalized: __('Merge request'), + locked: __('Locked'), + unlocked: __('Unlocked'), + lockingMergeRequest: __('Locking %{issuableDisplayName}'), + unlockingMergeRequest: __('Unlocking %{issuableDisplayName}'), + lockMergeRequest: __('Lock %{issuableDisplayName}'), + unlockMergeRequest: __('Unlock %{issuableDisplayName}'), + lockedMessage: __('%{issuableDisplayName} locked.'), + unlockedMessage: __('%{issuableDisplayName} unlocked.'), + }, data() { return { + isLoading: false, isLockDialogOpen: false, }; }, @@ -49,18 +69,61 @@ export default { isMovedMrSidebar() { return this.glFeatures.movedMrSidebar; }, + isIssuable() { + return this.getNoteableData.targetType === TYPE_ISSUE; + }, issuableDisplayName() { - const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE; - return isInIssuePage ? __('issue') : __('merge request'); + return this.isIssuable ? this.$options.i18n.issue : this.$options.i18n.mergeRequest; + }, + issuableDisplayNameCapitalized() { + return this.isIssuable + ? this.$options.i18n.issueCapitalized + : this.$options.i18n.mergeRequestCapitalized; }, isLocked() { return this.getNoteableData.discussion_locked; }, lockStatus() { - return this.isLocked ? this.$options.locked : this.$options.unlocked; + return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked; }, tooltipLabel() { - return this.isLocked ? __('Locked') : __('Unlocked'); + return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked; + }, + lockToggleInProgressText() { + return this.isLocked ? this.unlockingMergeRequestText : this.lockingMergeRequestText; + }, + lockToggleText() { + return this.isLocked ? this.unlockMergeRequestText : this.lockMergeRequestText; + }, + lockingMergeRequestText() { + return sprintf(this.$options.i18n.lockingMergeRequest, { + issuableDisplayName: this.issuableDisplayName, + }); + }, + unlockingMergeRequestText() { + return sprintf(this.$options.i18n.unlockingMergeRequest, { + issuableDisplayName: this.issuableDisplayName, + }); + }, + lockMergeRequestText() { + return sprintf(this.$options.i18n.lockMergeRequest, { + issuableDisplayName: this.issuableDisplayName, + }); + }, + unlockMergeRequestText() { + return sprintf(this.$options.i18n.unlockMergeRequest, { + issuableDisplayName: this.issuableDisplayName, + }); + }, + lockedMessageText() { + return sprintf(this.$options.i18n.lockedMessage, { + issuableDisplayName: this.issuableDisplayNameCapitalized, + }); + }, + unlockedMessageText() { + return sprintf(this.$options.i18n.unlockedMessage, { + issuableDisplayName: this.issuableDisplayNameCapitalized, + }); }, }, @@ -88,12 +151,7 @@ export default { }) .then(() => { if (this.isMovedMrSidebar) { - toast( - sprintf(__('%{issuableDisplayName} %{lockStatus}.'), { - issuableDisplayName: capitalizeFirstCharacter(this.issuableDisplayName), - lockStatus: this.isLocked ? __('locked') : __('unlocked'), - }), - ); + toast(this.isLocked ? this.lockedMessageText : this.unlockedMessageText); } }) .catch(() => { @@ -116,18 +174,35 @@ export default { </script> <template> - <li v-if="isMovedMrSidebar" class="gl-dropdown-item"> + <li v-if="isMovedMrSidebar && isIssuable" class="gl-dropdown-item"> <button type="button" class="dropdown-item" data-testid="issuable-lock" @click="toggleLocked"> <span class="gl-dropdown-item-text-wrapper"> - <template v-if="isLocked"> - {{ sprintf(__('Unlock %{issuableType}'), { issuableType: issuableDisplayName }) }} + <template v-if="isLoading"> + <gl-loading-icon inline size="sm" /> {{ lockToggleInProgressText }} </template> <template v-else> - {{ sprintf(__('Lock %{issuableType}'), { issuableType: issuableDisplayName }) }} + {{ lockToggleText }} </template> </span> </button> </li> + <gl-disclosure-dropdown-item v-else-if="isMovedMrSidebar"> + <button + type="button" + class="gl-new-dropdown-item-content" + data-testid="issuable-lock" + @click="toggleLocked" + > + <span class="gl-new-dropdown-item-text-wrapper"> + <template v-if="isLoading"> + <gl-loading-icon inline size="sm" /> {{ lockToggleInProgressText }} + </template> + <template v-else> + {{ lockToggleText }} + </template> + </span> + </button> + </gl-disclosure-dropdown-item> <div v-else class="block issuable-sidebar-item lock"> <div v-gl-tooltip.left.viewport="{ title: tooltipLabel }" @@ -139,7 +214,7 @@ export default { </div> <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold"> - {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} + {{ lockMergeRequestText }} <a v-if="isEditable" class="float-right lock-edit btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary gl-mr-n2" @@ -164,7 +239,7 @@ export default { /> <div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class"> - {{ lockStatus.displayText }} + {{ lockStatus }} </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 1680e42e5e4..2653748861b 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -157,7 +157,6 @@ export default { :data-track-action="tracking.event" :data-track-label="tracking.label" :data-track-property="tracking.property" - data-qa-selector="edit_link" @keyup.esc="toggle" @click="toggle" > diff --git a/app/assets/javascripts/sidebar/components/status/status_dropdown.vue b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue index 7763ec00091..69ec4214712 100644 --- a/app/assets/javascripts/sidebar/components/status/status_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue @@ -1,39 +1,35 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import { statusDropdownOptions } from '../../constants'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, data() { return { status: null, + selectedValue: undefined, }; }, computed: { dropdownText() { - return this.status?.text ?? this.$options.i18n.defaultDropdownText; - }, - selectedValue() { - return this.status?.value; + const selected = this.$options.statusDropdownOptions.find( + (option) => option.value === this.selectedValue, + ); + return selected?.text || this.$options.i18n.defaultDropdownText; }, }, methods: { - onDropdownItemClick(statusOption) { - // clear status if the currently checked status is clicked again - if (this.status?.value === statusOption.value) { - this.status = null; - } else { - this.status = statusOption; - } + handleReset() { + this.selectedValue = undefined; }, }, i18n: { dropdownTitle: __('Change status'), defaultDropdownText: __('Select status'), + resetText: __('Reset'), }, statusDropdownOptions, }; @@ -41,17 +37,14 @@ export default { <template> <div> <input type="hidden" name="update[state_event]" :value="selectedValue" /> - <gl-dropdown :text="dropdownText" :title="$options.i18n.dropdownTitle" class="gl-w-full"> - <gl-dropdown-item - v-for="statusOption in $options.statusDropdownOptions" - :key="statusOption.value" - :is-checked="selectedValue === statusOption.value" - is-check-item - :title="statusOption.text" - @click="onDropdownItemClick(statusOption)" - > - {{ statusOption.text }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + v-model="selectedValue" + block + :header-text="$options.i18n.dropdownTitle" + :reset-button-label="$options.i18n.resetText" + :toggle-text="dropdownText" + :items="$options.statusDropdownOptions" + @reset="handleReset" + /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index f2b960ed02c..d6e1847aecb 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,7 +1,14 @@ <script> -import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; +import { + GlDisclosureDropdownItem, + GlDropdownForm, + GlIcon, + GlLoadingIcon, + GlToggle, + GlTooltipDirective, +} from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; +import { TYPE_ISSUE, TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -22,6 +29,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { + GlDisclosureDropdownItem, GlDropdownForm, GlIcon, GlLoadingIcon, @@ -89,6 +97,9 @@ export default { isMovedMrSidebar() { return this.glFeatures.movedMrSidebar; }, + isIssuable() { + return this.issuableType === TYPE_ISSUE; + }, isLoading() { return this.$apollo.queries?.subscribed?.loading || this.loading; }, @@ -182,18 +193,32 @@ export default { </script> <template> - <gl-dropdown-form v-if="isMovedMrSidebar" class="gl-dropdown-item"> + <gl-dropdown-form v-if="isMovedMrSidebar && isIssuable" class="gl-dropdown-item"> <div class="gl-px-5 gl-pb-2 gl-pt-1"> <gl-toggle :value="subscribed" - :label="__('Notifications')" + :label="$options.i18n.notifications" class="merge-request-notification-toggle" label-position="left" - data-testid="notifications-toggle" + data-testid="notification-toggle" @change="toggleSubscribed" /> </div> </gl-dropdown-form> + <gl-disclosure-dropdown-item + v-else-if="isMovedMrSidebar" + data-testid="notification-toggle" + @action="toggleSubscribed" + > + <template #list-item> + <gl-toggle + :value="subscribed" + :label="__('Notifications')" + class="merge-request-notification-toggle" + label-position="left" + /> + </template> + </gl-disclosure-dropdown-item> <sidebar-editable-item v-else ref="editable" diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue index 4c3ba76d12d..bacbe5d46a6 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import { subscriptionsDropdownOptions } from '../../constants'; @@ -8,27 +8,27 @@ export default { i18n: { defaultDropdownText: __('Select subscription'), headerText: __('Change subscription'), + resetText: __('Reset'), }, components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, data() { return { - subscription: undefined, + selectedValue: undefined, }; }, computed: { dropdownText() { - return this.subscription?.text ?? this.$options.i18n.defaultDropdownText; - }, - selectedValue() { - return this.subscription?.value; + const selected = this.$options.subscriptionsDropdownOptions.find( + (option) => option.value === this.selectedValue, + ); + return selected?.text || this.$options.i18n.defaultDropdownText; }, }, methods: { - handleClick(option) { - this.subscription = option.value === this.subscription?.value ? undefined : option; + handleReset() { + this.selectedValue = undefined; }, }, }; @@ -36,16 +36,14 @@ export default { <template> <div> <input type="hidden" name="update[subscription_event]" :value="selectedValue" /> - <gl-dropdown class="gl-w-full" :header-text="$options.i18n.headerText" :text="dropdownText"> - <gl-dropdown-item - v-for="subscriptionsOption in $options.subscriptionsDropdownOptions" - :key="subscriptionsOption.value" - is-check-item - :is-checked="selectedValue === subscriptionsOption.value" - @click="handleClick(subscriptionsOption)" - > - {{ subscriptionsOption.text }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + v-model="selectedValue" + block + :header-text="$options.i18n.headerText" + :reset-button-label="$options.i18n.resetText" + :toggle-text="dropdownText" + :items="$options.subscriptionsDropdownOptions" + @reset="handleReset" + /> </div> </template> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 74843bcc006..67e76b575e0 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -2,8 +2,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants'; import { gqlClient } from '~/issues/list/graphql'; import { @@ -805,8 +803,6 @@ const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; export function mountSidebar(mediator, store) { - initInviteMembersModal(); - initInviteMembersTrigger(); mountSidebarTodoWidget(); if (isAssigneesWidgetShown) { mountSidebarAssigneesWidget(); diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 853293e5eb6..074c5fda29b 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -7,7 +7,7 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants'; -import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue'; import { getSnippetMixin } from '../mixins/snippets'; import { markBlobPerformance } from '../utils/blob'; @@ -31,7 +31,14 @@ export default { mixins: [getSnippetMixin], computed: { embeddable() { - return this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING; + return ( + this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING && !this.isInPrivateProject + ); + }, + isInPrivateProject() { + const projectVisibility = this.snippet?.project?.visibility; + const isLimitedVisibilityProject = projectVisibility !== VISIBILITY_LEVEL_PUBLIC_STRING; + return projectVisibility ? isLimitedVisibilityProject : false; }, canBeCloned() { return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo); diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index 260ee496df0..59f7c8d8d97 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -2,7 +2,7 @@ import { GlButton, GlFormGroup } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { s__, sprintf } from '~/locale'; -import { SNIPPET_MAX_BLOBS } from '../constants'; +import { SNIPPET_MAX_BLOBS, SNIPPET_LIMITATIONS } from '../constants'; import { createBlob, decorateBlob, diffAll } from '../utils/blob'; import SnippetBlobEdit from './snippet_blob_edit.vue'; @@ -50,6 +50,11 @@ export default { total: SNIPPET_MAX_BLOBS, }); }, + limitationText() { + return sprintf(SNIPPET_LIMITATIONS, { + total: SNIPPET_MAX_BLOBS, + }); + }, canDelete() { return this.count > 1; }, @@ -159,5 +164,8 @@ export default { @click="addBlob" >{{ addLabel }}</gl-button > + <p v-if="!canAdd" data-testid="limitations_text" class="gl-text-secondary"> + {{ limitationText }} + </p> </div> </template> diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index 84a940ed1f8..2d2eede9137 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { VISIBILITY_LEVEL_PRIVATE_STRING, VISIBILITY_LEVEL_INTERNAL_STRING, @@ -41,3 +41,5 @@ export const SNIPPET_LEVELS_RESTRICTED = __( export const SNIPPET_LEVELS_DISABLED = __( 'Visibility settings have been disabled by the administrator.', ); + +export const SNIPPET_LIMITATIONS = s__('Snippets|Snippets are limited to %{total} files.'); diff --git a/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js b/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js new file mode 100644 index 00000000000..fa5fe02878c --- /dev/null +++ b/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js @@ -0,0 +1,80 @@ +import { localTimeAgo } from '~/lib/utils/datetime_utility'; + +const STREAMING_ELEMENT_NAME = 'streaming-element'; +const TIME_AGO_CLASS_NAME = 'js-timeago'; + +// Callback handler for intersections observed on timestamps. +const handleTimestampsIntersecting = (entries, observer) => { + entries.forEach((entry) => { + const { isIntersecting, target: timestamp } = entry; + if (isIntersecting) { + localTimeAgo([timestamp]); + observer.unobserve(timestamp); + } + }); +}; + +// Finds nodes containing the `js-timeago` class within a mutation list. +const findTimeAgoNodes = (mutationList) => { + return mutationList.reduce((acc, mutation) => { + [...mutation.addedNodes].forEach((node) => { + if (node.classList?.contains(TIME_AGO_CLASS_NAME)) { + acc.push(node); + } + }); + + return acc; + }, []); +}; + +// Callback handler for mutations observed on the streaming element. +const handleStreamingElementMutation = (mutationList) => { + const timestamps = findTimeAgoNodes(mutationList); + const timestampIntersectionObserver = new IntersectionObserver(handleTimestampsIntersecting, { + rootMargin: `${window.innerHeight}px 0px`, + }); + + timestamps.forEach((timestamp) => timestampIntersectionObserver.observe(timestamp)); +}; + +// Finds the streaming element within a mutation list. +const findStreamingElement = (mutationList) => + mutationList.find((mutation) => + [...mutation.addedNodes].find((node) => node.localName === STREAMING_ELEMENT_NAME), + )?.target; + +// Waits for the streaming element to become available on the rootElement. +const waitForStreamingElement = (rootElement) => { + return new Promise((resolve) => { + let element = document.querySelector(STREAMING_ELEMENT_NAME); + + if (element) { + resolve(element); + return; + } + + const rootElementObserver = new MutationObserver((mutations) => { + element = findStreamingElement(mutations); + if (element) { + resolve(element); + rootElementObserver.disconnect(); + } + }); + + rootElementObserver.observe(rootElement, { childList: true, subtree: true }); + }); +}; + +/** + * Ensures relative (timeago) timestamps that are streamed are formatted correctly. + * + * Example: `May 12, 2020` → `3 years ago` + */ +export const handleStreamedRelativeTimestamps = async (rootElement) => { + const streamingElement = await waitForStreamingElement(rootElement); // wait for streaming to start + const streamingElementObserver = new MutationObserver(handleStreamingElementMutation); + + streamingElementObserver.observe(streamingElement, { childList: true, subtree: true }); + + return () => streamingElementObserver.disconnect(); +}; diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue new file mode 100644 index 00000000000..c017fa8afa2 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue @@ -0,0 +1,45 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import logo from '../../../../views/shared/_logo.svg?raw'; + +export default { + logo, + i18n: { + homepage: __('Homepage'), + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + inject: ['rootPath'], + props: { + logoUrl: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <a + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" + class="tanuki-logo-container" + :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="logoUrl" + data-testid="brand-header-custom-logo" + :src="logoUrl" + class="gl-h-6 gl-max-w-full" + /> + <span v-else v-safe-html="$options.logo" data-testid="brand-header-default-logo"></span> + </a> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue index ad2111140a1..c5f3410a68f 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -5,7 +5,6 @@ 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 { trackContextAccess, formatContextSwitcherItems } from '../utils'; -import { maxSize, applyMaxSize } from '../popper_max_size_modifier'; import NavItem from './nav_item.vue'; import ProjectsList from './projects_list.vue'; import GroupsList from './groups_list.vue'; @@ -142,9 +141,6 @@ export default { }, }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS, - popperOptions: { - modifiers: [maxSize, applyMaxSize], - }, }; </script> @@ -153,7 +149,6 @@ export default { ref="disclosure-dropdown" class="context-switcher gl-w-full" placement="center" - :popper-options="$options.popperOptions" @shown="onDisclosureDropdownShown" @hidden="onDisclosureDropdownHidden" > @@ -194,6 +189,7 @@ export default { :key="item.link" :item="item" :link-classes="{ [item.link_classes]: item.link_classes }" + is-subitem /> </ul> </li> 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 cfb7e7732e9..17227a2b123 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -34,7 +34,7 @@ export default { <template> <button type="button" - 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 gl-flex-shrink-0" + 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 gl-flex-shrink-0" data-qa-selector="context_switcher" > <span diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index fa6056aff5e..0ce856c9af8 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -42,21 +42,9 @@ export default { isInvitedMembers(groupItem) { return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT; }, - closeAndFocus() { - this.$refs.dropdown.closeAndFocus(); - }, }, toggleId: 'create-menu-toggle', - popperOptions: { - modifiers: [ - { - name: 'offset', - options: { - offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], - }, - }, - ], - }, + dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET }, TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, }; </script> @@ -64,14 +52,13 @@ export default { <template> <div> <gl-disclosure-dropdown - ref="dropdown" category="tertiary" icon="plus" no-caret text-sr-only :toggle-text="$options.i18n.createNew" :toggle-id="$options.toggleId" - :popper-options="$options.popperOptions" + :dropdown-offset="$options.dropdownOffset" data-qa-selector="new_menu_toggle" data-testid="new-menu-toggle" @shown="dropdownOpen = true" @@ -89,7 +76,6 @@ export default { :key="`${groupItem.text}-trigger`" trigger-source="top-nav" :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN" - @modal-opened="closeAndFocus" /> <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" /> </template> 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 11bf2ddbd30..02adebc50af 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,19 @@ <script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import AccessorUtilities from '~/lib/utils/accessor'; +import { __ } from '~/locale'; import { getTopFrequentItems, formatContextSwitcherItems } from '../utils'; import ItemsList from './items_list.vue'; export default { components: { + GlButton, ItemsList, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { title: { type: String, @@ -68,6 +74,9 @@ export default { } }, }, + i18n: { + removeItem: __('Remove'), + }, }; </script> @@ -87,7 +96,20 @@ export default { > {{ pristineText }} </div> - <items-list :aria-label="title" :items="cachedFrequentItems" @remove-item="handleItemRemove"> + <items-list :aria-label="title" :items="cachedFrequentItems"> + <template #actions="{ item }"> + <gl-button + v-gl-tooltip.right.viewport + size="small" + category="tertiary" + icon="dash" + :aria-label="$options.i18n.removeItem" + :title="$options.i18n.removeItem" + class="gl-align-self-center gl-mr-2" + data-testid="item-remove" + @click.stop.prevent="handleItemRemove(item)" + /> + </template> <template #view-all-items> <slot name="view-all-items"></slot> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue new file mode 100644 index 00000000000..96e6c9bab9e --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue @@ -0,0 +1,191 @@ +<script> +import { debounce } from 'lodash'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { getFormattedItem } from '../utils'; +import { + COMMON_HANDLES, + COMMAND_HANDLE, + USER_HANDLE, + PROJECT_HANDLE, + ISSUE_HANDLE, + GLOBAL_COMMANDS_GROUP_TITLE, + PAGES_GROUP_TITLE, + GROUP_TITLES, +} from './constants'; +import SearchItem from './search_item.vue'; +import { commandMapper, linksReducer, autocompleteQuery } from './utils'; + +export default { + name: 'CommandPaletteItems', + components: { + GlDisclosureDropdownGroup, + GlLoadingIcon, + SearchItem, + }, + inject: ['commandPaletteCommands', 'commandPaletteLinks', 'autocompletePath', 'searchContext'], + props: { + searchQuery: { + type: String, + required: true, + }, + handle: { + type: String, + required: true, + validator: (value) => { + return COMMON_HANDLES.includes(value); + }, + }, + }, + data: () => ({ + groups: [], + error: null, + loading: false, + }), + computed: { + isCommandMode() { + return this.handle === COMMAND_HANDLE; + }, + isUserMode() { + return this.handle === USER_HANDLE; + }, + commands() { + return this.commandPaletteCommands.map(commandMapper); + }, + links() { + return this.commandPaletteLinks.reduce(linksReducer, []); + }, + filteredCommands() { + return this.searchQuery + ? this.commands + .map(({ name, items }) => { + return { + name: name || GLOBAL_COMMANDS_GROUP_TITLE, + items: this.filterBySearchQuery(items, 'text'), + }; + }) + .filter(({ items }) => items.length) + : this.commands; + }, + hasResults() { + return this.groups?.length && this.groups.some((group) => group.items?.length); + }, + hasSearchQuery() { + if (this.isCommandMode) { + return this.searchQuery?.length > 0; + } + return this.searchQuery?.length > 2; + }, + searchTerm() { + if (this.handle === ISSUE_HANDLE) { + return `${ISSUE_HANDLE}${this.searchQuery}`; + } + return this.searchQuery; + }, + }, + watch: { + searchQuery: { + handler() { + switch (this.handle) { + case COMMAND_HANDLE: + this.getCommandsAndPages(); + break; + case USER_HANDLE: + case PROJECT_HANDLE: + case ISSUE_HANDLE: + this.getScopedItems(); + break; + default: + break; + } + }, + immediate: true, + }, + }, + methods: { + filterBySearchQuery(items, key = 'keywords') { + return fuzzaldrinPlus.filter(items, this.searchQuery, { key }); + }, + getCommandsAndPages() { + if (!this.searchQuery) { + this.groups = [...this.commands]; + return; + } + const matchedLinks = this.filterBySearchQuery(this.links); + + if (this.filteredCommands.length || matchedLinks.length) { + this.groups = []; + } + + if (this.filteredCommands.length) { + this.groups = [...this.filteredCommands]; + } + + if (matchedLinks.length) { + this.groups.push({ + name: PAGES_GROUP_TITLE, + items: matchedLinks, + }); + } + }, + getScopedItems: debounce(function debouncedSearch() { + if (this.searchQuery && this.searchQuery.length < 3) return null; + + this.loading = true; + + return axios + .get( + autocompleteQuery({ + path: this.autocompletePath, + searchTerm: this.searchTerm, + handle: this.handle, + projectId: this.searchContext.project?.id, + }), + ) + .then(({ data }) => { + this.groups = this.getGroups(data); + }) + .catch((error) => { + this.error = error; + }) + .finally(() => { + this.loading = false; + }); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + getGroups(data) { + return [ + { + name: GROUP_TITLES[this.handle], + items: data.map(getFormattedItem), + }, + ]; + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <gl-loading-icon v-if="loading" size="lg" class="gl-my-5" /> + + <template v-else-if="hasResults"> + <gl-disclosure-dropdown-group + v-for="(group, index) in groups" + :key="index" + :group="group" + bordered + class="{'gl-mt-0!': index===0}" + > + <template #list-item="{ item }"> + <search-item :item="item" :search-query="searchQuery" /> + </template> + </gl-disclosure-dropdown-group> + </template> + + <div v-else-if="hasSearchQuery && !hasResults" class="gl-text-gray-700 gl-pl-5 gl-py-3"> + {{ __('No results found') }} + </div> + </ul> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js new file mode 100644 index 00000000000..9dab16984f5 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js @@ -0,0 +1,45 @@ +import { s__, sprintf } from '~/locale'; + +export const COMMAND_HANDLE = '>'; +export const USER_HANDLE = '@'; +export const PROJECT_HANDLE = '&'; +export const ISSUE_HANDLE = '#'; + +export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE]; +export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf( + s__( + 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{issueHandle} for issue or perform generic search...', + ), + { + commandHandle: COMMAND_HANDLE, + userHandle: USER_HANDLE, + issueHandle: ISSUE_HANDLE, + projectHandle: PROJECT_HANDLE, + }, + false, +); + +export const SEARCH_SCOPE_PLACEHOLDER = { + [COMMAND_HANDLE]: s__('CommandPalette|command'), + [USER_HANDLE]: s__('CommandPalette|user (enter at least 3 chars)'), + [PROJECT_HANDLE]: s__('CommandPalette|project (enter at least 3 chars)'), + [ISSUE_HANDLE]: s__('CommandPalette|issue (enter at least 3 chars)'), +}; + +export const SEARCH_SCOPE = { + [USER_HANDLE]: 'user', + [PROJECT_HANDLE]: 'project', + [ISSUE_HANDLE]: 'issue', +}; + +export const GLOBAL_COMMANDS_GROUP_TITLE = s__('CommandPalette|Global Commands'); +export const USERS_GROUP_TITLE = s__('GlobalSearch|Users'); +export const PAGES_GROUP_TITLE = s__('CommandPalette|Pages'); +export const PROJECTS_GROUP_TITLE = s__('GlobalSearch|Projects'); +export const ISSUE_GROUP_TITLE = s__('GlobalSearch|Recent issues'); + +export const GROUP_TITLES = { + [USER_HANDLE]: USERS_GROUP_TITLE, + [PROJECT_HANDLE]: PROJECTS_GROUP_TITLE, + [ISSUE_HANDLE]: ISSUE_GROUP_TITLE, +}; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue new file mode 100644 index 00000000000..dce2b24f551 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue @@ -0,0 +1,42 @@ +<script> +import { COMMON_HANDLES, SEARCH_SCOPE_PLACEHOLDER } from './constants'; + +export default { + name: 'FakeSearchInput', + props: { + userInput: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + validator: (value) => COMMON_HANDLES.includes(value), + }, + }, + computed: { + placeholder() { + return SEARCH_SCOPE_PLACEHOLDER[this.scope]; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-pointer-events-none fake-input"> + <span class="gl-opacity-0" data-testid="search-scope">{{ scope }} </span> + <span + v-if="!userInput" + data-testid="search-scope-placeholder" + class="gl-text-gray-500 gl-pointer-events-none" + >{{ placeholder }}</span + > + </div> +</template> + +<style scoped> +.fake-input { + top: 12px; + left: 33px; +} +</style> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue new file mode 100644 index 00000000000..b940c7c24c6 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue @@ -0,0 +1,57 @@ +<script> +import { GlAvatar, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import highlight from '~/lib/utils/highlight'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; + +export default { + name: 'CommandPaletteSearchItem', + components: { + GlAvatar, + GlIcon, + }, + directives: { + SafeHtml, + }, + props: { + item: { + type: Object, + required: true, + }, + searchQuery: { + type: String, + required: true, + }, + }, + computed: { + highlightedName() { + return highlight(this.item.text, this.searchQuery); + }, + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + 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" + /> + <gl-icon v-if="item.icon" class="gl-mr-3" :name="item.icon" /> + <span class="gl-display-flex gl-flex-direction-column"> + <span v-safe-html="highlightedName" class="gl-text-gray-900"></span> + <span + v-if="item.namespace" + v-safe-html="item.namespace" + class="gl-font-sm gl-text-gray-500" + ></span> + </span> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js new file mode 100644 index 00000000000..5c8c0e59eaf --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js @@ -0,0 +1,47 @@ +import { isNil, omitBy } from 'lodash'; +import { objectToQuery } from '~/lib/utils/url_utility'; +import { SEARCH_SCOPE } from './constants'; + +export const commandMapper = ({ name, items }) => { + // TODO: we filter out invite_members for now, because it is complicated to add the invite members modal here + // and is out of scope for the basic command palette items. If it proves to be useful, we can add it later. + return { + name, + items: items.filter(({ component }) => component !== 'invite_members'), + }; +}; + +export const linksReducer = (acc, menuItem) => { + acc.push({ + text: menuItem.title, + keywords: menuItem.title, + icon: menuItem.icon, + href: menuItem.link, + }); + if (menuItem.items?.length) { + const items = menuItem.items.map(({ title, link }) => ({ + keywords: title, + text: [menuItem.title, title].join(' > '), + href: link, + icon: menuItem.icon, + })); + + /* eslint-disable-next-line no-param-reassign */ + acc = [...acc, ...items]; + } + return acc; +}; + +export const autocompleteQuery = ({ path, searchTerm, handle, projectId }) => { + const query = omitBy( + { + term: searchTerm, + project_id: projectId, + filter: 'search', + scope: SEARCH_SCOPE[handle], + }, + isNil, + ); + + return `${path}?${objectToQuery(query)}`; +}; 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 55c28661440..cb34f2b8c26 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 @@ -24,6 +24,7 @@ import { SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, } from '~/vue_shared/global_search/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, @@ -35,6 +36,9 @@ import { SEARCH_INPUT_SELECTOR, SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; +import CommandPaletteItems from '../command_palette/command_palette_items.vue'; +import FakeSearchInput from '../command_palette/fake_search_input.vue'; +import { COMMON_HANDLES, SEARCH_OR_COMMAND_MODE_PLACEHOLDER } from '../command_palette/constants'; import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; import GlobalSearchDefaultItems from './global_search_default_items.vue'; import GlobalSearchScopedItems from './global_search_scoped_items.vue'; @@ -60,7 +64,10 @@ export default { GlIcon, GlToken, GlModal, + CommandPaletteItems, + FakeSearchInput, }, + mixins: [glFeatureFlagMixin()], computed: { ...mapState(['search', 'loading', 'searchContext']), ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), @@ -72,6 +79,9 @@ export default { this.setSearch(value); }, }, + searchPlaceholder() { + return this.glFeatures?.commandPalette ? SEARCH_OR_COMMAND_MODE_PLACEHOLDER : SEARCH_GITLAB; + }, showDefaultItems() { return !this.searchText; }, @@ -104,7 +114,7 @@ export default { }; }, showScopeHelp() { - return this.searchTermOverMin; + return this.searchTermOverMin && !this.isCommandMode; }, searchBarItem() { return this.searchOptions?.[0]; @@ -120,10 +130,26 @@ export default { scope: this.infieldHelpContent, }); }, + + searchTextFirstChar() { + return this.searchText?.trim().charAt(0); + }, + isCommandMode() { + return this.glFeatures?.commandPalette && COMMON_HANDLES.includes(this.searchTextFirstChar); + }, + commandPaletteQuery() { + if (this.isCommandMode) { + return this.searchText?.trim().substring(1); + } + return ''; + }, }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { + if (this.isCommandMode) { + return; + } if (!searchTerm) { this.clearAutocomplete(); } else { @@ -222,12 +248,12 @@ export default { > <form role="search" - :aria-label="$options.i18n.SEARCH_GITLAB" + :aria-label="searchPlaceholder" class="gl-relative gl-rounded-base gl-w-full" :class="searchBarClasses" data-testid="global-search-form" > - <div class="gl-p-1"> + <div class="gl-p-1 gl-relative"> <gl-search-box-by-type id="search" ref="searchInputBox" @@ -236,7 +262,7 @@ export default { data-testid="global-search-input" data-qa-selector="global_search_input" autocomplete="off" - :placeholder="$options.i18n.SEARCH_GITLAB" + :placeholder="searchPlaceholder" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" borderless @input="getAutocompleteOptions" @@ -266,6 +292,13 @@ export default { <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} </span> + + <fake-search-input + v-if="isCommandMode" + :user-input="commandPaletteQuery" + :scope="searchTextFirstChar" + class="gl-absolute" + /> </div> <span role="region" @@ -282,13 +315,20 @@ export default { class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" @keydown="onKeydown" > - <global-search-default-items v-if="showDefaultItems" /> + <command-palette-items + v-if="isCommandMode" + :search-query="commandPaletteQuery" + :handle="searchTextFirstChar" + /> + <template v-else> - <global-search-scoped-items v-if="showScopedSearchItems" /> - <global-search-autocomplete-items /> + <global-search-default-items v-if="showDefaultItems" /> + <template v-else> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> + </template> </template> </div> - <template v-if="searchContext"> <input v-if="searchContext.group" diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue index 4fa15f1cd76..48becacebb7 100644 --- a/app/assets/javascripts/super_sidebar/components/groups_list.vue +++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue @@ -64,7 +64,7 @@ export default { :search-results="searchResults" > <template #view-all-items> - <nav-item v-bind="viewAllProps" /> + <nav-item v-bind="viewAllProps" is-subitem /> </template> </search-results> <frequent-items-list @@ -75,7 +75,7 @@ export default { :pristine-text="$options.i18n.pristineText" > <template #view-all-items> - <nav-item v-bind="viewAllProps" /> + <nav-item v-bind="viewAllProps" is-subitem /> </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 1fffbb05d03..1d4c24c6853 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -8,7 +8,7 @@ import { } from '@gitlab/ui'; import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import { FORUM_URL, DOCS_URL, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; import Tracking from '~/tracking'; @@ -70,7 +70,7 @@ export default { helpLinks: { items: [ this.sidebarData.show_tanuki_bot && { - icon: 'tanuki', + icon: 'tanuki-ai', text: this.$options.i18n.chat, action: this.showTanukiBotChat, extraAttrs: { @@ -93,7 +93,7 @@ export default { }, { text: this.$options.i18n.docs, - href: `https://docs.${DOMAIN}`, + href: DOCS_URL, extraAttrs: { ...this.trackingAttrs('gitlab_documentation'), }, @@ -107,7 +107,7 @@ export default { }, { text: this.$options.i18n.forum, - href: `https://forum.${DOMAIN}/`, + href: FORUM_URL, extraAttrs: { ...this.trackingAttrs('community_forum'), }, @@ -132,7 +132,7 @@ export default { items: [ { text: this.$options.i18n.shortcuts, - action: this.showKeyboardShortcuts, + action: () => {}, extraAttrs: { class: 'js-shortcuts-modal-trigger', 'data-track-action': 'click_button', @@ -172,18 +172,11 @@ export default { return true; }, - showKeyboardShortcuts() { - this.$refs.dropdown.close(); - }, - showTanukiBotChat() { - this.$refs.dropdown.close(); - this.helpCenterState.showTanukiBotChatDrawer = true; }, async showWhatsNew() { - this.$refs.dropdown.close(); this.showWhatsNewNotification = false; if (!this.toggleWhatsNewDrawer) { @@ -211,29 +204,23 @@ export default { }); }, }, - popperOptions: { - modifiers: [ - { - name: 'offset', - options: { - offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], - }, - }, - ], - }, + dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET }, }; </script> <template> <gl-disclosure-dropdown - ref="dropdown" - :popper-options="$options.popperOptions" + :dropdown-offset="$options.dropdownOffset" @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> + <span + v-if="showWhatsNewNotification" + data-testid="notification-dot" + class="notification-dot-info" + ></span> {{ $options.i18n.help }} </gl-button> </template> @@ -263,7 +250,7 @@ export default { <template #list-item="{ item }"> <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> {{ item.text }} - <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-orange-500" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-purple-600" /> </span> </template> </gl-disclosure-dropdown-group> diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue index ef27251dc6c..7d5af883651 100644 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -1,17 +1,12 @@ <script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import NavItem from './nav_item.vue'; export default { components: { - GlButton, ProjectAvatar, NavItem, }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { items: { type: Array, @@ -29,6 +24,7 @@ export default { :key="item.id" :item="item" :link-classes="{ 'gl-py-2!': true }" + is-subitem > <template #icon> <project-avatar @@ -37,20 +33,11 @@ export default { :project-avatar-url="item.avatar" :size="24" aria-hidden="true" + class="gl-mr-n2" /> </template> <template #actions> - <gl-button - v-gl-tooltip.right.viewport - size="small" - category="tertiary" - icon="dash" - :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)" - /> + <slot name="actions" :item="item"></slot> </template> </nav-item> <slot name="view-all-items"></slot> diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index 93c249dffeb..b5a8241a286 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -71,7 +71,7 @@ export default { <component :is="tag"> <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" /> <button - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 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! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-line-height-normal gl-mb-2 gl-py-3 gl-px-0 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" :class="computedLinkClasses" data-qa-selector="menu_section_button" :data-qa-section-name="item.title" diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index ec1c4069b1a..0ee9db10ee2 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -51,6 +51,11 @@ export default { required: false, default: () => ({}), }, + isSubitem: { + type: Boolean, + required: false, + default: false, + }, }, computed: { pillData() { @@ -99,6 +104,7 @@ export default { return { 'gl-py-2': this.isPinnable, 'gl-py-3': !this.isPinnable, + 'gl-mx-2': this.isSubitem, [this.item.link_classes]: this.item.link_classes, ...this.linkClasses, }; @@ -106,6 +112,9 @@ export default { navItemLinkComponent() { return this.item.to ? NavItemRouterLink : NavItemLink; }, + iconClasses() { + return this.isSubitem === true ? 'gl-ml-2 gl-mr-4' : 'gl-w-6 gl-mx-3'; + }, }, }; </script> @@ -128,7 +137,7 @@ export default { style="width: 3px; border-radius: 3px; margin-right: 1px" data-testid="active-indicator" ></div> - <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> + <div :class="iconClasses" class="gl-flex-shrink-0"> <slot name="icon"> <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> <gl-icon @@ -138,14 +147,14 @@ export default { /> </slot> </div> - <div class="gl-pr-8 gl-text-gray-900 gl-truncate-end"> + <div class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end"> {{ item.title }} <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end"> {{ item.subtitle }} </div> </div> <slot name="actions"></slot> - <span v-if="hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-relative"> + <span v-if="hasPill || isPinnable" class="gl-text-right gl-mr-3 gl-relative"> <gl-badge v-if="hasPill" size="sm" diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue index 78860e35eb1..8d1a5c825b5 100644 --- a/app/assets/javascripts/super_sidebar/components/projects_list.vue +++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue @@ -65,7 +65,7 @@ export default { :search-results="searchResults" > <template #view-all-items> - <nav-item v-bind="viewAllProps" /> + <nav-item v-bind="viewAllProps" is-subitem /> </template> </search-results> <frequent-items-list @@ -76,7 +76,7 @@ export default { :pristine-text="$options.i18n.pristineText" > <template #view-all-items> - <nav-item v-bind="viewAllProps" /> + <nav-item v-bind="viewAllProps" is-subitem /> </template> </frequent-items-list> </template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 08af9232107..287e4f57d01 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -1,6 +1,7 @@ <script> import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; import { PANELS_WITH_PINS } from '../constants'; import NavItem from './nav_item.vue'; import PinnedSection from './pinned_section.vue'; @@ -42,6 +43,10 @@ export default { }, }, + i18n: { + mainNavigation: s__('Navigation|Main navigation'), + }, + data() { return { // This is used as a provide and injected into the nav items. @@ -137,8 +142,8 @@ export default { </script> <template> - <nav class="gl-p-2 gl-relative"> - <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0"> + <nav :aria-label="$options.i18n.mainNavigation" class="gl-p-2 gl-relative"> + <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0" data-testid="static-items-section"> <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> </ul> <pinned-section @@ -154,7 +159,7 @@ export default { class="gl-my-2 gl-mx-4" data-testid="main-menu-separator" /> - <ul class="gl-p-0 gl-list-style-none"> + <ul class="gl-p-0 gl-list-style-none" data-testid="non-static-items-section"> <template v-for="item in nonStaticItems"> <menu-section v-if="isSection(item)" diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 768914584e8..d3b2143aaa7 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,13 +1,12 @@ <script> import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import SafeHtml from '~/vue_shared/directives/safe_html'; import { destroyUserCountsManager, createUserCountsManager, userCounts, } from '~/super_sidebar/user_counts_manager'; -import logo from '../../../../views/shared/_logo.svg'; +import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue'; import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants'; import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; @@ -20,7 +19,6 @@ 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: { @@ -35,6 +33,7 @@ export default { /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue' ), SuperSidebarToggle, + BrandLogo, }, i18n: { createNew: __('Create new...'), @@ -53,9 +52,8 @@ export default { directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, - SafeHtml, }, - inject: ['rootPath', 'isImpersonating'], + inject: ['isImpersonating'], props: { hasCollapseButton: { default: true, @@ -107,23 +105,7 @@ export default { <template> <div class="user-bar"> <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" - class="tanuki-logo-container" - :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" - :src="sidebarData.logo_url" - class="gl-h-6" - /> - <span v-else v-safe-html="$options.logo"></span> - </a> + <brand-logo :logo-url="sidebarData.logo_url" /> <gl-badge v-if="sidebarData.gitlab_com_and_canary" variant="success" @@ -168,6 +150,7 @@ export default { category="tertiary" data-method="delete" data-testid="stop-impersonation-btn" + data-qa-selector="stop_impersonation_link" /> </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index cd5a83c86cc..7d4991fbe96 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -221,16 +221,7 @@ export default { }); }, }, - popperOptions: { - modifiers: [ - { - name: 'offset', - options: { - offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], - }, - }, - ], - }, + dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET }, }; </script> @@ -238,9 +229,10 @@ export default { <div> <gl-disclosure-dropdown ref="userDropdown" - :popper-options="$options.popperOptions" + :dropdown-offset="$options.dropdownOffset" data-testid="user-dropdown" data-qa-selector="user_menu" + :auto-close="false" @shown="onShow" > <template #toggle> diff --git a/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js deleted file mode 100644 index 6581d521107..00000000000 --- a/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js +++ /dev/null @@ -1,43 +0,0 @@ -import { detectOverflow } from '@popperjs/core'; - -/** - * These modifiers were copied from the community modifier popper-max-size-modifier - * https://www.npmjs.com/package/popper-max-size-modifier. - * We are considering upgrading Popper.js to Floating UI, at which point the behavior this - * introduces will be available out of the box. - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2213 - */ - -export const maxSize = { - name: 'maxSize', - enabled: true, - phase: 'main', - requiresIfExists: ['offset', 'preventOverflow', 'flip'], - fn({ state, name }) { - const overflow = detectOverflow(state); - const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 }; - const { width, height } = state.rects.popper; - const [basePlacement] = state.placement.split('-'); - - const widthProp = basePlacement === 'left' ? 'left' : 'right'; - const heightProp = basePlacement === 'top' ? 'top' : 'bottom'; - - state.modifiersData[name] = { - width: width - overflow[widthProp] - x, - height: height - overflow[heightProp] - y, - }; - }, -}; - -export const applyMaxSize = { - name: 'applyMaxSize', - enabled: true, - phase: 'write', - requires: ['maxSize'], - fn({ state }) { - // The `maxSize` modifier provides this data - const { width, height } = state.modifiersData.maxSize; - state.elements.popper.style.maxWidth = `${width}px`; - state.elements.popper.style.maxHeight = `${height}px`; - }, -}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 63424277ffc..f6afde02fa5 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -72,6 +72,8 @@ export const initSuperSidebar = () => { const sidebarData = JSON.parse(sidebar); const searchData = convertObjectPropsToCamelCase(sidebarData.search); + const commandPaletteCommands = sidebarData.create_new_menu_groups || []; + const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []); const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; const isImpersonating = parseBoolean(sidebarData.is_impersonating); @@ -85,6 +87,10 @@ export const initSuperSidebar = () => { toggleNewNavEndpoint, isImpersonating, ...getTrialStatusWidgetData(sidebarData), + commandPaletteCommands, + commandPaletteLinks, + autocompletePath, + searchContext, }, store: createStore({ searchPath, 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 1a359533435..2687ea5ccf8 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 @@ -24,7 +24,7 @@ export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => { findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed); sidebarState.isPeek = false; - sidebarState.isPeekable = Boolean(gon.features?.superSidebarPeek) && collapsed; + sidebarState.isPeekable = collapsed; sidebarState.isCollapsed = collapsed; if (saveCookie && isDesktopBreakpoint()) { diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue index 6e90ad2e0fd..333059b5340 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.vue +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg'; +import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg?raw'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, __ } from '~/locale'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue index 036ce2cca78..bb4f3ac0571 100644 --- a/app/assets/javascripts/tags/components/sort_dropdown.vue +++ b/app/assets/javascripts/tags/components/sort_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui'; import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -8,8 +8,7 @@ export default { searchPlaceholder: s__('TagsPage|Filter by tag name'), }, components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlSearchBoxByClick, }, inject: ['sortOptions', 'filterTagsPath'], @@ -23,6 +22,11 @@ export default { selectedSortMethod() { return this.sortOptions[this.selectedKey]; }, + sortOptionsListboxItems() { + return Object.entries(this.sortOptions).map(([value, text]) => { + return { value, text }; + }); + }, }, created() { const sortValue = getParameterValues('sort'); @@ -37,9 +41,6 @@ export default { } }, methods: { - isSortMethodSelected(sortKey) { - return sortKey === this.selectedKey; - }, visitUrlFromOption(sortKey) { this.selectedKey = sortKey; const urlParams = {}; @@ -62,16 +63,13 @@ export default { data-testid="tag-search" @submit="visitUrlFromOption(selectedKey)" /> - <gl-dropdown :text="selectedSortMethod" right data-testid="tags-dropdown"> - <gl-dropdown-item - v-for="(value, key) in sortOptions" - :key="key" - :is-checked="isSortMethodSelected(key)" - is-check-item - @click="visitUrlFromOption(key)" - > - {{ value }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + v-model="selectedKey" + data-testid="tags-dropdown" + :items="sortOptionsListboxItems" + placement="right" + :toggle-text="selectedSortMethod" + @select="visitUrlFromOption" + /> </div> </template> diff --git a/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js new file mode 100644 index 00000000000..a572d5af31b --- /dev/null +++ b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js @@ -0,0 +1,46 @@ +import SectionedPercentageBar from './sectioned_percentage_bar.vue'; + +export default { + component: SectionedPercentageBar, + title: 'usage_quotas/sectioned_percentage_bar', +}; + +const Template = (args, { argTypes }) => ({ + components: { SectionedPercentageBar }, + props: Object.keys(argTypes), + template: '<sectioned-percentage-bar :sections="sections" />', +}); + +export const Default = Template.bind({}); +Default.args = { + sections: [ + { + id: 'artifacts', + label: 'Artifacts', + value: 2000, + formattedValue: '1.95 KiB', + cssClasses: 'gl-bg-data-viz-blue-500', + }, + { + id: 'repository', + label: 'Repository', + value: 4000, + formattedValue: '3.90 KiB', + cssClasses: 'gl-bg-data-viz-orange-500', + }, + { + id: 'packages', + label: 'Packages', + value: 3000, + formattedValue: '2.93 KiB', + cssClasses: 'gl-bg-data-viz-aqua-500', + }, + { + id: 'registry', + label: 'Registry', + value: 5000, + formattedValue: '4.88 KiB', + cssClasses: 'gl-bg-data-viz-green-500', + }, + ], +}; diff --git a/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue new file mode 100644 index 00000000000..3d9ce591450 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue @@ -0,0 +1,87 @@ +<script> +import { colorFromDefaultPalette } from '@gitlab/ui/dist/utils/charts/theme'; +import { roundOffFloat } from '~/lib/utils/common_utils'; +import { formatNumber } from '~/locale'; + +export default { + props: { + /** + * { + * id: string; + * label: string; + * value: number; + * formattedValue: number | string; + * }[] + */ + sections: { + type: Array, + required: true, + }, + }, + computed: { + sectionsCombinedValue() { + return this.sections.reduce((accumulator, section) => { + return accumulator + section.value; + }, 0); + }, + computedSections() { + return this.sections.map((section, index) => { + const percentage = section.value / this.sectionsCombinedValue; + + return { + ...section, + backgroundColor: colorFromDefaultPalette(index), + cssPercentage: `${roundOffFloat(percentage * 100, 4)}%`, + srLabelPercentage: formatNumber(percentage, { + style: 'percent', + minimumFractionDigits: 1, + }), + }; + }); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-rounded-pill gl-overflow-hidden gl-w-full"> + <div + v-for="{ id, label, backgroundColor, cssPercentage, srLabelPercentage } in computedSections" + :key="id" + class="gl-h-5" + :style="{ + backgroundColor, + width: cssPercentage, + }" + :data-testid="`percentage-bar-section-${id}`" + > + <span class="gl-sr-only">{{ label }} {{ srLabelPercentage }}</span> + </div> + </div> + <div class="gl-mt-5"> + <div class="gl-display-flex gl-align-items-center gl-flex-wrap gl-my-n3 gl-mx-n3"> + <div + v-for="{ id, label, backgroundColor, formattedValue } in computedSections" + :key="id" + class="gl-display-flex gl-align-items-center gl-p-3" + :data-testid="`percentage-bar-legend-section-${id}`" + > + <div + class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" + :style="{ backgroundColor }" + data-testid="legend-section-color" + ></div> + <p class="gl-m-0 gl-font-sm"> + <span class="gl-mr-2 gl-font-weight-bold"> + {{ label }} + </span> + <span class="gl-text-gray-500"> + {{ formattedValue }} + </span> + </p> + </div> + </div> + </div> + </div> +</template> 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 beff3b4c0c3..ce487beca07 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 @@ -82,7 +82,15 @@ export default { /> <div> <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> - {{ item.storageType.name }} + <gl-link + v-if="item.storageType.detailsPath && item.value" + :data-testid="`${item.storageType.id}-details-link`" + :href="item.storageType.detailsPath" + >{{ item.storageType.name }}</gl-link + > + <template v-else> + {{ item.storageType.name }} + </template> <gl-link v-if="item.storageType.helpPath" :href="item.storageType.helpPath" 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 5142c2c0915..70dd1a841b2 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 @@ -14,10 +14,10 @@ export default { iconName(storageTypeName) { const defaultStorageTypeIcon = 'disk'; const storageTypeIconMap = { - lfsObjectsSize: 'doc-image', - snippetsSize: 'snippet', - repositorySize: 'infrastructure-registry', - packagesSize: 'package', + lfsObjects: 'doc-image', + snippets: 'snippet', + repository: 'infrastructure-registry', + packages: 'package', }; return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon; 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 e9683924ff8..cdaba2ad3f9 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -1,11 +1,10 @@ <script> 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 { - mixins: [glFeatureFlagMixin()], + name: 'UsageGraph', props: { rootStorageStatistics: { required: true, @@ -36,49 +35,49 @@ export default { return [ { - id: 'repositorySize', + id: 'repository', style: this.usageStyle(this.barRatio(repositorySize)), class: 'gl-bg-data-viz-blue-500', size: repositorySize, }, { - id: 'lfsObjectsSize', + id: 'lfsObjects', style: this.usageStyle(this.barRatio(lfsObjectsSize)), class: 'gl-bg-data-viz-orange-600', size: lfsObjectsSize, }, { - id: 'packagesSize', + id: 'packages', style: this.usageStyle(this.barRatio(packagesSize)), class: 'gl-bg-data-viz-aqua-500', size: packagesSize, }, { - id: 'containerRegistrySize', + id: 'containerRegistry', style: this.usageStyle(this.barRatio(containerRegistrySize)), class: 'gl-bg-data-viz-aqua-800', size: containerRegistrySize, }, { - id: 'buildArtifactsSize', + id: 'buildArtifacts', style: this.usageStyle(this.barRatio(buildArtifactsSize)), class: 'gl-bg-data-viz-green-500', size: buildArtifactsSize, }, { - id: 'pipelineArtifactsSize', + id: 'pipelineArtifacts', style: this.usageStyle(this.barRatio(pipelineArtifactsSize)), class: 'gl-bg-data-viz-green-800', size: pipelineArtifactsSize, }, { - id: 'wikiSize', + id: 'wiki', style: this.usageStyle(this.barRatio(wikiSize)), class: 'gl-bg-data-viz-magenta-500', size: wikiSize, }, { - id: 'snippetsSize', + id: 'snippets', style: this.usageStyle(this.barRatio(snippetsSize)), class: 'gl-bg-data-viz-orange-800', size: snippetsSize, diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js index 8e3eaff4496..f08e8db26b9 100644 --- a/app/assets/javascripts/usage_quotas/storage/constants.js +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -26,44 +26,44 @@ export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); export const PROJECT_STORAGE_TYPES = [ { - id: 'containerRegistrySize', + id: 'containerRegistry', name: __('Container Registry'), description: s__( 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.', ), }, { - id: 'buildArtifactsSize', + id: 'buildArtifacts', name: __('Job artifacts'), description: s__('UsageQuota|Job artifacts created by CI/CD.'), }, { - id: 'pipelineArtifactsSize', + id: 'pipelineArtifacts', name: __('Pipeline artifacts'), description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'), }, { - id: 'lfsObjectsSize', + id: 'lfsObjects', name: __('LFS'), description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), }, { - id: 'packagesSize', + id: 'packages', name: __('Packages'), description: s__('UsageQuota|Code packages and container images.'), }, { - id: 'repositorySize', + id: 'repository', name: __('Repository'), description: s__('UsageQuota|Git repository.'), }, { - id: 'snippetsSize', + id: 'snippets', name: __('Snippets'), description: s__('UsageQuota|Shared bits of code and text.'), }, { - id: 'wikiSize', + id: 'wiki', name: __('Wiki'), description: s__('UsageQuota|Wiki content.'), }, 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 d254f576219..85a181d3e01 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 @@ -1,6 +1,14 @@ query getProjectStorageStatistics($fullPath: ID!) { project(fullPath: $fullPath) { id + statisticsDetailsPaths { + containerRegistry + buildArtifacts + packages + repository + snippets + wiki + } statistics { containerRegistrySize buildArtifactsSize diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js index 443788f650d..0460cd0a9b2 100644 --- a/app/assets/javascripts/usage_quotas/storage/utils.js +++ b/app/assets/javascripts/usage_quotas/storage/utils.js @@ -1,17 +1,23 @@ import { numberToHumanSize } from '~/lib/utils/number_utils'; import { PROJECT_STORAGE_TYPES } from './constants'; -export const getStorageTypesFromProjectStatistics = (projectStatistics, helpLinks = {}) => +export const getStorageTypesFromProjectStatistics = ( + projectStatistics, + helpLinks = {}, + statisticsDetailsPaths = {}, +) => PROJECT_STORAGE_TYPES.reduce((types, currentType) => { - const helpPathKey = currentType.id.replace(`Size`, ``); - const helpPath = helpLinks[helpPathKey]; + const helpPath = helpLinks[currentType.id]; + const value = projectStatistics[`${currentType.id}Size`]; + const detailsPath = statisticsDetailsPaths[currentType.id]; return types.concat({ storageType: { ...currentType, helpPath, + detailsPath, }, - value: projectStatistics[currentType.id], + value, }); }, []); @@ -27,7 +33,11 @@ export const parseGetProjectStorageResults = (data, helpLinks) => { return {}; } const { storageSize } = projectStatistics; - const storageTypes = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks); + const storageTypes = getStorageTypesFromProjectStatistics( + projectStatistics, + helpLinks, + data?.project?.statisticsDetailsPaths, + ); return { storage: { 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 25cf5335fb5..95fa01c23f1 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 @@ -7,7 +7,6 @@ import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __, sprintf } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; import StateContainer from '../state_container.vue'; import { INVALID_RULES_DOCS_PATH } from '../../constants'; @@ -187,7 +186,7 @@ export default { this.updateApproval( () => this.service.approveMergeRequestWithAuth(data), (error) => { - if (error && error.response && error.response.status === HTTP_STATUS_UNAUTHORIZED) { + if (error?.response?.status === HTTP_STATUS_UNAUTHORIZED) { this.hasApprovalAuthError = true; return; } @@ -215,11 +214,6 @@ export default { this.clearError(); return serviceFn() .then(() => { - if (!window.gon?.features?.realtimeMrStatusChange) { - eventHub.$emit('MRWidgetUpdateRequested'); - eventHub.$emit('ApprovalUpdated'); - } - // TODO: Remove this line when we move to Apollo subscriptions this.$apollo.queries.approvals.refetch(); }) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue index bdd46d6a656..a3d5a6bed11 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { RUNNING } from './constants'; +import { RUNNING, WILL_DEPLOY } from './constants'; export default { name: 'DeploymentActionButton', @@ -42,40 +41,50 @@ export default { }, computed: { isActionInProgress() { - return Boolean(this.computedDeploymentStatus === RUNNING || this.actionInProgress); - }, - actionInProgressTooltip() { - switch (this.actionInProgress) { - case this.actionsConfiguration.actionName: - return this.actionsConfiguration.busyText; - case null: - return ''; - default: - return __('Another action is currently in progress'); - } + return Boolean( + this.computedDeploymentStatus === RUNNING || + this.computedDeploymentStatus === WILL_DEPLOY || + this.actionInProgress, + ); }, isLoading() { - return this.actionInProgress === this.actionsConfiguration.actionName; + return ( + this.actionInProgress === this.actionsConfiguration.actionName || + this.computedDeploymentStatus === WILL_DEPLOY + ); }, }, }; </script> <template> - <span v-gl-tooltip :title="actionInProgressTooltip" class="gl-display-inline-block" tabindex="0"> - <gl-button - v-gl-tooltip - category="primary" - size="small" - :title="buttonTitle" - :aria-label="buttonTitle" - :loading="isLoading" - :disabled="isActionInProgress" - :class="`inline gl-ml-3 ${containerClasses}`" - :icon="icon" - @click="$emit('click')" - > - <slot> </slot> - </gl-button> - </span> + <gl-button + v-if="isLoading || isActionInProgress" + category="primary" + size="small" + :title="buttonTitle" + :aria-label="buttonTitle" + :loading="isLoading" + :disabled="isActionInProgress" + :class="`inline gl-ml-3 ${containerClasses}`" + :icon="icon" + @click="$emit('click')" + > + <slot> </slot> + </gl-button> + <gl-button + v-else + v-gl-tooltip.hover + category="primary" + size="small" + :title="buttonTitle" + :aria-label="buttonTitle" + :loading="isLoading" + :disabled="isActionInProgress" + :class="`inline gl-ml-3 ${containerClasses}`" + :icon="icon" + @click="$emit('click')" + > + <slot> </slot> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 306ed664326..e79d2db4b5a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -71,11 +71,25 @@ export default { return this.deployment.details?.playable_build?.play_path; }, redeployPath() { + if (this.redeployMrWidgetFeatureFlagEnabled) { + return this.deployment.retry_url; + } return this.deployment.details?.playable_build?.retry_path; }, stopUrl() { return this.deployment.stop_url; }, + environmentAvailable() { + return Boolean(this.deployment.environment_available); + }, + redeployMrWidgetFeatureFlagEnabled() { + return this.glFeatures.reviewAppsRedeployMrWidget; + }, + showDeploymentActionButton() { + return ( + this.redeployPath && !this.environmentAvailable && this.redeployMrWidgetFeatureFlagEnabled + ); + }, }, actionsConfiguration: { [STOPPING]: { @@ -124,6 +138,10 @@ export default { MRWidgetService.executeInlineAction(endpoint) .then((resp) => { + if (this.redeployMrWidgetFeatureFlagEnabled) { + return; + } + const redirectUrl = resp?.data?.redirect_url; if (redirectUrl) { visitUrl(redirectUrl); @@ -167,7 +185,7 @@ export default { <span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span> </deployment-action-button> <deployment-action-button - v-if="canBeManuallyRedeployed" + v-if="canBeManuallyRedeployed && !redeployMrWidgetFeatureFlagEnabled" :action-in-progress="actionInProgress" :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]" :computed-deployment-status="computedDeploymentStatus" @@ -178,12 +196,12 @@ export default { <span>{{ $options.actionsConfiguration[constants.REDEPLOYING].buttonText }}</span> </deployment-action-button> <deployment-view-button - v-if="hasExternalUrls" + v-if="hasExternalUrls && environmentAvailable" :app-button-text="appButtonText" :deployment="deployment" /> <deployment-action-button - v-if="stopUrl" + v-if="stopUrl && environmentAvailable" :action-in-progress="actionInProgress" :computed-deployment-status="computedDeploymentStatus" :actions-configuration="$options.actionsConfiguration[constants.STOPPING]" @@ -192,5 +210,15 @@ export default { container-classes="js-stop-env" @click="stopEnvironment" /> + <deployment-action-button + v-if="showDeploymentActionButton" + :action-in-progress="actionInProgress" + :computed-deployment-status="computedDeploymentStatus" + :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]" + :button-title="$options.actionsConfiguration[constants.REDEPLOYING].buttonText" + :icon="$options.btnIcons.repeat" + container-classes="js-redeploy-action" + @click="redeploy" + /> </div> </template> 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 17c51bc4e6e..9258bc39bcb 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 @@ -55,7 +55,6 @@ export default { // If state is merged we should update the widget and stop the polling eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('FetchActionsContent'); - MergeRequest.hideCloseButton(); MergeRequest.decreaseCounter(); stopPolling(); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue new file mode 100644 index 00000000000..1dc4270f054 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue @@ -0,0 +1,23 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; + +import { MR_WIDGET_PREPARING_ASYNCHRONOUSLY } from '../../i18n'; + +export default { + name: 'MRWidgetPreparing', + i18n: { + preparing: MR_WIDGET_PREPARING_ASYNCHRONOUSLY, + }, + components: { + GlLoadingIcon, + }, +}; +</script> +<template> + <div class="gl-w-full gl-display-flex gl-p-4"> + <gl-loading-icon size="md" class="gl-pr-4" inline /> + <div class="gl-display-flex gl-align-items-center"> + {{ $options.i18n.preparing }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index 30cd9fa752f..e1c54a8827c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,27 +1,14 @@ <script> -import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-merge-requests-md.svg?url'; -import api from '~/api'; import { helpPagePath } from '~/helpers/help_page_helper'; export default { name: 'MRWidgetNothingToMerge', components: { - GlButton, GlSprintf, GlLink, }, - props: { - mr: { - type: Object, - required: true, - }, - }, - methods: { - onClickNewFile() { - api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file'); - }, - }, ciHelpPage: helpPagePath('ci/quick_start/index.html'), EMPTY_STATE_SVG_URL, }; @@ -31,42 +18,30 @@ export default { <div class="mr-widget-body mr-widget-empty-state"> <div class="row"> <div - class="col-md-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-150 pb-0 pt-0" + class="col-md-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-130 pb-0 pt-0" > - <img - :alt="s__('mrWidgetNothingToMerge|This merge request contains no changes.')" - :src="$options.EMPTY_STATE_SVG_URL" - /> + <img :src="$options.EMPTY_STATE_SVG_URL" :alt="''" /> </div> <div class="text col-md-9 col-12"> - <p class="highlight"> - {{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }} + <p class="highlight mt-3"> + {{ s__('mrWidgetNothingToMerge|Merge request contains no changes') }} </p> <p data-testid="nothing-to-merge-body"> <gl-sprintf :message=" s__( - 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch. With %{linkStart}CI/CD%{linkEnd}, automatically test your changes before merging.', + 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the %{boldStart}Code%{boldEnd} dropdown list above, then test them with %{linkStart}CI/CD%{linkEnd} before merging.', ) " > + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> <template #link="{ content }"> <gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </p> - <div> - <gl-button - v-if="mr.newBlobPath" - :href="mr.newBlobPath" - category="primary" - variant="confirm" - data-testid="createFileButton" - @click="onClickNewFile" - > - {{ __('Create file') }} - </gl-button> - </div> </div> </div> </div> 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 f120680b440..52cdafd4717 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 @@ -97,7 +97,7 @@ export default { return readyToMergeSubscription; }, skip() { - return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange; + return !this.mr?.id || this.loading; }, variables() { return { @@ -146,6 +146,8 @@ export default { AddedCommitMessage, RelatedLinks, HelpPopover, + AiCommitMessage: () => + import('ee_component/vue_merge_request_widget/components/ai_commit_message.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -502,6 +504,10 @@ export default { this.squashCommitMessage = val; this.squashCommitMessageIsTouched = true; }, + appendCommitMessage(val) { + this.commitMessage = `${this.commitMessage}\n\n${val}`; + this.commitMessageIsTouched = true; + }, }, i18n: { mergeCommitTemplateHintText: s__( @@ -596,7 +602,15 @@ export default { input-id="merge-message-edit" class="gl-m-0! gl-p-0!" @input="setCommitMessage" - /> + > + <template #header> + <ai-commit-message + v-if="mr.aiCommitMessageEnabled" + :id="mr.id" + @update="appendCommitMessage" + /> + </template> + </commit-edit> <li class="gl-m-0! gl-p-0!"> <p class="form-text text-muted"> <gl-sprintf :message="commitTemplateHintText"> 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 af036c01032..e4e81a5e2d1 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,7 +3,6 @@ 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.'); @@ -16,7 +15,6 @@ export default { GlButton, StateContainer, }, - mixins: [glFeatureFlagsMixin()], props: { mr: { type: Object, @@ -52,16 +50,6 @@ export default { > {{ s__('mrWidget|Go to first unresolved thread') }} </gl-button> - <gl-button - 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|Resolve all with new issue') }} - </gl-button> </template> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 5db5f1f8dcf..334fc01c9f7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -14,9 +14,7 @@ export default { }, computed: { widgets() { - return [window.gon?.features?.refactorSecurityExtension && 'MrSecurityWidget'].filter( - (w) => w, - ); + return ['MrSecurityWidget']; }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 18503720814..db237bc7439 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -182,6 +182,7 @@ export const INVALID_RULES_DOCS_PATH = helpPagePath( ); export const DETAILED_MERGE_STATUS = { + PREPARING: 'PREPARING', MERGEABLE: 'MERGEABLE', CHECKING: 'CHECKING', NOT_OPEN: 'NOT_OPEN', diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index 5ca56074031..1b5929e31be 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -1,5 +1,9 @@ import { __, s__ } from '~/locale'; +export const MR_WIDGET_PREPARING_ASYNCHRONOUSLY = s__( + 'mrWidget|Your merge request is almost ready!', +); + export const MR_WIDGET_MISSING_BRANCH_WHICH = s__( 'mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist.', ); 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 3228c09c9b6..564e9321d54 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -39,7 +39,7 @@ export default { }; }, skip() { - return !this.mr?.id || !this.isRealtimeEnabled; + return !this.mr?.id; }, updateQuery( _, @@ -63,14 +63,6 @@ export default { disableCommittersApproval: false, }; }, - computed: { - isRealtimeEnabled() { - // This mixin needs glFeatureFlagsMixin, but fatals if it's included here. - // Parents that include this mixin (approvals) should also include the - // glFeatureFlagsMixin mixin, or this will always be false. - return Boolean(this.glFeatures?.realtimeApprovals); - }, - }, methods: { clearError() { this.$emit('clearError'); 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 6e0ee1cb912..af9e303594a 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 @@ -26,6 +26,7 @@ import ArchivedState from './components/states/mr_widget_archived.vue'; import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; +import PreparingState from './components/states/mr_widget_preparing.vue'; import ClosedState from './components/states/mr_widget_closed.vue'; import ConflictsState from './components/states/mr_widget_conflicts.vue'; import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; @@ -88,6 +89,7 @@ export default { MrWidgetReadyToMerge, ShaMismatch, MrWidgetChecking: CheckingState, + MrWidgetPreparing: PreparingState, MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState, MrWidgetPipelineBlocked: PipelineBlockedState, MrWidgetPipelineFailed: PipelineFailedState, @@ -96,7 +98,6 @@ export default { MrWidgetRebase: RebaseState, SourceBranchRemovalStatus, MrWidgetApprovals, - SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'), MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'), ReadyToMerge: ReadyToMergeState, ReportWidgetContainer, @@ -132,7 +133,7 @@ export default { return getStateSubscription; }, skip() { - return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange; + return !this.mr?.id || this.loading; }, variables() { return { @@ -199,7 +200,7 @@ export default { ); }, shouldRenderApprovals() { - return this.mr.state !== 'nothingToMerge'; + return !['preparing', 'nothingToMerge'].includes(this.mr.state); }, componentName() { return stateToComponentMap[this.machineState] || classState[this.mr.state]; @@ -237,9 +238,6 @@ export default { this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId, ); }, - shouldRenderSecurityReport() { - return Boolean(this.mr?.pipeline?.id); - }, shouldRenderTerraformPlans() { return Boolean(this.mr?.terraformReportsPath); }, @@ -273,9 +271,6 @@ export default { hasAlerts() { return this.hasMergeError || this.showMergePipelineForkWarning; }, - shouldShowSecurityExtension() { - return window.gon?.features?.refactorSecurityExtension; - }, shouldShowMergeDetails() { if (this.mr.state === 'readyToMerge') return true; @@ -599,15 +594,7 @@ export default { <mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" /> <report-widget-container> <extensions-container v-if="hasExtensions" :mr="mr" /> - <widget-container v-if="mr && shouldShowSecurityExtension" :mr="mr" /> - <security-reports-app - v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension" - :pipeline-id="mr.pipeline.id" - :project-id="mr.sourceProjectId" - :security-reports-docs-path="mr.securityReportsDocsPath" - :target-project-full-path="mr.targetProjectFullPath" - :mr-iid="mr.iid" - /> + <widget-container :mr="mr" /> </report-widget-container> <div class="mr-section-container mr-widget-workflow"> <div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container"> diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql index a6b35f20776..4366c01e0a2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql @@ -3,6 +3,7 @@ subscription getStateSubscription($issuableId: IssuableID!) { ... on MergeRequest { id detailedMergeStatus + commitCount } } } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index cead42b12ae..f90056a8e1a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -2,7 +2,9 @@ import { DETAILED_MERGE_STATUS } from '../constants'; import { stateKey } from './state_maps'; export default function deviseState() { - if (!this.commitsCount) { + if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.PREPARING) { + return stateKey.preparing; + } else if (!this.commitsCount) { return stateKey.nothingToMerge; } else if (this.projectArchived) { return stateKey.archived; 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 39ae1fda9f4..9ddf8241020 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,7 +1,7 @@ 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 { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility'; import { machine } from '~/lib/utils/finite_state_machine'; import { MTWPS_MERGE_STRATEGY, @@ -212,6 +212,7 @@ export default class MergeRequestStore { setGraphqlSubscriptionData(data) { this.detailedMergeStatus = data.detailedMergeStatus; + this.commitsCount = data.commitCount; this.setState(); } @@ -341,7 +342,7 @@ export default class MergeRequestStore { return ''; } - return format(date); + return format(date, timeagoLanguageCode); } static getPreferredAutoMergeStrategy(availableAutoMergeStrategies) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 9dfeaee905c..04468855942 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -10,6 +10,7 @@ export const stateToComponentMap = { notAllowedToMerge: 'mr-widget-not-allowed', archived: 'mr-widget-archived', checking: 'mr-widget-checking', + preparing: 'mr-widget-preparing', unresolvedDiscussions: 'mr-widget-unresolved-discussions', pipelineBlocked: 'mr-widget-pipeline-blocked', pipelineFailed: 'mr-widget-pipeline-failed', @@ -38,6 +39,7 @@ export const stateKey = { archived: 'archived', missingBranch: 'missingBranch', nothingToMerge: 'nothingToMerge', + preparing: 'preparing', checking: 'checking', conflicts: 'conflicts', draft: 'draft', diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index 175aef59ae5..c3f3226c46e 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -1,29 +1,25 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, +} from '@gitlab/ui'; export default { components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlButton, - GlTooltip, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, }, props: { - id: { + toggleText: { type: String, - required: false, - default: '', + required: true, }, actions: { type: Array, required: true, }, - selectedKey: { - type: String, - required: false, - default: '', - }, category: { type: String, required: false, @@ -34,78 +30,40 @@ export default { required: false, default: 'default', }, - showActionTooltip: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - hasMultipleActions() { - return this.actions.length > 1; - }, - selectedAction() { - return this.actions.find((x) => x.key === this.selectedKey) || this.actions[0]; - }, }, methods: { handleItemClick(action) { - this.$emit('select', action.key); - }, - handleClick(action, evt) { - this.$emit('actionClicked', { action }); - return action.handle?.(evt); + return action.handle?.(); }, }, }; </script> <template> - <span> - <gl-dropdown - v-if="hasMultipleActions" - :id="id" - :text="selectedAction.text" - :split-href="selectedAction.href" - :variant="variant" - :category="category" - split - data-qa-selector="action_dropdown" - @click="handleClick(selectedAction, $event)" - > - <template #button-content> - <span class="gl-dropdown-button-text" v-bind="selectedAction.attrs"> - {{ selectedAction.text }} - </span> - </template> - <template v-for="(action, index) in actions"> - <gl-dropdown-item - :key="action.key" - is-check-item - :is-checked="action.key === selectedAction.key" - :secondary-text="action.secondaryText" - :data-qa-selector="`${action.key}_menu_item`" - :data-testid="`action_${action.key}`" - @click="handleItemClick(action)" - > - <span class="gl-font-weight-bold">{{ action.text }}</span> - </gl-dropdown-item> - <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" /> - </template> - </gl-dropdown> - <gl-button - v-else-if="selectedAction" - :id="id" - v-bind="selectedAction.attrs" - :variant="variant" - :category="category" - :href="selectedAction.href" - @click="handleClick(selectedAction, $event)" - > - {{ selectedAction.text }} - </gl-button> - <gl-tooltip v-if="selectedAction.tooltip && showActionTooltip" :target="id"> - {{ selectedAction.tooltip }} - </gl-tooltip> - </span> + <gl-disclosure-dropdown + :variant="variant" + :category="category" + :toggle-text="toggleText" + data-qa-selector="action_dropdown" + > + <gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-item + v-for="action in actions" + :key="action.key" + v-bind="action.attrs" + :item="action" + :data-qa-selector="`${action.key}_menu_item`" + @action="handleItemClick(action)" + > + <template #list-item> + <div class="gl-display-flex gl-flex-direction-column"> + <span class="gl-font-weight-bold gl-mb-2">{{ action.text }}</span> + <span class="gl-text-gray-700"> + {{ action.secondaryText }} + </span> + </div> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 7b5ded9348f..9023807eba3 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from './ci_icon.vue'; /** * Renders CI Badge link with CI icon and status text based on @@ -26,8 +26,8 @@ import CiIcon from './ci_icon.vue'; export default { components: { - GlLink, CiIcon, + GlBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -42,6 +42,11 @@ export default { required: false, default: true, }, + badgeSize: { + type: String, + required: false, + default: 'md', + }, }, computed: { title() { @@ -51,27 +56,76 @@ export default { // For now, this can either come from graphQL with camelCase or REST API in snake_case return this.status.detailsPath || this.status.details_path; }, - cssClass() { - const className = this.status.group; - return className ? `ci-status ci-${className}` : 'ci-status'; + badgeStyles() { + switch (this.status.icon) { + case 'status_success': + return { + textColor: 'gl-text-green-700', + variant: 'success', + }; + case 'status_warning': + return { + textColor: 'gl-text-orange-700', + variant: 'warning', + }; + case 'status_failed': + return { + textColor: 'gl-text-red-700', + variant: 'danger', + }; + case 'status_running': + return { + textColor: 'gl-text-blue-700', + variant: 'info', + }; + case 'status_pending': + return { + textColor: 'gl-text-orange-700', + variant: 'warning', + }; + case 'status_canceled': + return { + textColor: 'gl-text-gray-700', + variant: 'neutral', + }; + case 'status_manual': + return { + textColor: 'gl-text-gray-700', + variant: 'neutral', + }; + // default covers the styles for the remainder of CI + // statuses that are not explicitly stated here + default: + return { + textColor: 'gl-text-gray-600', + variant: 'muted', + }; + } }, }, }; </script> <template> - <gl-link + <gl-badge v-gl-tooltip - class="gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base" - :class="cssClass" :title="title" - data-qa-selector="status_badge_link" :href="detailsPath" + :size="badgeSize" + :variant="badgeStyles.variant" + :data-testid="`ci-badge-${status.text}`" + data-qa-selector="status_badge_link" @click="$emit('ciStatusBadgeClick')" > <ci-icon :status="status" /> <template v-if="showText"> - <span class="gl-ml-2 gl-white-space-nowrap">{{ status.text }}</span> + <span + class="gl-ml-2 gl-white-space-nowrap" + :class="badgeStyles.textColor" + data-testid="ci-badge-text" + > + {{ status.text }} + </span> </template> - </gl-link> + </gl-badge> </template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue deleted file mode 100644 index dd6923d9fcd..00000000000 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownSectionHeader, - GlFormInputGroup, - GlButton, - GlTooltipDirective, -} from '@gitlab/ui'; -import { getHTTPProtocol } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; - -export default { - components: { - GlDropdown, - GlDropdownSectionHeader, - GlFormInputGroup, - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - sshLink: { - type: String, - required: false, - default: '', - }, - httpLink: { - type: String, - required: false, - default: '', - }, - }, - computed: { - httpLabel() { - const protocol = this.httpLink ? getHTTPProtocol(this.httpLink)?.toUpperCase() : ''; - return sprintf(__('Clone with %{protocol}'), { protocol }); - }, - }, - labels: { - defaultLabel: __('Clone'), - ssh: __('Clone with SSH'), - }, - copyURLTooltip: __('Copy URL'), -}; -</script> -<template> - <gl-dropdown right :text="$options.labels.defaultLabel" category="primary" variant="confirm"> - <div class="pb-2 mx-1"> - <template v-if="sshLink"> - <gl-dropdown-section-header>{{ $options.labels.ssh }}</gl-dropdown-section-header> - - <div class="mx-3"> - <gl-form-input-group :value="sshLink" readonly select-on-click> - <template #append> - <gl-button - v-gl-tooltip.hover - :title="$options.copyURLTooltip" - :aria-label="$options.copyURLTooltip" - :data-clipboard-text="sshLink" - data-qa-selector="copy_ssh_url_button" - icon="copy-to-clipboard" - class="d-inline-flex" - /> - </template> - </gl-form-input-group> - </div> - </template> - - <template v-if="httpLink"> - <gl-dropdown-section-header>{{ httpLabel }}</gl-dropdown-section-header> - - <div class="mx-3"> - <gl-form-input-group :value="httpLink" readonly select-on-click> - <template #append> - <gl-button - v-gl-tooltip.hover - :title="$options.copyURLTooltip" - :aria-label="$options.copyURLTooltip" - :data-clipboard-text="httpLink" - data-qa-selector="copy_http_url_button" - icon="copy-to-clipboard" - class="d-inline-flex" - /> - </template> - </gl-form-input-group> - </div> - </template> - </div> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js new file mode 100644 index 00000000000..ed0e9150bc4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js @@ -0,0 +1,33 @@ +import CloneDropdown from './clone_dropdown.vue'; + +export default { + component: CloneDropdown, + title: 'vue_shared/components/clone_dropdown', +}; + +const Template = (args, { argTypes }) => ({ + components: { CloneDropdown }, + props: Object.keys(argTypes), + template: '<clone-dropdown v-bind="$props" />', +}); + +const sshLink = 'ssh://some-ssh-link'; +const httpLink = 'https://some-http-link'; + +export const Default = Template.bind({}); +Default.args = { + sshLink, + httpLink, +}; + +export const HttpLink = Template.bind({}); +HttpLink.args = { + httpLink, + sshLink: '', +}; + +export const SSHLink = Template.bind({}); +SSHLink.args = { + sshLink, + httpLink: '', +}; diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue new file mode 100644 index 00000000000..fa7c5bc1978 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue @@ -0,0 +1,59 @@ +<script> +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { getHTTPProtocol } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import CloneDropdownItem from './clone_dropdown_item.vue'; + +export default { + components: { + GlDisclosureDropdown, + CloneDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + sshLink: { + type: String, + required: false, + default: '', + }, + httpLink: { + type: String, + required: false, + default: '', + }, + }, + computed: { + httpLabel() { + const protocol = this.httpLink ? getHTTPProtocol(this.httpLink)?.toUpperCase() : ''; + return sprintf(__('Clone with %{protocol}'), { protocol }); + }, + }, + labels: { + defaultLabel: __('Clone'), + ssh: __('Clone with SSH'), + }, +}; +</script> +<template> + <gl-disclosure-dropdown + :toggle-text="$options.labels.defaultLabel" + category="primary" + variant="confirm" + placement="right" + > + <clone-dropdown-item + v-if="sshLink" + :label="$options.labels.ssh" + :link="sshLink" + qa-selector="copy_ssh_url_button" + /> + <clone-dropdown-item + v-if="httpLink" + :label="httpLabel" + :link="httpLink" + qa-selector="copy_http_url_button" + /> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue new file mode 100644 index 00000000000..0e322ebc686 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue @@ -0,0 +1,56 @@ +<script> +import { + GlButton, + GlDisclosureDropdownItem, + GlFormGroup, + GlFormInputGroup, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlDisclosureDropdownItem, + GlFormGroup, + GlFormInputGroup, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + label: { + type: String, + required: true, + }, + link: { + type: String, + required: true, + }, + qaSelector: { + type: String, + required: true, + }, + }, + copyURLTooltip: __('Copy URL'), +}; +</script> +<template> + <gl-disclosure-dropdown-item> + <gl-form-group :label="label" class="gl-px-3 gl-mb-3"> + <gl-form-input-group :value="link" readonly select-on-click> + <template #append> + <gl-button + v-gl-tooltip.hover + :title="$options.copyURLTooltip" + :aria-label="$options.copyURLTooltip" + :data-clipboard-text="link" + :data-qa-selector="qaSelector" + icon="copy-to-clipboard" + class="gl-display-inline-flex" + /> + </template> + </gl-form-input-group> + </gl-form-group> + </gl-disclosure-dropdown-item> +</template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue deleted file mode 100644 index 64e3b5d0bae..00000000000 --- a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> -import { GlModal } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export const i18n = { - btnText: __('Fork project'), - title: __('Fork project?'), - message: __( - 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.', - ), -}; - -export default { - name: 'ConfirmForkModal', - components: { - GlModal, - }, - model: { - prop: 'visible', - event: 'change', - }, - props: { - visible: { - type: Boolean, - required: false, - default: false, - }, - modalId: { - type: String, - required: true, - }, - forkPath: { - type: String, - required: true, - }, - }, - computed: { - btnActions() { - return { - cancel: { text: __('Cancel') }, - primary: { - text: this.$options.i18n.btnText, - attributes: { - href: this.forkPath, - variant: 'confirm', - 'data-qa-selector': 'fork_project_button', - 'data-method': 'post', - }, - }, - }; - }, - }, - i18n, -}; -</script> -<template> - <gl-modal - :visible="visible" - data-qa-selector="confirm_fork_modal" - :modal-id="modalId" - :title="$options.i18n.title" - :action-primary="btnActions.primary" - :action-cancel="btnActions.cancel" - @change="$emit('change', $event)" - > - <p>{{ $options.i18n.message }}</p> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index faa50a50c69..3bb168e9051 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -131,7 +131,6 @@ export default { ref="search" :value="searchTerm" :placeholder="searchText" - class="js-dropdown-input-field" @input="setSearchTerm" /> </slot> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 88062bf245f..c72356dc713 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -13,10 +13,11 @@ import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searche import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import { createAlert } from '~/alert'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import { SORT_DIRECTION } from './constants'; -import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils'; +import { filterEmptySearchTerm, uniqueTokens } from './filtered_search_utils'; export default { components: { @@ -338,7 +339,7 @@ export default { </script> <template> - <div class="vue-filtered-search-bar-container gl-md-display-flex"> + <div class="vue-filtered-search-bar-container gl-md-display-flex gl-min-w-0"> <gl-form-checkbox v-if="showCheckbox" class="gl-align-self-center" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 5cc96471aef..65c783ada55 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -5,15 +5,6 @@ import { queryToObject } from '~/lib/utils/url_utility'; import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants'; /** - * Strips enclosing quotations from a string if it has one. - * - * @param {String} value String to strip quotes from - * - * @returns {String} String without any enclosure - */ -export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); - -/** * This method removes duplicate tokens from tokens array. * * @param {Array} tokens Array of tokens as defined by `GlFilteredSearch` 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 b5783265ffa..5a7382bcd7c 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,12 +9,9 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; -import { - getRecentlyUsedSuggestions, - setTokenValueToRecentlyUsed, - stripQuotes, -} from '../filtered_search_utils'; +import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; export default { components: { @@ -113,13 +110,15 @@ export default { * present in "Recently used" */ availableSuggestions() { - return this.searchKey + const suggestions = this.searchKey ? this.suggestions : this.suggestions.filter( (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) && !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]), ); + + return this.applyMaxSuggestions(suggestions); }, showDefaultSuggestions() { return this.availableDefaultSuggestions.length > 0; @@ -196,6 +195,12 @@ export default { setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue); } }, + applyMaxSuggestions(suggestions) { + const { maxSuggestions } = this.config; + if (!maxSuggestions || maxSuggestions <= 0) return suggestions; + + return suggestions.slice(0, maxSuggestions); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index c69a2927ec9..0ce784fab1a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -3,8 +3,8 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { OPTIONS_NONE_ANY } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 6a7dd6131e2..3dfdb15db31 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -3,10 +3,10 @@ import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import { OPTIONS_NONE_ANY } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; import BaseToken from './base_token.vue'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 81b8a6c78fc..8322fe92de4 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -4,8 +4,8 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { DEFAULT_MILESTONES } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index 897ca2f84d2..186f5619b87 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -71,7 +71,6 @@ export default { } }, }, - popperOptions: { strategy: 'fixed' }, }; </script> @@ -89,7 +88,7 @@ export default { searchable size="small" class="comment-template-dropdown" - :popper-options="$options.popperOptions" + positioning-strategy="fixed" :searching="$apollo.queries.savedReplies.loading" @shown="fetchCommentTemplates" @search="setCommentTemplateSearch" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 8802f364665..af0b34f1389 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -199,7 +199,7 @@ export default { insertIntoTextarea(text) { const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); if (textArea) { - const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated using AI')}_`; + const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated by AI')}_`; updateText({ textArea, tag: generatedByText, @@ -267,6 +267,7 @@ export default { :css-classes="['diff-suggest-popover']" placement="bottom" :show="suggestPopoverVisible" + triggers="" > <strong>{{ __('New! Suggest changes directly') }}</strong> <p class="mb-2"> 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 d9d4056e997..9fd606d775d 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -95,6 +95,11 @@ export default { required: false, default: false, }, + disableAttachments: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -111,6 +116,9 @@ export default { // Match textarea focus behavior return this.autofocus && !this.autofocused ? 'end' : false; }, + markdownFieldRestrictedToolBarItems() { + return this.disableAttachments ? ['attach-file'] : []; + }, }, watch: { value(val) { @@ -231,7 +239,7 @@ export default { v-bind="$attrs" data-testid="markdown-field" :markdown-preview-path="renderMarkdownPath" - can-attach-file + :can-attach-file="!disableAttachments" :textarea-value="markdown" :uploads-path="uploadsPath" :enable-autocomplete="enableAutocomplete" @@ -240,6 +248,7 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :show-content-editor-switcher="enableContentEditor" :drawio-enabled="drawioEnabled" + :restricted-tool-bar-items="markdownFieldRestrictedToolBarItems" :remove-border="true" @enableContentEditor="onEditingModeChange('contentEditor')" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" @@ -256,8 +265,7 @@ export default { :disabled="disabled" @input="updateMarkdownFromMarkdownField" @keydown="$emit('keydown', $event)" - > - </textarea> + ></textarea> </template> </markdown-field> <div v-else> @@ -273,6 +281,7 @@ export default { :enable-autocomplete="enableAutocomplete" :autocomplete-data-sources="autocompleteDataSources" :editable="!disabled" + :disable-attachments="disableAttachments" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @keydown="$emit('keydown', $event)" 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 index ac4f06a665d..8ff14220eab 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '../../constants'; import MarkdownEditor from './markdown_editor.vue'; import eventHub from './eventhub'; @@ -67,6 +68,9 @@ export function mountMarkdownEditor() { newIssuePath, } = el.dataset; + const supportsQuickActions = parseBoolean(el.dataset.supportsQuickActions ?? true); + const enableAutocomplete = parseBoolean(el.dataset.enableAutocomplete ?? true); + const disableAttachments = parseBoolean(el.dataset.disableAttachments ?? false); const hiddenInput = el.querySelector('input[type="hidden"]'); const formFieldName = hiddenInput.getAttribute('name'); const formFieldId = hiddenInput.getAttribute('id'); @@ -102,9 +106,11 @@ export function mountMarkdownEditor() { 'data-qa-selector': qaSelector, }, autosaveKey, - enableAutocomplete: true, + enableAutocomplete, autocompleteDataSources: gl.GfmAutoComplete?.dataSources, - supportsQuickActions: true, + supportsQuickActions, + disableAttachments, + autofocus: true, }, }); }, diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue index 379f22fdc6f..4bb32a53b30 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue @@ -4,6 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import { contentTop } from '~/lib/utils/common_utils'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getRenderedMarkdown } from './utils/fetch'; export const cache = {}; @@ -95,10 +96,17 @@ export default { safeHtmlConfig: { ADD_TAGS: ['copy-code'], }, + DRAWER_Z_INDEX, }; </script> <template> - <gl-drawer :header-height="drawerTop" :open="open" header-sticky @close="closeDrawer"> + <gl-drawer + :header-height="drawerTop" + :open="open" + header-sticky + :z-index="$options.DRAWER_Z_INDEX" + @close="closeDrawer" + > <template #title> <h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4> </template> diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue new file mode 100644 index 00000000000..064458cfc1f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue @@ -0,0 +1,361 @@ +<script> +import { + GlLoadingIcon, + GlButton, + GlIcon, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlTooltipDirective, +} from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; +import { __, s__ } from '~/locale'; +import api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import { createAlert } from '~/alert'; +import MergeRequest from '~/merge_request'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; +import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue'; +import { TYPE_MERGE_REQUEST } from '~/issues/constants'; + +Vue.use(VueApollo); + +export default { + apolloProvider, + i18n: { + edit: __('Edit'), + copyReferenceText: __('Copy reference'), + errorMessage: __('Something went wrong. Please try again.'), + issuableName: __('merge request'), + reportAbuse: __('Report abuse'), + markAsReady: __('Mark as ready'), + markAsDraft: __('Mark as draft'), + close: __('Close %{issuableType}'), + closing: __('Closing %{issuableType}...'), + reopen: __('Reopen %{issuableType}'), + reopening: __('Reopening %{issuableType}...'), + lock: __('Lock %{issuableType}'), + mergeRequestActions: __('Merge request actions'), + }, + components: { + GlLoadingIcon, + GlButton, + GlIcon, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + SidebarSubscriptionsWidget, + AbuseCategorySelector, + NewHeaderActionsPopover, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + inject: { + reportAbusePath: { + default: '', + }, + }, + props: { + mr: { + type: Object, + required: true, + }, + projectPath: { + type: String, + default: '', + required: false, + }, + editUrl: { + type: String, + default: '', + required: false, + }, + isCurrentUser: { + type: Boolean, + default: false, + required: true, + }, + isLoggedIn: { + type: Boolean, + defauilt: false, + required: false, + }, + canUpdateMergeRequest: { + type: Boolean, + default: false, + required: false, + }, + open: { + type: Boolean, + default: false, + required: false, + }, + isMerged: { + type: Boolean, + default: false, + required: false, + }, + sourceProjectMissing: { + type: Boolean, + default: false, + required: false, + }, + clipboardText: { + type: String, + default: '', + required: false, + }, + reportedUserId: { + type: Number, + default: 0, + required: false, + }, + reportedFromUrl: { + type: String, + default: '', + required: false, + }, + }, + data() { + return { + isOpen: this.open, + draft: this.mr.draft, + issuableType: TYPE_MERGE_REQUEST, + fullPath: this.projectPath, + isLoading: false, + isLoadingDraft: false, + isLoadingClipboard: false, + isReportAbuseDrawerOpen: false, + }; + }, + computed: { + isMovedMrSidebar() { + return this.glFeatures.movedMrSidebar; + }, + draftLabel() { + return this.draft ? this.$options.i18n.markAsReady : this.$options.i18n.markAsDraft; + }, + draftState() { + return this.draft ? 'ready' : 'draft'; + }, + editItem() { + return { + text: this.$options.i18n.edit, + href: this.editUrl, + }; + }, + }, + methods: { + draftAction() { + this.isLoadingDraft = true; + + axios + .put(`?merge_request[wip_event]=${this.draftState}`, null, { + params: { format: 'json' }, + }) + .then(({ data }) => { + MergeRequest.toggleDraftStatus(data.title, this.draft); + }) + .catch(() => { + createAlert({ + message: this.$options.i18n.errorMessage, + }); + }) + .finally(() => { + this.draft = !this.draft; + this.isLoadingDraft = false; + this.closeActionsDropdown(); + }); + }, + stateAction(state) { + this.isLoading = true; + + api + .updateMergeRequest(this.mr.target_project_id, this.mr.iid, { state_event: state }) + .then(() => { + window.location.reload(); + }) + .catch(() => { + createAlert({ + message: this.$options.i18n.errorMessage, + }); + }) + .finally(() => { + this.isOpen = !this.isOpen; + this.isLoading = false; + this.closeActionsDropdown(); + }); + }, + copyClipboardAction() { + this.$toast.show(s__('MergeRequests|Reference copied')); + this.closeActionsDropdown(); + }, + reportAbuseAction(isOpen) { + if (isOpen) { + this.closeActionsDropdown(); + } + + this.isReportAbuseDrawerOpen = isOpen; + }, + closeActionsDropdown() { + this.$refs.mrMoreActionsDropdown.close(); + }, + showReopenMergeRequestOption() { + return !this.sourceProjectMissing && !this.isOpen; + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-justify-content-end gl-w-full gl-relative" + data-testid="merge-request-actions" + > + <gl-disclosure-dropdown + id="new-actions-header-dropdown" + ref="mrMoreActionsDropdown" + data-testid="dropdown-toggle" + placement="right" + :auto-close="false" + > + <template #toggle> + <div class="gl-min-h-7 gl-mb-2 gl-md-mb-0!" :aria-label="$options.i18n.mergeRequestActions"> + <gl-button + class="gl-md-display-none! gl-new-dropdown-toggle gl-absolute gl-top-0 gl-left-0 gl-w-full" + category="secondary" + > + <span class="">{{ $options.i18n.mergeRequestActions }}</span> + <gl-icon class="dropdown-chevron" name="chevron-down" /> + </gl-button> + <gl-button + class="gl-display-none gl-md-display-flex! gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret gl-ml-3" + category="tertiary" + icon="ellipsis_v" + /> + </div> + </template> + <gl-disclosure-dropdown-group v-if="isLoggedIn && isMovedMrSidebar"> + <sidebar-subscriptions-widget + :iid="String(mr.iid)" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="notification-toggle" + /> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group + bordered + :class="{ 'gl-mt-0! gl-pt-0! gl-border-t-0!': !(isLoggedIn && isMovedMrSidebar) }" + > + <gl-disclosure-dropdown-item + v-if="canUpdateMergeRequest" + class="gl-md-display-none!" + data-testid="edit-merge-request" + :item="editItem" + /> + + <gl-disclosure-dropdown-item + v-if="isOpen && canUpdateMergeRequest" + data-testid="ready-and-draft-action" + @action="draftAction" + > + <template #list-item> + <gl-loading-icon v-if="isLoadingDraft" inline size="sm" /> + {{ draftLabel }} + </template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item + v-if="isOpen && canUpdateMergeRequest" + data-testid="close-merge-request" + @action="stateAction('close')" + > + <template #list-item> + <template v-if="isLoading"> + <gl-loading-icon inline size="sm" /> + {{ + sprintf($options.i18n.closing, { + issuableType: $options.i18n.issuableName, + }) + }} + </template> + <template v-else> + {{ sprintf($options.i18n.close, { issuableType: $options.i18n.issuableName }) }} + </template> + </template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item + v-else-if="!isMerged && showReopenMergeRequestOption && canUpdateMergeRequest" + data-testid="reopen-merge-request" + @action="stateAction('reopen')" + > + <template #list-item> + <template v-if="isLoading"> + <gl-loading-icon inline size="sm" /> + {{ + sprintf($options.i18n.reopening, { + issuableType: $options.i18n.issuableName, + }) + }} + </template> + <template v-else> + {{ sprintf($options.i18n.reopen, { issuableType: $options.i18n.issuableName }) }} + </template> + </template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item v-if="isMovedMrSidebar" class="js-sidebar-lock-root"> + <template #list-item> + {{ sprintf($options.i18n.lock, { issuableType: $options.i18n.issuableName }) }} + </template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item + v-if="isMovedMrSidebar" + class="js-copy-reference" + :data-clipboard-text="clipboardText" + data-testid="copy-reference" + @action="copyClipboardAction" + > + <template #list-item> + {{ $options.i18n.copyReferenceText }} + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group + v-if="!isCurrentUser" + bordered + :class="{ 'gl-mt-0! gl-pt-0! gl-border-t-0!': !canUpdateMergeRequest }" + > + <gl-disclosure-dropdown-item + class="js-report-abuse-dropdown-item" + data-testid="report-abuse-option" + @action="reportAbuseAction(true)" + > + <template #list-item> + {{ $options.i18n.reportAbuse }} + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> + + <new-header-actions-popover v-if="isMovedMrSidebar" :issue-type="issuableType" /> + + <abuse-category-selector + v-if="!isCurrentUser && isReportAbuseDrawerOpen" + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" + :show-drawer="isReportAbuseDrawerOpen" + @close-drawer="reportAbuseAction(false)" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 748d6082abd..57b19620c10 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -45,18 +45,31 @@ export default { required: false, default: false, }, + internalNote: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters(['getUserData']), renderedNote() { return renderMarkdown(this.note.body); }, + internalNoteClass() { + return { + 'internal-note': this.internalNote, + }; + }, }, }; </script> <template> - <timeline-entry-item class="note note-wrapper note-comment being-posted fade-in-half"> + <timeline-entry-item + class="note note-wrapper note-comment being-posted fade-in-half" + :class="internalNoteClass" + > <div class="timeline-avatar gl-float-left"> <gl-avatar-link :href="getUserData.path"> <gl-avatar 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 d77061d4b31..28a16cd846a 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,53 +1,64 @@ <script> import { GlIntersectionObserver } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { getPageParamValue, getPageSearchString } from '~/blob/utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import ChunkLine from './chunk_line.vue'; /* * We only highlight the chunk that is currently visible to the user. * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. * - * Content that is not visible to the user (i.e. not highlighted) does not need to look nice, - * so by rendering raw (non-highlighted) text, the browser spends less resources on painting - * content that is not immediately relevant. - * Why use plaintext as opposed to hiding content entirely? - * If content is hidden entirely, native find text (⌘ + F) won't work. + * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, + * so by making text transparent and rendering raw (non-highlighted) text, + * the browser spends less resources on painting content that is not immediately relevant. + * + * Why use transparent text as opposed to hiding content entirely? + * 1. If content is hidden entirely, native find text (⌘ + F) won't work. + * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. */ export default { components: { + ChunkLine, GlIntersectionObserver, }, - directives: { - SafeHtml, - }, props: { - isHighlighted: { + isFirstChunk: { type: Boolean, - required: true, + required: false, + default: false, }, chunkIndex: { type: Number, required: false, default: 0, }, - rawContent: { - type: String, + isHighlighted: { + type: Boolean, required: true, }, - highlightedContent: { + content: { type: String, required: true, }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, totalLines: { type: Number, required: false, default: 0, }, - startingFrom: { + totalChunks: { type: Number, required: false, default: 0, }, + language: { + type: String, + required: false, + default: null, + }, blamePath: { type: String, required: true, @@ -55,36 +66,37 @@ export default { }, data() { return { - hasAppeared: false, isLoading: true, }; }, computed: { - shouldHighlight() { - return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); - }, lines() { return this.content.split('\n'); }, - pageSearchString() { - const page = getPageParamValue(this.number); - return getPageSearchString(this.blamePath, page); - }, }, + created() { - if (this.chunkIndex === 0) { - // Display first chunk ASAP in order to improve perceived performance + if (this.isFirstChunk) { this.isLoading = false; return; } - window.requestIdleCallback(() => { + window.requestIdleCallback(async () => { this.isLoading = false; + const { hash } = this.$route; + if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { + // when the last chunk is loaded scroll to the hash + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + } }); }, methods: { handleChunkAppear() { - this.hasAppeared = true; + if (!this.isHighlighted) { + this.$emit('appear', this.chunkIndex); + } }, calculateLineNumber(index) { return this.startingFrom + index + 1; @@ -94,36 +106,28 @@ export default { </script> <template> <gl-intersection-observer @appear="handleChunkAppear"> - <div class="gl-display-flex"> - <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> - <div + <div v-if="isHighlighted"> + <chunk-line + v-for="(line, index) in lines" + :key="index" + :number="calculateLineNumber(index)" + :content="line" + :language="language" + :blame-path="blamePath" + /> + </div> + <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> + <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> + <span v-for="(n, index) in totalLines" + v-once + :id="`L${calculateLineNumber(index)}`" :key="index" - data-testid="line-numbers" - class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" - > - <a - class="gl-user-select-none gl-shadow-none! file-line-blame" - :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" - ></a> - <a - :id="`L${calculateLineNumber(index)}`" - class="gl-user-select-none gl-shadow-none! file-line-num" - :href="`#L${calculateLineNumber(index)}`" - :data-line-number="calculateLineNumber(index)" - > - {{ calculateLineNumber(index) }} - </a> - </div> + data-testid="line-number" + v-text="calculateLineNumber(index)" + ></span> </div> - - <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> - <!-- Placeholder for line numbers while content is not highlighted --> - </div> - - <pre - class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" - ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> + <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> </div> </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue deleted file mode 100644 index 28a16cd846a..00000000000 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue +++ /dev/null @@ -1,133 +0,0 @@ -<script> -import { GlIntersectionObserver } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import ChunkLine from './chunk_line.vue'; - -/* - * We only highlight the chunk that is currently visible to the user. - * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. - * - * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, - * so by making text transparent and rendering raw (non-highlighted) text, - * the browser spends less resources on painting content that is not immediately relevant. - * - * Why use transparent text as opposed to hiding content entirely? - * 1. If content is hidden entirely, native find text (⌘ + F) won't work. - * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. - */ -export default { - components: { - ChunkLine, - GlIntersectionObserver, - }, - props: { - isFirstChunk: { - type: Boolean, - required: false, - default: false, - }, - chunkIndex: { - type: Number, - required: false, - default: 0, - }, - isHighlighted: { - type: Boolean, - required: true, - }, - content: { - type: String, - required: true, - }, - startingFrom: { - type: Number, - required: false, - default: 0, - }, - totalLines: { - type: Number, - required: false, - default: 0, - }, - totalChunks: { - type: Number, - required: false, - default: 0, - }, - language: { - type: String, - required: false, - default: null, - }, - blamePath: { - type: String, - required: true, - }, - }, - data() { - return { - isLoading: true, - }; - }, - computed: { - lines() { - return this.content.split('\n'); - }, - }, - - created() { - if (this.isFirstChunk) { - this.isLoading = false; - return; - } - - window.requestIdleCallback(async () => { - this.isLoading = false; - const { hash } = this.$route; - if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { - // when the last chunk is loaded scroll to the hash - await this.$nextTick(); - const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - lineHighlighter.highlightHash(hash); - } - }); - }, - methods: { - handleChunkAppear() { - if (!this.isHighlighted) { - this.$emit('appear', this.chunkIndex); - } - }, - calculateLineNumber(index) { - return this.startingFrom + index + 1; - }, - }, -}; -</script> -<template> - <gl-intersection-observer @appear="handleChunkAppear"> - <div v-if="isHighlighted"> - <chunk-line - v-for="(line, index) in lines" - :key="index" - :number="calculateLineNumber(index)" - :content="line" - :language="language" - :blame-path="blamePath" - /> - </div> - <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> - <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> - <span - v-for="(n, index) in totalLines" - v-once - :id="`L${calculateLineNumber(index)}`" - :key="index" - data-testid="line-number" - v-text="calculateLineNumber(index)" - ></span> - </div> - <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> - </div> - </gl-intersection-observer> -</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue new file mode 100644 index 00000000000..d77061d4b31 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -0,0 +1,129 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getPageParamValue, getPageSearchString } from '~/blob/utils'; + +/* + * We only highlight the chunk that is currently visible to the user. + * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. + * + * Content that is not visible to the user (i.e. not highlighted) does not need to look nice, + * so by rendering raw (non-highlighted) text, the browser spends less resources on painting + * content that is not immediately relevant. + * Why use plaintext as opposed to hiding content entirely? + * If content is hidden entirely, native find text (⌘ + F) won't work. + */ +export default { + components: { + GlIntersectionObserver, + }, + directives: { + SafeHtml, + }, + props: { + isHighlighted: { + type: Boolean, + required: true, + }, + chunkIndex: { + type: Number, + required: false, + default: 0, + }, + rawContent: { + type: String, + required: true, + }, + highlightedContent: { + type: String, + required: true, + }, + totalLines: { + type: Number, + required: false, + default: 0, + }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, + blamePath: { + type: String, + required: true, + }, + }, + data() { + return { + hasAppeared: false, + isLoading: true, + }; + }, + computed: { + shouldHighlight() { + return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); + }, + lines() { + return this.content.split('\n'); + }, + pageSearchString() { + const page = getPageParamValue(this.number); + return getPageSearchString(this.blamePath, page); + }, + }, + created() { + if (this.chunkIndex === 0) { + // Display first chunk ASAP in order to improve perceived performance + this.isLoading = false; + return; + } + + window.requestIdleCallback(() => { + this.isLoading = false; + }); + }, + methods: { + handleChunkAppear() { + this.hasAppeared = true; + }, + calculateLineNumber(index) { + return this.startingFrom + index + 1; + }, + }, +}; +</script> +<template> + <gl-intersection-observer @appear="handleChunkAppear"> + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div + v-for="(n, index) in totalLines" + :key="index" + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" + > + {{ calculateLineNumber(index) }} + </a> + </div> + </div> + + <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + + <pre + class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" + ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> + </div> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 58db1ceda95..6c49a601401 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -147,3 +147,7 @@ export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unico * HAML: https://github.com/highlightjs/highlight.js/issues/3783 * */ export const LEGACY_FALLBACKS = ['python', 'haml']; + +export const CODEOWNERS_FILE_NAME = 'CODEOWNERS'; + +export const CODEOWNERS_LANGUAGE = 'codeowners'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js b/app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js new file mode 100644 index 00000000000..33149b42222 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js @@ -0,0 +1,42 @@ +/* +Language: Codeowners +Description: language definition for CODEOWNERS files +*/ + +export default (hljs) => { + return { + name: 'codeowners', + case_insensitive: true, + contains: [ + { + scope: 'number', + begin: '\\[\\d+\\]', + end: '(?=\\s|$)', + }, + { + scope: 'regexp', + begin: '^\\^|\\*', + }, + { + scope: 'attr', + begin: '^\\s*(?![#^*[])\\S|(?<=\\*)\\S*', + end: '(?=\\s|$)', + contains: [ + { + scope: 'regexp', + begin: '\\*', + }, + ], + }, + { + scope: 'keyword', + begin: '\\[(?!\\d+\\])[^\\]]+\\]', + }, + { + scope: 'variable', + begin: '\\S*@.*$', + }, + hljs.HASH_COMMENT_MODE, + ], + }; +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js index 3540ac6caf1..a79e88a1132 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js @@ -11,25 +11,25 @@ import { escape } from 'lodash'; const newlineRegex = /\r?\n/; const generateClassName = (suffix) => (suffix ? `hljs-${escape(suffix)}` : ''); const generateCloseTag = (includeClose) => (includeClose ? '</span>' : ''); -const generateHLJSTag = (kind, content = '', includeClose) => - `<span class="${generateClassName(kind)}">${escape(content)}${generateCloseTag(includeClose)}`; +const generateHLJSTag = (scope, content = '', includeClose) => + `<span class="${generateClassName(scope)}">${escape(content)}${generateCloseTag(includeClose)}`; -const format = (node, kind = '') => { +const format = (node, scope = '') => { let buffer = ''; if (typeof node === 'string') { buffer += node .split(newlineRegex) - .map((newline) => generateHLJSTag(kind, newline, true)) + .map((newline) => generateHLJSTag(scope, newline, true)) .join('\n'); - } else if (node.kind || node.sublanguage) { + } else if (node.scope || node.sublanguage) { const { children } = node; if (children.length && children.length === 1) { - buffer += format(children[0], node.kind); + buffer += format(children[0], node.scope); } else { - buffer += generateHLJSTag(node.kind); + buffer += generateHLJSTag(node.scope); children.forEach((subChild) => { - buffer += format(subChild, node.kind); + buffer += format(subChild, node.scope); }); buffer += `</span>`; } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 11708b6f1f6..9dc6dc1b93a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -1,40 +1,201 @@ <script> -import SafeHtml from '~/vue_shared/directives/safe_html'; -import Tracking from '~/tracking'; +import { GlLoadingIcon } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; -import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; +import Tracking from '~/tracking'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, + LEGACY_FALLBACKS, + CODEOWNERS_FILE_NAME, + CODEOWNERS_LANGUAGE, +} from './constants'; import Chunk from './components/chunk.vue'; +import { registerPlugins } from './plugins/index'; +/* + * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, + * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. + * + * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). + * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, + * it does not trigger a repaint on a parent element that wraps all 1000 lines. + */ export default { + name: 'SourceViewer', components: { + GlLoadingIcon, Chunk, }, - directives: { - SafeHtml, - }, mixins: [Tracking.mixin()], - inject: { - highlightWorker: { default: null }, - }, props: { blob: { type: Object, required: true, }, - chunks: { - type: Array, - required: false, - default: () => [], + }, + data() { + return { + languageDefinition: null, + content: this.blob.rawTextBlob, + hljs: null, + firstChunk: null, + chunks: {}, + isLoading: true, + isLineSelected: false, + lineHighlighter: null, + }; + }, + computed: { + splitContent() { + return this.content.split(/\r?\n/); + }, + language() { + return this.blob.name === this.$options.codeownersFileName + ? this.$options.codeownersLanguage + : ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()]; + }, + lineNumbers() { + return this.splitContent.length; + }, + unsupportedLanguage() { + const supportedLanguages = Object.keys(languageLoader); + const unsupportedLanguage = + !supportedLanguages.includes(this.language) && + !supportedLanguages.includes(this.blob.language?.toLowerCase()); + + return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; + }, + totalChunks() { + return Object.keys(this.chunks).length; }, }, - created() { - this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); + async created() { addBlobLinksTracking(); + this.trackEvent(EVENT_LABEL_VIEWER); + + if (this.unsupportedLanguage) { + this.handleUnsupportedLanguage(); + return; + } + + this.generateFirstChunk(); + this.hljs = await this.loadHighlightJS(); + + if (this.language) { + this.languageDefinition = await this.loadLanguage(); + } + + // Highlight the first chunk as soon as highlight.js is available + this.highlightChunk(null, true); + + window.requestIdleCallback(async () => { + // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first + this.generateRemainingChunks(); + this.isLoading = false; + await this.$nextTick(); + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + }); + }, + methods: { + trackEvent(label) { + this.track(EVENT_ACTION, { label, property: this.blob.language }); + }, + handleUnsupportedLanguage() { + this.trackEvent(EVENT_LABEL_FALLBACK); + this.$emit('error'); + }, + generateFirstChunk() { + const lines = this.splitContent.splice(0, LINES_PER_CHUNK); + this.firstChunk = this.createChunk(lines); + }, + generateRemainingChunks() { + const result = {}; + for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); + } + + this.chunks = result; + }, + createChunk(lines, startingFrom = 0) { + return { + content: lines.join('\n'), + startingFrom, + totalLines: lines.length, + language: this.language, + isHighlighted: false, + }; + }, + highlightChunk(index, isFirstChunk) { + const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; + + if (chunk.isHighlighted) { + return; + } + + const { highlightedContent, language } = this.highlight(chunk.content, this.language); + + Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); + + this.selectLine(); + + this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); + }, + highlight(content, language) { + let detectedLanguage = language; + let highlightedContent; + if (this.hljs) { + registerPlugins(this.hljs, this.blob.fileType, this.content); + if (!detectedLanguage) { + const hljsHighlightAuto = this.hljs.highlightAuto(content); + highlightedContent = hljsHighlightAuto.value; + detectedLanguage = hljsHighlightAuto.language; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(content, { language: this.language }).value; + } + } + + return { highlightedContent, language: detectedLanguage }; + }, + loadHighlightJS() { + // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) + return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await languageLoader[this.language](); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + async selectLine() { + if (this.isLineSelected || !this.lineHighlighter) { + return; + } + + this.isLineSelected = true; + await this.$nextTick(); + this.lineHighlighter.highlightHash(this.$route.hash); + }, }, userColorScheme: window.gon.user_color_scheme, + currentlySelectedLine: null, + codeownersFileName: CODEOWNERS_FILE_NAME, + codeownersLanguage: CODEOWNERS_LANGUAGE, }; </script> - <template> <div class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" @@ -44,15 +205,32 @@ export default { data-qa-selector="blob_viewer_file_content" > <chunk - v-for="(chunk, _, index) in chunks" - :key="index" - :chunk-index="index" - :is-highlighted="Boolean(chunk.isHighlighted)" - :raw-content="chunk.rawContent" - :highlighted-content="chunk.highlightedContent" + v-if="firstChunk" + :lines="firstChunk.lines" + :total-lines="firstChunk.totalLines" + :content="firstChunk.content" + :starting-from="firstChunk.startingFrom" + :is-highlighted="firstChunk.isHighlighted" + is-first-chunk + :language="firstChunk.language" + :blame-path="blob.blamePath" + /> + + <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> + <chunk + v-for="(chunk, key, index) in chunks" + v-else + :key="key" + :lines="chunk.lines" + :content="chunk.content" :total-lines="chunk.totalLines" :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" :blame-path="blob.blamePath" + :total-chunks="totalChunks" + @appear="highlightChunk" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue deleted file mode 100644 index 26cf45c7570..00000000000 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue +++ /dev/null @@ -1,227 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; -import addBlobLinksTracking from '~/blob/blob_links_tracking'; -import Tracking from '~/tracking'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, -} from './constants'; -import Chunk from './components/chunk_deprecated.vue'; -import { registerPlugins } from './plugins/index'; - -/* - * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, - * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. - * - * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). - * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, - * it does not trigger a repaint on a parent element that wraps all 1000 lines. - */ -export default { - components: { - GlLoadingIcon, - Chunk, - }, - mixins: [Tracking.mixin()], - props: { - blob: { - type: Object, - required: true, - }, - }, - data() { - return { - languageDefinition: null, - content: this.blob.rawTextBlob, - language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], - hljs: null, - firstChunk: null, - chunks: {}, - isLoading: true, - isLineSelected: false, - lineHighlighter: null, - }; - }, - computed: { - splitContent() { - return this.content.split(/\r?\n/); - }, - lineNumbers() { - return this.splitContent.length; - }, - unsupportedLanguage() { - const supportedLanguages = Object.keys(languageLoader); - const unsupportedLanguage = - !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language?.toLowerCase()); - - return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; - }, - totalChunks() { - return Object.keys(this.chunks).length; - }, - }, - async created() { - addBlobLinksTracking(); - this.trackEvent(EVENT_LABEL_VIEWER); - - if (this.unsupportedLanguage) { - this.handleUnsupportedLanguage(); - return; - } - - this.generateFirstChunk(); - this.hljs = await this.loadHighlightJS(); - - if (this.language) { - this.languageDefinition = await this.loadLanguage(); - } - - // Highlight the first chunk as soon as highlight.js is available - this.highlightChunk(null, true); - - window.requestIdleCallback(async () => { - // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first - this.generateRemainingChunks(); - this.isLoading = false; - await this.$nextTick(); - this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - }); - }, - methods: { - trackEvent(label) { - this.track(EVENT_ACTION, { label, property: this.blob.language }); - }, - handleUnsupportedLanguage() { - this.trackEvent(EVENT_LABEL_FALLBACK); - this.$emit('error'); - }, - generateFirstChunk() { - const lines = this.splitContent.splice(0, LINES_PER_CHUNK); - this.firstChunk = this.createChunk(lines); - }, - generateRemainingChunks() { - const result = {}; - for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { - const chunkIndex = Math.floor(i / LINES_PER_CHUNK); - const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); - result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); - } - - this.chunks = result; - }, - createChunk(lines, startingFrom = 0) { - return { - content: lines.join('\n'), - startingFrom, - totalLines: lines.length, - language: this.language, - isHighlighted: false, - }; - }, - highlightChunk(index, isFirstChunk) { - const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; - - if (chunk.isHighlighted) { - return; - } - - const { highlightedContent, language } = this.highlight(chunk.content, this.language); - - Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); - - this.selectLine(); - - this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); - }, - highlight(content, language) { - let detectedLanguage = language; - let highlightedContent; - if (this.hljs) { - registerPlugins(this.hljs, this.blob.fileType, this.content); - if (!detectedLanguage) { - const hljsHighlightAuto = this.hljs.highlightAuto(content); - highlightedContent = hljsHighlightAuto.value; - detectedLanguage = hljsHighlightAuto.language; - } else if (this.languageDefinition) { - highlightedContent = this.hljs.highlight(content, { language: this.language }).value; - } - } - - return { highlightedContent, language: detectedLanguage }; - }, - loadHighlightJS() { - // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) - return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); - }, - async loadLanguage() { - let languageDefinition; - - try { - languageDefinition = await languageLoader[this.language](); - this.hljs.registerLanguage(this.language, languageDefinition.default); - } catch (message) { - this.$emit('error', message); - } - - return languageDefinition; - }, - async selectLine() { - if (this.isLineSelected || !this.lineHighlighter) { - return; - } - - this.isLineSelected = true; - await this.$nextTick(); - this.lineHighlighter.highlightHash(this.$route.hash); - }, - }, - userColorScheme: window.gon.user_color_scheme, - currentlySelectedLine: null, -}; -</script> -<template> - <div - class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" - :class="$options.userColorScheme" - data-type="simple" - :data-path="blob.path" - data-qa-selector="blob_viewer_file_content" - > - <chunk - v-if="firstChunk" - :lines="firstChunk.lines" - :total-lines="firstChunk.totalLines" - :content="firstChunk.content" - :starting-from="firstChunk.startingFrom" - :is-highlighted="firstChunk.isHighlighted" - is-first-chunk - :language="firstChunk.language" - :blame-path="blob.blamePath" - /> - - <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> - <chunk - v-for="(chunk, key, index) in chunks" - v-else - :key="key" - :lines="chunk.lines" - :content="chunk.content" - :total-lines="chunk.totalLines" - :starting-from="chunk.startingFrom" - :is-highlighted="chunk.isHighlighted" - :chunk-index="index" - :language="chunk.language" - :blame-path="blob.blamePath" - :total-chunks="totalChunks" - @appear="highlightChunk" - /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue new file mode 100644 index 00000000000..7e18c8414d5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -0,0 +1,64 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; +import Tracking from '~/tracking'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; +import Chunk from './components/chunk_new.vue'; + +/* + * Note, this is a new experimental version of the SourceViewer, it is not ready for production use. + * See the following issue for more details: https://gitlab.com/gitlab-org/gitlab/-/issues/391586 + */ + +export default { + name: 'SourceViewerNew', + components: { + Chunk, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, + props: { + blob: { + type: Object, + required: true, + }, + chunks: { + type: Array, + required: false, + default: () => [], + }, + }, + created() { + this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); + addBlobLinksTracking(); + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> + +<template> + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + data-qa-selector="blob_viewer_file_content" + > + <chunk + v-for="(chunk, _, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :blame-path="blob.blamePath" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue index 247f49c1345..6764ad4ce73 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue @@ -1,20 +1,19 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { formatTimezone } from '~/lib/utils/datetime_utility'; export default { name: 'TimezoneDropdown', components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - }, - directives: { - autofocusonshow, + GlCollapsibleListbox, }, props: { + headerText: { + type: String, + required: false, + default: '', + }, value: { type: String, required: true, @@ -52,11 +51,10 @@ export default { identifier: timezone.identifier, })); }, - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.timezones.filter((timezone) => - timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm), - ); + filteredListboxItems() { + return this.timezones + .filter((timezone) => timezone.formattedTimezone.toLowerCase().includes(this.searchTerm)) + .map(({ formattedTimezone }) => ({ value: formattedTimezone, text: formattedTimezone })); }, selectedTimezoneLabel() { return this.tzValue || __('Select timezone'); @@ -68,14 +66,14 @@ export default { }, }, methods: { - selectTimezone(selectedTimezone) { - this.tzValue = selectedTimezone.formattedTimezone; + selectTimezone(formattedTimezone) { + const selectedTimezone = this.timezones.find( + (timezone) => timezone.formattedTimezone === formattedTimezone, + ); + this.tzValue = formattedTimezone; this.$emit('input', selectedTimezone); this.searchTerm = ''; }, - isSelected(timezone) { - return this.tzValue === timezone.formattedTimezone; - }, initialTimezone(timezones, value) { if (!value) { return undefined; @@ -89,6 +87,9 @@ export default { return undefined; }, + setSearchTerm(value) { + this.searchTerm = value?.toLowerCase(); + }, }, }; </script> @@ -101,31 +102,17 @@ export default { :value="timezoneIdentifier || value" type="hidden" /> - <gl-dropdown - :text="selectedTimezoneLabel" - :class="additionalClass" + <gl-collapsible-listbox + :header-text="headerText" + :items="filteredListboxItems" + :toggle-text="selectedTimezoneLabel" + :toggle-class="additionalClass" + :no-results-text="$options.translations.noResultsText" + :selected="tzValue" block - lazy - menu-class="gl-w-full!" - v-bind="$attrs" - > - <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> - <gl-dropdown-item - v-for="timezone in filteredResults" - :key="timezone.formattedTimezone" - :is-checked="isSelected(timezone)" - is-check-item - @click="selectTimezone(timezone)" - > - {{ timezone.formattedTimezone }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="!filteredResults.length" - class="gl-pointer-events-none" - data-testid="noMatchingResults" - > - {{ $options.translations.noResultsText }} - </gl-dropdown-item> - </gl-dropdown> + searchable + @search="setSearchTerm" + @select="selectTimezone" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js deleted file mode 100644 index c3b43d40adf..00000000000 --- a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -import { __ } from '~/locale'; - -export const SHOW_MORE = __('Show more'); -export const SHOW_LESS = __('Show less'); -export const STATES = { - INITIAL: 'initial', - TRUNCATED: 'truncated', - EXTENDED: 'extended', -}; diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js deleted file mode 100644 index 6a7ac72c31e..00000000000 --- a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js +++ /dev/null @@ -1,26 +0,0 @@ -import { escape } from 'lodash'; -import TruncatedText from './truncated_text.vue'; - -export default { - component: TruncatedText, - title: 'vue_shared/truncated_text', -}; - -const Template = (args, { argTypes }) => ({ - components: { TruncatedText }, - props: Object.keys(argTypes), - template: ` - <truncated-text v-bind="$props"> - <template v-if="${'default' in args}" v-slot> - <span style="white-space: pre-line;">${escape(args.default)}</span> - </template> - </truncated-text> - `, -}); - -export const Default = Template.bind({}); -Default.args = { - lines: 3, - mobileLines: 10, - default: [...Array(15)].map((_, i) => `line ${i + 1}`).join('\n'), -}; diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue deleted file mode 100644 index 96fc04ec825..00000000000 --- a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlResizeObserverDirective, GlButton } from '@gitlab/ui'; -import { STATES, SHOW_MORE, SHOW_LESS } from './constants'; - -export default { - name: 'TruncatedText', - components: { - GlButton, - }, - directives: { - GlResizeObserver: GlResizeObserverDirective, - }, - props: { - lines: { - type: Number, - required: false, - default: 3, - }, - mobileLines: { - type: Number, - required: false, - default: 10, - }, - }, - data() { - return { - state: STATES.INITIAL, - }; - }, - computed: { - showTruncationToggle() { - return this.state !== STATES.INITIAL; - }, - truncationToggleText() { - if (this.state === STATES.TRUNCATED) { - return SHOW_MORE; - } - return SHOW_LESS; - }, - styleObject() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return { '--lines': this.lines, '--mobile-lines': this.mobileLines }; - }, - isTruncated() { - return this.state === STATES.EXTENDED ? null : 'gl-truncate-text-by-line gl-overflow-hidden'; - }, - }, - methods: { - onResize({ target }) { - if (target.scrollHeight > target.offsetHeight) { - this.state = STATES.TRUNCATED; - } else if (this.state === STATES.TRUNCATED) { - this.state = STATES.INITIAL; - } - }, - toggleTruncation() { - if (this.state === STATES.TRUNCATED) { - this.state = STATES.EXTENDED; - } else if (this.state === STATES.EXTENDED) { - this.state = STATES.TRUNCATED; - } - }, - }, -}; -</script> - -<template> - <section> - <article - ref="content" - v-gl-resize-observer="onResize" - :class="isTruncated" - :style="styleObject" - > - <slot></slot> - </article> - <gl-button v-if="showTruncationToggle" variant="link" @click="toggleTruncation">{{ - truncationToggleText - }}</gl-button> - </section> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index abd3575d020..4879baced0d 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -308,7 +308,7 @@ export default { <gl-search-box-by-type ref="search" :value="search" - class="js-dropdown-input-field" + data-testid="user-search-input" @input="debouncedSearchKeyUpdate" /> </template> @@ -345,7 +345,7 @@ export default { data-testid="selected-participant" @click.native.capture.stop="unselect(item.username)" > - <sidebar-participant :user="item" :issuable-type="issuableType" /> + <sidebar-participant :user="item" :issuable-type="issuableType" selected /> </gl-dropdown-item> <template v-if="showCurrentUser"> <gl-dropdown-divider /> diff --git a/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue new file mode 100644 index 00000000000..b4afb27c497 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue @@ -0,0 +1,114 @@ +<script> +import { GlModal, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; +import getWritableForksQuery from './get_writable_forks.query.graphql'; + +export const i18n = { + btnText: __('Create a new fork'), + title: __('Fork project?'), + message: __('You can’t edit files directly in this project.'), + existingForksMessage: __( + 'To submit your changes in a merge request, switch to one of these forks or create a new fork.', + ), + newForkMessage: __('To submit your changes in a merge request, create a new fork.'), +}; + +export default { + name: 'ConfirmForkModal', + components: { + GlModal, + GlLoadingIcon, + GlLink, + }, + inject: { + projectPath: { + default: '', + }, + }, + model: { + prop: 'visible', + event: 'change', + }, + props: { + visible: { + type: Boolean, + required: false, + default: false, + }, + modalId: { + type: String, + required: true, + }, + forkPath: { + type: String, + required: true, + }, + }, + data() { + return { + forks: [], + }; + }, + apollo: { + forks: { + query: getWritableForksQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update({ project } = {}) { + return project?.visibleForks?.nodes.map((node) => { + return { + text: node.fullPath, + href: node.webUrl, + }; + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.forks.loading; + }, + hasWritableForks() { + return this.forks.length; + }, + btnActions() { + return { + cancel: { text: __('Cancel') }, + primary: { + text: this.$options.i18n.btnText, + attributes: { + href: this.forkPath, + variant: 'confirm', + 'data-qa-selector': 'fork_project_button', + }, + }, + }; + }, + }, + i18n, +}; +</script> +<template> + <gl-modal + :visible="visible" + data-qa-selector="confirm_fork_modal" + :modal-id="modalId" + :title="$options.i18n.title" + :action-primary="btnActions.primary" + :action-cancel="btnActions.cancel" + @change="$emit('change', $event)" + > + <p>{{ $options.i18n.message }}</p> + <gl-loading-icon v-if="isLoading" /> + <template v-else-if="hasWritableForks"> + <p>{{ $options.i18n.existingForksMessage }}</p> + <div v-for="fork in forks" :key="fork.text"> + <gl-link :href="fork.href">{{ fork.text }}</gl-link> + </div> + </template> + <p v-else>{{ $options.i18n.newForkMessage }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql b/app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql new file mode 100644 index 00000000000..044b79e64f3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql @@ -0,0 +1,12 @@ +query getWritableForks($projectPath: ID!) { + project(fullPath: $projectPath) { + id + visibleForks(minimumAccessLevel: DEVELOPER) { + nodes { + id + fullPath + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 3c08142e2b9..82f4edcbd5f 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -3,9 +3,7 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants'; export const i18n = { @@ -21,22 +19,18 @@ export const i18n = { webIdeTooltip: s__( 'WebIDE|Quickly and easily edit multiple files in your project. Press . to open', ), + toggleText: __('Edit'), }; -export const PREFERRED_EDITOR_KEY = 'gl-web-ide-button-selected'; -export const PREFERRED_EDITOR_RESET_KEY = 'gl-web-ide-button-selected-reset'; - export default { components: { ActionsButton, - LocalStorageSync, GlModal, GlSprintf, GlLink, ConfirmForkModal, }, i18n, - mixins: [glFeatureFlagsMixin()], props: { isFork: { type: Boolean, @@ -141,7 +135,6 @@ export default { }, data() { return { - selection: this.showPipelineEditorButton ? KEY_PIPELINE_EDITOR : KEY_WEB_IDE, showEnableGitpodModal: false, showForkModal: false, }; @@ -155,10 +148,11 @@ export default { this.gitpodAction, ].filter((action) => action); }, + hasActions() { + return this.actions.length > 0; + }, editAction() { - if (!this.showEditButton) { - return null; - } + if (!this.showEditButton) return null; const handleOptions = this.needsToFork ? { @@ -176,9 +170,8 @@ export default { return { key: KEY_EDIT, - text: __('Edit'), + text: __('Edit single file'), secondaryText: __('Edit this file only.'), - tooltip: '', attrs: { 'data-qa-selector': 'edit_button', 'data-track-action': 'click_consolidated_edit', @@ -199,13 +192,10 @@ export default { return __('Web IDE'); }, webIdeAction() { - if (!this.showWebIdeButton) { - return null; - } + if (!this.showWebIdeButton) return null; const handleOptions = this.needsToFork ? { - href: '#modal-confirm-fork-webide', handle: () => { if (this.disableForkModal) { this.$emit('edit', 'ide'); @@ -216,9 +206,7 @@ export default { }, } : { - href: this.webIdeUrl, - handle: (evt) => { - evt.preventDefault(); + handle: () => { visitUrl(this.webIdeUrl, true); }, }; @@ -227,7 +215,6 @@ export default { key: KEY_WEB_IDE, text: this.webIdeActionText, secondaryText: this.$options.i18n.webIdeText, - tooltip: this.$options.i18n.webIdeTooltip, attrs: { 'data-qa-selector': 'web_ide_button', 'data-track-action': 'click_consolidated_edit_ide', @@ -258,7 +245,6 @@ export default { key: KEY_PIPELINE_EDITOR, text: __('Edit in pipeline editor'), secondaryText, - tooltip: secondaryText, attrs: { 'data-qa-selector': 'pipeline_editor_button', }, @@ -283,7 +269,6 @@ export default { key: KEY_GITPOD, text: this.gitpodActionText, secondaryText, - tooltip: secondaryText, attrs: { 'data-qa-selector': 'gitpod_button', }, @@ -309,53 +294,31 @@ export default { }, }; }, - }, - mounted() { - this.resetPreferredEditor(); + mountForkModal() { + const { disableForkModal, showWebIdeButton, showEditButton } = this; + if (disableForkModal) return false; + + return showWebIdeButton || showEditButton; + }, }, methods: { - select(key) { - this.selection = key; - }, showModal(dataKey) { this[dataKey] = true; }, - resetPreferredEditor() { - if (!this.glFeatures.vscodeWebIde || this.showEditButton) { - return; - } - - if (localStorage.getItem(PREFERRED_EDITOR_RESET_KEY) === 'true') { - return; - } - - localStorage.setItem(PREFERRED_EDITOR_KEY, KEY_WEB_IDE); - localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, true); - - this.select(KEY_WEB_IDE); - }, }, webIdeButtonId: 'web-ide-link', - PREFERRED_EDITOR_KEY, }; </script> <template> <div class="gl-sm-ml-3"> <actions-button + v-if="hasActions" :id="$options.webIdeButtonId" :actions="actions" - :selected-key="selection" + :toggle-text="$options.i18n.toggleText" :variant="isBlob ? 'confirm' : 'default'" :category="isBlob ? 'primary' : 'secondary'" - show-action-tooltip - @select="select" - /> - <local-storage-sync - :storage-key="$options.PREFERRED_EDITOR_KEY" - :value="selection" - as-string - @input="select" /> <gl-modal v-if="computedShowGitpodButton && !gitpodEnabled" @@ -369,7 +332,7 @@ export default { </gl-sprintf> </gl-modal> <confirm-fork-modal - v-if="showWebIdeButton || showEditButton" + v-if="mountForkModal" v-model="showForkModal" :modal-id="forkModalId" :fork-path="forkPath" diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue new file mode 100644 index 00000000000..0ada1d8a6ae --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue @@ -0,0 +1 @@ +<template><div></div></template> 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 62a32d8942a..ce33d7a9b4b 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 @@ -4,7 +4,6 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective 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'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; @@ -91,7 +90,7 @@ export default { return this.issuable.assignees?.nodes || this.issuable.assignees || []; }, createdAt() { - return getTimeago().format(this.issuable.createdAt); + return this.timeFormatted(this.issuable.createdAt); }, timestamp() { if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { @@ -102,11 +101,11 @@ export default { formattedTimestamp() { if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { return sprintf(__('closed %{timeago}'), { - timeago: getTimeago().format(this.issuable.closedAt), + timeago: this.timeFormatted(this.issuable.closedAt), }); } else if (this.issuable.updatedAt !== this.issuable.createdAt) { return sprintf(__('updated %{timeAgo}'), { - timeAgo: getTimeago().format(this.issuable.updatedAt), + timeAgo: this.timeFormatted(this.issuable.updatedAt), }); } return undefined; 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 95108933a0b..4023337a1cb 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 @@ -6,12 +6,14 @@ import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import issuableEventHub from '~/issues/list/eventhub'; import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants'; import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; import IssuableItem from './issuable_item.vue'; import IssuableTabs from './issuable_tabs.vue'; +import IssuableGrid from './issuable_grid.vue'; const VueDraggable = () => import('vuedraggable'); @@ -30,12 +32,14 @@ export default { IssuableTabs, FilteredSearchBar, IssuableItem, + IssuableGrid, IssuableBulkEditSidebar, GlPagination, VueDraggable, PageSizeSelector, LocalStorageSync, }, + mixins: [glFeatureFlagMixin()], props: { namespace: { type: String, @@ -194,6 +198,11 @@ export default { required: false, default: false, }, + isGridView: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -229,6 +238,9 @@ export default { issuablesWrapper() { return this.isManualOrdering ? VueDraggable : 'ul'; }, + gridViewFeatureEnabled() { + return Boolean(this.glFeatures?.issuesGridView); + }, }, watch: { issuables(list) { @@ -342,7 +354,7 @@ export default { <template v-else> <component :is="issuablesWrapper" - v-if="issuables.length > 0" + v-if="issuables.length > 0 && !isGridView" class="content-list issuable-list issues-list" :class="{ 'manual-ordering': isManualOrdering }" v-bind="$options.vueDraggableAttributes" @@ -382,6 +394,9 @@ export default { </template> </issuable-item> </component> + <div v-else-if="issuables.length > 0 && isGridView"> + <issuable-grid /> + </div> <slot v-else name="empty-state"></slot> </template> 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 c8c7deff882..5ab2e346a7a 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 @@ -43,6 +43,11 @@ export default { type: String, required: true, }, + isSaas: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -81,11 +86,7 @@ export default { }, showNewTopLevelGroupAlert() { - if (this.activePanel.detailProps === undefined) { - return false; - } - - return this.activePanel.detailProps.parentGroupName === ''; + return this.isSaas && this.activePanel.detailProps?.parentGroupName === ''; }, showSuperSidebarToggle() { diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 472bc1dfacc..dd5d4edda59 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -2,6 +2,7 @@ import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import Tracking from '~/tracking'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { getDrawerBodyHeight } from '../utils/get_drawer_body_height'; import Feature from './feature.vue'; import SkeletonLoader from './skeleton_loader.vue'; @@ -22,11 +23,15 @@ export default { props: { versionDigest: { type: String, - required: true, + required: false, + default: undefined, }, }, computed: { ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']), + getDrawerHeaderHeight() { + return getContentWrapperHeight(); + }, }, mounted() { this.openDrawer(this.versionDigest); @@ -68,6 +73,7 @@ export default { ref="drawer" v-gl-resize-observer="handleResize" class="whats-new-drawer gl-reset-line-height" + :header-height="getDrawerHeaderHeight" :z-index="700" :open="open" @close="closeDrawer" @@ -75,7 +81,7 @@ export default { <template #title> <h4 class="page-title gl-my-2">{{ __("What's new") }}</h4> </template> - <template v-if="features.length"> + <template v-if="features.length || !fetching"> <gl-infinite-scroll :fetched-items="features.length" :max-list-height="drawerBodyHeight" diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js index f9b725ed429..1621c4d5f27 100644 --- a/app/assets/javascripts/whats_new/utils/notification.js +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -9,7 +9,7 @@ export const setNotification = (appEl) => { let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); - if (localStorage.getItem(STORAGE_KEY) === versionDigest) { + if (localStorage.getItem(STORAGE_KEY) === versionDigest || versionDigest === undefined) { notificationEl.classList.remove('with-notifications'); if (notificationCountEl) { notificationCountEl.parentElement.removeChild(notificationCountEl); 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 f8dfa1c7f01..1fa217f456e 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -19,17 +19,13 @@ import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ import $ from 'jquery'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; -import axios from '~/lib/utils/axios_utils'; +import descriptionVersionHistoryMixin from 'ee_else_ce/work_items/mixins/description_version_history'; import { getLocationHash } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; - export default { i18n: { deleteButtonLabel: __('Remove description history'), @@ -46,7 +42,7 @@ export default { GlTooltip: GlTooltipDirective, SafeHtml, }, - mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()], + mixins: [descriptionVersionHistoryMixin], props: { note: { type: Object, @@ -60,15 +56,13 @@ export default { showLines: false, loadingDiff: false, isLoadingDescriptionVersion: false, + descriptionVersions: {}, }; }, computed: { targetNoteHash() { return getLocationHash(); }, - descriptionVersions() { - return []; - }, noteAnchorId() { return `note_${this.noteId}`; }, @@ -78,42 +72,22 @@ export default { toggleIcon() { return this.expanded ? 'chevron-up' : 'chevron-down'; }, - // following 2 methods taken from code in `collapseLongCommitList` of notes.js: actionTextHtml() { return $(this.note.bodyHtml).unwrap().html(); }, - hasMoreCommits() { - return $(this.note.bodyHtml).filter('ul').children().length > MAX_VISIBLE_COMMIT_LIST_COUNT; - }, - descriptionVersion() { - return this.descriptionVersions[this.note.description_version_id]; + descriptionVersionId() { + return getIdFromGraphQLId(this.systemNoteDescriptionVersion?.id); }, noteId() { return getIdFromGraphQLId(this.note.id); }, + descriptionVersion() { + return this.descriptionVersions[this.descriptionVersionId]; + }, }, mounted() { renderGFM(this.$refs['gfm-content']); }, - methods: { - fetchDescriptionVersion() {}, - softDeleteDescriptionVersion() {}, - - async toggleDiff() { - this.showLines = !this.showLines; - - if (!this.lines.length) { - this.loadingDiff = true; - const { data } = await axios.get(this.note.outdated_line_change_path); - - this.lines = data.map((l) => ({ - ...l, - rich_text: l.rich_text.replace(/^[+ -]/, ''), - })); - this.loadingDiff = false; - } - }, - }, safeHtmlConfig: { ADD_TAGS: ['use'], // to support icon SVGs }, @@ -141,10 +115,7 @@ export default { :is-system-note="true" > <span ref="gfm-content" v-safe-html="actionTextHtml"></span> - <template - v-if="canSeeDescriptionVersion || note.outdated_line_change_path" - #extra-controls - > + <template v-if="canSeeDescriptionVersion" #extra-controls> · <gl-button v-if="canSeeDescriptionVersion" @@ -155,36 +126,20 @@ export default { @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > - <gl-button - v-if="note.outdated_line_change_path" - :icon="showLines ? 'chevron-up' : 'chevron-down'" - variant="link" - data-testid="outdated-lines-change-btn" - class="gl-vertical-align-text-bottom gl-font-sm!" - @click="toggleDiff" - > - {{ __('Compare changes') }} - </gl-button> </template> </note-header> </div> <div class="note-body"> - <div - v-safe-html="note.bodyHtml" - :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" - class="note-text md" - ></div> - <div v-if="hasMoreCommits" class="flex-list"> - <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> - <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" /> - <span>{{ __('Toggle commit list') }}</span> - </div> - </div> - <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <div v-if="shouldShowDescriptionVersion" class="description-version gl-pt-3! gl-pl-4"> <pre v-if="isLoadingDescriptionVersion" class="loading-state"> <gl-skeleton-loader /> </pre> - <pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre> + <pre + v-else + v-safe-html="descriptionVersion" + data-testid="description-version-diff" + class="wrapper gl-mt-3" + ></pre> <gl-button v-if="displayDeleteButton" v-gl-tooltip @@ -198,39 +153,6 @@ export default { @click="deleteDescriptionVersion" /> </div> - <div - v-if="lines.length && showLines" - class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" - > - <table - :class="$options.userColorSchemeClass" - class="code js-syntax-highlight" - data-testid="outdated-lines" - > - <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> - <td - :class="line.type" - class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" - > - {{ line.old_line }} - </td> - <td - :class="line.type" - class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!" - > - {{ line.new_line }} - </td> - <td - :class="line.type" - class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" - v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" - ></td> - </tr> - </table> - </div> - <div v-else-if="showLines" class="mt-4"> - <gl-skeleton-loader /> - </div> </div> </div> </timeline-entry-item> 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 e10a82b5197..c330eccb186 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 @@ -69,6 +69,11 @@ export default { required: false, default: false, }, + isInternalThread: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -132,8 +137,8 @@ export default { isProjectArchived() { return this.workItem?.project?.archived; }, - canUpdate() { - return this.workItem?.userPermissions?.updateWorkItem; + canCreateNote() { + return this.workItem?.userPermissions?.createNote; }, workItemState() { return this.workItem?.state; @@ -147,7 +152,8 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix': !this .isNewDiscussion, - 'gl-bg-white! gl-pt-0!': this.isEditing, + 'gl-pt-0! is-replying': this.isEditing, + 'internal-note': this.isInternalThread, }; }, }, @@ -162,7 +168,7 @@ export default { }, }, methods: { - async updateWorkItem(commentText) { + async updateWorkItem({ commentText, isNoteInternal = false }) { this.isSubmitting = true; this.$emit('replying', commentText); try { @@ -175,6 +181,7 @@ export default { noteableId: this.workItemId, body: commentText, discussionId: this.discussionId || null, + internal: isNoteInternal, }, }, update(store, createNoteData) { @@ -236,7 +243,7 @@ export default { <li :class="timelineEntryClass"> <work-item-note-signed-out v-if="!signedIn" /> <work-item-comment-locked - v-else-if="!canUpdate" + v-else-if="!canCreateNote" :work-item-type="workItemType" :is-project-archived="isProjectArchived" /> 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 cea28b30d42..c317ec48732 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,5 +1,5 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, __, sprintf } from '~/locale'; @@ -19,12 +19,24 @@ import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; export default { + i18n: { + internal: s__('Notes|Make this an internal note'), + internalVisibility: s__( + 'Notes|Internal notes are only visible to members with the role of Reporter or higher', + ), + addInternalNote: __('Add internal note'), + }, constantOptions: { markdownDocsPath: helpPagePath('user/markdown'), }, components: { GlButton, MarkdownEditor, + GlFormCheckbox, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], inject: ['fullPath'], @@ -89,6 +101,7 @@ export default { return { commentText: getDraft(this.autosaveKey) || this.initialValue || '', updateInProgress: false, + isNoteInternal: false, }; }, computed: { @@ -118,6 +131,9 @@ export default { cancelButtonText() { return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); }, + commentButtonTextComputed() { + return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; + }, }, methods: { setCommentText(newText) { @@ -213,18 +229,33 @@ export default { supports-quick-actions :autofocus="autofocus" @input="setCommentText" - @keydown.meta.enter="$emit('submitForm', commentText)" - @keydown.ctrl.enter="$emit('submitForm', commentText)" + @keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })" + @keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })" @keydown.esc.stop="cancelEditing" /> + <gl-form-checkbox + v-if="isNewDiscussion" + v-model="isNoteInternal" + class="gl-mb-2" + data-testid="internal-note-checkbox" + > + {{ $options.i18n.internal }} + <gl-icon + v-gl-tooltip:tooltipcontainer.bottom + name="question-o" + :size="16" + :title="$options.i18n.internalVisibility" + class="gl-text-blue-500" + /> + </gl-form-checkbox> <gl-button category="primary" variant="confirm" data-testid="confirm-button" :disabled="!commentText.length" :loading="isSubmitting" - @click="$emit('submitForm', commentText)" - >{{ commentButtonText }} + @click="$emit('submitForm', { commentText, isNoteInternal })" + >{{ commentButtonTextComputed }} </gl-button> <gl-button data-testid="cancel-button" 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 e98e03f76fd..f030363664f 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 @@ -164,12 +164,7 @@ export default { @reportAbuse="$emit('reportAbuse', note)" @error="$emit('error', $event)" /> - <timeline-entry-item - v-else - :class="{ 'internal-note': note.internal }" - :data-note-id="noteId" - class="note note-discussion gl-px-0" - > + <timeline-entry-item v-else :data-note-id="noteId" class="note note-discussion gl-px-0"> <div class="timeline-content"> <div class="discussion"> <div class="discussion-body"> @@ -222,7 +217,11 @@ export default { @error="$emit('error', $event)" /> </template> - <work-item-note-replying v-if="isReplying" :body="replyingText" /> + <work-item-note-replying + v-if="isReplying" + :is-internal-note="note.internal" + :body="replyingText" + /> <work-item-add-note v-if="shouldShowReplyForm" :notes-form="false" @@ -235,6 +234,7 @@ export default { :add-padding="true" :autocomplete-data-sources="autocompleteDataSources" :markdown-preview-path="markdownPreviewPath" + :is-internal-thread="note.internal" @startReplying="showReplyForm" @cancelEditing="hideReplyForm" @replied="onReplied" 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 75b0970a89e..7ad424868c6 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 @@ -106,6 +106,7 @@ export default { 'note note-wrapper note-comment': true, target: this.isTarget, 'inner-target': this.isTarget && !this.isFirstNote, + 'internal-note': this.note.internal, }; }, showReply() { @@ -147,8 +148,14 @@ export default { currentUserId() { return window.gon.current_user_id; }, - canReportAbuse() { - return getIdFromGraphQLId(this.author.id) !== this.currentUserId; + isCurrentUserAuthorOfNote() { + return getIdFromGraphQLId(this.author.id) === this.currentUserId; + }, + isWorkItemAuthor() { + return getIdFromGraphQLId(this.workItem?.author?.id) === getIdFromGraphQLId(this.author.id); + }, + projectName() { + return this.workItem?.project?.name; }, }, apollo: { @@ -179,7 +186,7 @@ export default { this.isEditing = true; updateDraft(this.autosaveKey, this.note.body); }, - async updateNote(newText) { + async updateNote({ commentText }) { try { this.isEditing = false; await this.$apollo.mutate({ @@ -187,7 +194,7 @@ export default { variables: { input: { id: this.note.id, - body: newText, + body: commentText, }, }, optimisticResponse: { @@ -195,14 +202,14 @@ export default { errors: [], note: { ...this.note, - bodyHtml: renderMarkdown(newText), + bodyHtml: renderMarkdown(commentText), }, }, }, }); clearDraft(this.autosaveKey); } catch (error) { - updateDraft(this.autosaveKey, newText); + updateDraft(this.autosaveKey, commentText); this.isEditing = true; this.$emit('error', __('Something went wrong when updating a comment. Please try again')); Sentry.captureException(error); @@ -309,6 +316,7 @@ export default { :created-at="note.createdAt" :note-id="note.id" :note-url="note.url" + :is-internal-note="note.internal" > <span v-if="note.createdAt" class="d-none d-sm-inline">·</span> </note-header> @@ -321,7 +329,12 @@ export default { :note-id="note.id" :is-author-an-assignee="isAuthorAnAssignee" :show-assign-unassign="canSetWorkItemMetadata" - :can-report-abuse="canReportAbuse" + :can-report-abuse="!isCurrentUserAuthorOfNote" + :is-work-item-author="isWorkItemAuthor" + :work-item-type="workItemType" + :is-author-contributor="note.authorIsContributor" + :max-access-level-of-author="note.maxAccessLevelOfAuthor" + :project-name="projectName" @startReplying="showReplyForm" @startEditing="startEditing" @error="($event) => $emit('error', $event)" 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 93f21f4fad8..b32a8c78c93 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,7 +1,14 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { + GlButton, + GlIcon, + GlTooltipDirective, + GlDisclosureDropdown, + GlDisclosureDropdownItem, +} from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import addAwardEmojiMutation from '../../graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; @@ -20,10 +27,11 @@ export default { components: { GlButton, GlIcon, + GlDisclosureDropdown, + GlDisclosureDropdownItem, ReplyButton, - GlDropdown, - GlDropdownItem, EmojiPicker: () => import('~/emoji/components/picker.vue'), + UserAccessRoleBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -67,6 +75,30 @@ export default { required: false, default: false, }, + workItemType: { + type: String, + required: true, + }, + isWorkItemAuthor: { + type: Boolean, + required: false, + default: false, + }, + isAuthorContributor: { + type: Boolean, + required: false, + default: false, + }, + maxAccessLevelOfAuthor: { + type: String, + required: false, + default: '', + }, + projectName: { + type: String, + required: false, + default: '', + }, }, computed: { assignUserActionText() { @@ -74,7 +106,24 @@ export default { ? this.$options.i18n.unassignUserText : this.$options.i18n.assignUserText; }, + displayAuthorBadgeText() { + return sprintf(__('This user is the author of this %{workItemType}.'), { + workItemType: this.workItemType.toLowerCase(), + }); + }, + displayMemberBadgeText() { + return sprintf(__('This user has the %{access} role in the %{name} project.'), { + access: this.maxAccessLevelOfAuthor.toLowerCase(), + name: this.projectName, + }); + }, + displayContributorBadgeText() { + return sprintf(__('This user has previously committed to the %{name} project.'), { + name: this.projectName, + }); + }, }, + methods: { async setAwardEmoji(name) { try { @@ -98,12 +147,43 @@ export default { Sentry.captureException(error); } }, + emitEvent(eventName) { + this.$emit(eventName); + this.$refs.dropdown.close(); + }, }, }; </script> <template> <div class="note-actions"> + <user-access-role-badge + v-if="isWorkItemAuthor" + v-gl-tooltip + :title="displayAuthorBadgeText" + class="gl-mr-3 gl-display-none gl-sm-display-block" + data-testid="author-badge" + > + {{ __('Author') }} + </user-access-role-badge> + <user-access-role-badge + v-if="maxAccessLevelOfAuthor" + v-gl-tooltip + class="gl-mr-3 gl-display-none gl-sm-display-block" + :title="displayMemberBadgeText" + data-testid="max-access-level-badge" + > + {{ maxAccessLevelOfAuthor }} + </user-access-role-badge> + <user-access-role-badge + v-else-if="isAuthorContributor" + v-gl-tooltip + class="gl-mr-3 gl-display-none gl-sm-display-block" + :title="displayContributorBadgeText" + data-testid="contributor-badge" + > + {{ __('Contributor') }} + </user-access-role-badge> <emoji-picker v-if="showAwardEmoji && glFeatures.workItemsMvc2" toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" @@ -135,46 +215,54 @@ export default { :aria-label="$options.i18n.editButtonText" @click="$emit('startEditing')" /> - <gl-dropdown + <gl-disclosure-dropdown + ref="dropdown" v-gl-tooltip data-testid="work-item-note-actions" icon="ellipsis_v" text-sr-only - right - :text="$options.i18n.moreActionsText" + placement="right" + :toggle-text="$options.i18n.moreActionsText" :title="$options.i18n.moreActionsText" category="tertiary" no-caret > - <gl-dropdown-item + <gl-disclosure-dropdown-item v-if="canReportAbuse" data-testid="abuse-note-action" - @click="$emit('reportAbuse')" + @action="emitEvent('reportAbuse')" > - {{ $options.i18n.reportAbuseText }} - </gl-dropdown-item> - <gl-dropdown-item + <template #list-item> + {{ $options.i18n.reportAbuseText }} + </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item data-testid="copy-link-action" :data-clipboard-text="noteUrl" - @click="$emit('notifyCopyDone')" + @action="emitEvent('notifyCopyDone')" > - <span>{{ $options.i18n.copyLinkText }}</span> - </gl-dropdown-item> - <gl-dropdown-item + <template #list-item> + {{ $options.i18n.copyLinkText }} + </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item v-if="showAssignUnassign" data-testid="assign-note-action" - @click="$emit('assignUser')" + @action="emitEvent('assignUser')" > - {{ assignUserActionText }} - </gl-dropdown-item> - <gl-dropdown-item + <template #list-item> + {{ assignUserActionText }} + </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item v-if="showEdit" - variant="danger" data-testid="delete-note-action" - @click="$emit('deleteNote')" + @action="emitEvent('deleteNote')" > - {{ $options.i18n.deleteNoteText }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item> + <span class="gl-text-red-500">{{ $options.i18n.deleteNoteText }}</span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </div> </template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue index f053f6e1d7c..e4c25f2c93a 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue @@ -26,6 +26,11 @@ export default { required: false, default: '', }, + isInternalNote: { + type: Boolean, + required: false, + default: false, + }, }, computed: { author() { @@ -36,12 +41,18 @@ export default { username: window.gon.current_username, }; }, + entryClass() { + return { + 'note note-wrapper note-comment being-posted': true, + 'internal-note': this.isInternalNote, + }; + }, }, }; </script> <template> - <timeline-entry-item class="note note-wrapper note-comment being-posted"> + <timeline-entry-item :class="entryClass"> <div class="timeline-avatar gl-float-left"> <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" /> </div> 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 8ea5873f73a..76a04bede61 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -8,11 +8,14 @@ import { GlModalDirective, GlToggle, } from '@gitlab/ui'; + import * as Sentry from '@sentry/browser'; -import { s__ } from '~/locale'; + +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, @@ -22,10 +25,15 @@ import { TEST_ID_NOTIFICATIONS_TOGGLE_FORM, TEST_ID_DELETE_ACTION, TEST_ID_PROMOTE_ACTION, + TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, + TEST_ID_COPY_REFERENCE_ACTION, WIDGET_TYPE_NOTIFICATIONS, I18N_WORK_ITEM_ERROR_CONVERTING, WORK_ITEM_TYPE_VALUE_KEY_RESULT, WORK_ITEM_TYPE_VALUE_OBJECTIVE, + I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL, + I18N_WORK_ITEM_ERROR_COPY_REFERENCE, + I18N_WORK_ITEM_ERROR_COPY_EMAIL, } from '../constants'; import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql'; import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql'; @@ -38,6 +46,9 @@ export default { notifications: s__('WorkItem|Notifications'), notificationOn: s__('WorkItem|Notifications turned on.'), notificationOff: s__('WorkItem|Notifications turned off.'), + copyReference: __('Copy reference'), + referenceCopied: __('Reference copied'), + emailAddressCopied: __('Email address copied'), }, components: { GlDropdown, @@ -55,6 +66,8 @@ export default { notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM, confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + copyReferenceTestId: TEST_ID_COPY_REFERENCE_ACTION, + copyCreateNoteEmailTestId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, deleteActionTestId: TEST_ID_DELETE_ACTION, promoteActionTestId: TEST_ID_PROMOTE_ACTION, inject: ['fullPath'], @@ -99,6 +112,21 @@ export default { required: false, default: false, }, + workItemReference: { + type: String, + required: false, + default: null, + }, + workItemCreateNoteEmail: { + type: String, + required: false, + default: null, + }, + isModal: { + type: Boolean, + required: false, + default: false, + }, }, apollo: { workItemTypes: { @@ -122,6 +150,15 @@ export default { deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType), areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType), convertError: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CONVERTING, this.workItemType), + copyCreateNoteEmail: sprintfWorkItem( + I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL, + this.workItemType, + ), + copyReferenceError: sprintfWorkItem(I18N_WORK_ITEM_ERROR_COPY_REFERENCE, this.workItemType), + copyCreateNoteEmailError: sprintfWorkItem( + I18N_WORK_ITEM_ERROR_COPY_EMAIL, + this.workItemType, + ), }; }, canPromoteToObjective() { @@ -142,10 +179,19 @@ export default { }, }, methods: { + copyToClipboard(text, message) { + if (this.isModal) { + navigator.clipboard.writeText(text); + } + toast(message); + }, handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); this.$emit('toggleWorkItemConfidentiality', !this.isConfidential); }, + handleDelete() { + this.$refs.modal.show(); + }, handleDeleteWorkItem() { this.track('click_delete_work_item'); this.$emit('deleteWorkItem'); @@ -284,17 +330,35 @@ export default { : $options.i18n.enableTaskConfidentiality }}</gl-dropdown-item > + </template> + <gl-dropdown-item + ref="workItemReference" + :data-testid="$options.copyReferenceTestId" + :data-clipboard-text="workItemReference" + @click="copyToClipboard(workItemReference, $options.i18n.referenceCopied)" + >{{ $options.i18n.copyReference }}</gl-dropdown-item + > + <template v-if="$options.isLoggedIn && workItemCreateNoteEmail"> + <gl-dropdown-item + ref="workItemCreateNoteEmail" + :data-testid="$options.copyCreateNoteEmailTestId" + :data-clipboard-text="workItemCreateNoteEmail" + @click="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" + >{{ i18n.copyCreateNoteEmail }}</gl-dropdown-item + > <gl-dropdown-divider v-if="canDelete" /> </template> <gl-dropdown-item v-if="canDelete" - v-gl-modal="'work-item-confirm-delete'" :data-testid="$options.deleteActionTestId" variant="danger" - >{{ i18n.deleteWorkItem }}</gl-dropdown-item + @click="handleDelete" > + {{ i18n.deleteWorkItem }} + </gl-dropdown-item> </gl-dropdown> <gl-modal + ref="modal" modal-id="work-item-confirm-delete" :title="i18n.deleteWorkItem" :ok-title="i18n.deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4e6583b65f8..d0d520ae5b1 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -126,8 +126,16 @@ export default { assigneesTitleId() { return uniqueId('assignees-title-'); }, + deduplicatedUsers() { + return this.users.nodes.reduce((acc, current) => { + if (!acc.find((node) => node.user.id === current.user.id)) { + acc.push(current); + } + return acc; + }, []); + }, searchUsers() { - return this.users.nodes.map((node) => addClass({ ...node, ...node.user })); + return this.deduplicatedUsers.map((node) => addClass({ ...node, ...node.user })); }, pageInfo() { return this.users.pageInfo; diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue index 91f87be1233..144c29b8ec3 100644 --- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue +++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue @@ -1,17 +1,15 @@ <script> import * as Sentry from '@sentry/browser'; +import { produce } from 'immer'; + import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { - EMOJI_ACTION_REMOVE, - EMOJI_ACTION_ADD, - WIDGET_TYPE_AWARD_EMOJI, - EMOJI_THUMBSDOWN, - EMOJI_THUMBSUP, -} from '../constants'; + +import updateAwardEmojiMutation from '../graphql/update_award_emoji.mutation.graphql'; +import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; +import { EMOJI_THUMBSDOWN, EMOJI_THUMBSUP, WIDGET_TYPE_AWARD_EMOJI } from '../constants'; export default { defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN], @@ -20,59 +18,146 @@ export default { AwardsList, }, props: { - workItem: { - type: Object, + workItemId: { + type: String, + required: true, + }, + workItemFullpath: { + type: String, required: true, }, awardEmoji: { type: Object, required: true, }, + workItemIid: { + type: String, + required: false, + default: null, + }, }, computed: { currentUserId() { return window.gon.current_user_id; }, + currentUserFullName() { + return window.gon.current_user_fullname; + }, /** * Parse and convert award emoji list to a format that AwardsList can understand */ awards() { - return this.awardEmoji.nodes.map((emoji, index) => ({ - id: index + 1, + return this.awardEmoji.nodes.map((emoji) => ({ name: emoji.name, user: { id: getIdFromGraphQLId(emoji.user.id), + name: emoji.user.name, }, })); }, }, methods: { + getAwards() { + return this.awardEmoji.nodes.map((emoji) => ({ + name: emoji.name, + user: { + id: getIdFromGraphQLId(emoji.user.id), + name: emoji.user.name, + }, + })); + }, + isEmojiPresentForCurrentUser(name) { + return ( + this.awards.findIndex( + (emoji) => emoji.name === name && emoji.user.id === this.currentUserId, + ) > -1 + ); + }, + /** + * Prepare award emoji nodes based on emoji name + * and whether the user has toggled the emoji off or on + */ + getAwardEmojiNodes(name, toggledOn) { + // If the emoji toggled on, add the emoji + if (toggledOn) { + // If emoji is already present in award list, no action is needed + if (this.isEmojiPresentForCurrentUser(name)) { + return this.awardEmoji.nodes; + } + + // else make a copy of unmutable list and return the list after adding the new emoji + const awardEmojiNodes = [...this.awardEmoji.nodes]; + awardEmojiNodes.push({ + name, + __typename: 'AwardEmoji', + user: { + id: convertToGraphQLId(TYPENAME_USER, this.currentUserId), + name: this.currentUserFullName, + __typename: 'UserCore', + }, + }); + + return awardEmojiNodes; + } + + // else just filter the emoji + return this.awardEmoji.nodes.filter( + (emoji) => + !(emoji.name === name && getIdFromGraphQLId(emoji.user.id) === this.currentUserId), + ); + }, + updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }) { + const query = { + query: workItemByIidQuery, + variables: { fullPath: this.workItemFullpath, iid: this.workItemIid }, + }; + + const sourceData = cache.readQuery(query); + + const newData = produce(sourceData, (draftState) => { + const { widgets } = draftState.workspace.workItems.nodes[0]; + const widgetAwardEmoji = widgets.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI); + + widgetAwardEmoji.awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn); + }); + + cache.writeQuery({ ...query, data: newData }); + }, handleAward(name) { // Decide action based on emoji is already present - const action = - this.awards.findIndex((emoji) => emoji.name === name) > -1 - ? EMOJI_ACTION_REMOVE - : EMOJI_ACTION_ADD; const inputVariables = { - id: this.workItem.id, - awardEmojiWidget: { - action, - name, - }, + awardableId: this.workItemId, + name, }; this.$apollo .mutate({ - mutation: updateWorkItemMutation, + mutation: updateAwardEmojiMutation, variables: { input: inputVariables, }, - optimisticResponse: this.getOptimisticResponse({ name, action }), + optimisticResponse: { + awardEmojiToggle: { + errors: [], + toggledOn: !this.isEmojiPresentForCurrentUser(name), + }, + }, + update: ( + cache, + { + data: { + awardEmojiToggle: { toggledOn }, + }, + }, + ) => { + // update the cache of award emoji widget object + this.updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }); + }, }) .then( ({ data: { - workItemUpdate: { errors }, + awardEmojiToggle: { errors }, }, }) => { if (errors?.length) { @@ -85,46 +170,6 @@ export default { Sentry.captureException(error); }); }, - /** - * Prepare workItemUpdate for optimistic response - */ - getOptimisticResponse({ name, action }) { - let awardEmojiNodes = [ - ...this.awardEmoji.nodes, - { - name, - __typename: 'AwardEmoji', - user: { - id: convertToGraphQLId(TYPENAME_USER, this.currentUserId), - __typename: 'UserCore', - }, - }, - ]; - // Exclude the award emoji node in case of remove action - if (action === EMOJI_ACTION_REMOVE) { - awardEmojiNodes = [...this.awardEmoji.nodes.filter((emoji) => emoji.name !== name)]; - } - return { - workItemUpdate: { - errors: [], - workItem: { - ...this.workItem, - widgets: [ - { - type: WIDGET_TYPE_AWARD_EMOJI, - awardEmoji: { - nodes: awardEmojiNodes, - __typename: 'AwardEmojiConnection', - }, - __typename: 'WorkItemWidgetAwardEmoji', - }, - ], - __typename: 'WorkItem', - }, - __typename: 'WorkItemUpdatePayload', - }, - }; - }, }, }; </script> 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 a4cbc430b84..61dec21cae4 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlFormGroup } from '@gitlab/ui'; +import { GlAlert, GlButton, GlForm, GlFormGroup } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; @@ -7,8 +7,6 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m import { __, s__ } from '~/locale'; import EditedAt from '~/issues/show/components/edited.vue'; import Tracking from '~/tracking'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { autocompleteDataSources, markdownPreviewPath } from '../utils'; import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql'; @@ -22,12 +20,12 @@ export default { EditedAt, GlAlert, GlButton, + GlForm, GlFormGroup, MarkdownEditor, - MarkdownField, WorkItemDescriptionRendered, }, - mixins: [glFeatureFlagMixin(), Tracking.mixin()], + mixins: [Tracking.mixin()], inject: ['fullPath'], props: { workItemId: { @@ -227,111 +225,84 @@ export default { <template> <div> - <gl-form-group - v-if="isEditing" - class="gl-mb-5 gl-border-t gl-pt-6" - :label="__('Description')" - label-for="work-item-description" - > - <markdown-editor - v-if="glFeatures.workItemsMvc" - class="gl-my-3 common-note-form" - :value="descriptionText" - :render-markdown-path="markdownPreviewPath" - :markdown-docs-path="$options.markdownDocsPath" - :form-field-props="formFieldProps" - :quick-actions-docs-path="$options.quickActionsDocsPath" - :autocomplete-data-sources="autocompleteDataSources" - enable-autocomplete - supports-quick-actions - autofocus - @input="setDescriptionText" - @keydown.meta.enter="updateWorkItem" - @keydown.ctrl.enter="updateWorkItem" - /> - <markdown-field - v-else - can-attach-file - :textarea-value="descriptionText" - :is-submitting="isSubmitting" - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="$options.markdownDocsPath" - :quick-actions-docs-path="$options.quickActionsDocsPath" - :autocomplete-data-sources="autocompleteDataSources" - class="gl-px-3 bordered-box gl-mt-5" + <gl-form v-if="isEditing" @submit.prevent="updateWorkItem" @reset.prevent="cancelEditing"> + <gl-form-group + class="gl-mb-5 gl-border-t gl-pt-6 common-note-form" + :label="__('Description')" + label-for="work-item-description" > - <template #textarea> - <textarea - v-bind="formFieldProps" - ref="textarea" - v-model="descriptionText" - :disabled="isSubmitting" - class="note-textarea js-gfm-input js-autosize markdown-area" - dir="auto" - data-supports-quick-actions="true" - @keydown.meta.enter="updateWorkItem" - @keydown.ctrl.enter="updateWorkItem" - @keydown.exact.esc.stop="cancelEditing" - @input="onInput" - ></textarea> - </template> - </markdown-field> - <div class="gl-display-flex"> - <gl-alert - v-if="hasConflicts" - :dismissible="false" - variant="danger" - class="gl-w-full" - data-testid="work-item-description-conflicts" - > - <p> - {{ - s__( - "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.", - ) - }} - </p> - <details class="gl-mb-5"> - <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary> - <textarea - class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3" - readonly - :value="conflictedDescription" - ></textarea> - </details> - <template #actions> + <markdown-editor + class="gl-my-5" + :value="descriptionText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.markdownDocsPath" + :form-field-props="formFieldProps" + :quick-actions-docs-path="$options.quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" + enable-autocomplete + supports-quick-actions + autofocus + @input="setDescriptionText" + @keydown.meta.enter="updateWorkItem" + @keydown.ctrl.enter="updateWorkItem" + /> + <div class="gl-display-flex"> + <gl-alert + v-if="hasConflicts" + :dismissible="false" + variant="danger" + class="gl-w-full" + data-testid="work-item-description-conflicts" + > + <p> + {{ + s__( + "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.", + ) + }} + </p> + <details class="gl-mb-5"> + <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary> + <textarea + class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3" + readonly + :value="conflictedDescription" + ></textarea> + </details> + <template #actions> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ s__('WorkItem|Save and overwrite') }} + </gl-button> + <gl-button + category="secondary" + class="gl-ml-3" + data-testid="cancel" + @click="cancelEditing" + >{{ s__('WorkItem|Discard changes') }} + </gl-button> + </template> + </gl-alert> + <template v-else> <gl-button category="primary" variant="confirm" :loading="isSubmitting" data-testid="save-description" - @click="updateWorkItem" - >{{ s__('WorkItem|Save and overwrite') }} + type="submit" + >{{ __('Save') }} </gl-button> - <gl-button - category="secondary" - class="gl-ml-3" - data-testid="cancel" - @click="cancelEditing" - >{{ s__('WorkItem|Discard changes') }} + <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" type="reset" + >{{ __('Cancel') }} </gl-button> </template> - </gl-alert> - <template v-else> - <gl-button - category="primary" - variant="confirm" - :loading="isSubmitting" - data-testid="save-description" - @click="updateWorkItem" - >{{ __('Save') }} - </gl-button> - <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing" - >{{ __('Cancel') }} - </gl-button> - </template> - </div> - </gl-form-group> + </div> + </gl-form-group> + </gl-form> <work-item-description-rendered v-else :work-item-description="workItemDescription" 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 0f1af44e8a1..1ac40fe7dcb 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,6 +1,5 @@ <script> import { isEmpty } from 'lodash'; -import { produce } from 'immer'; import { GlAlert, GlSkeletonLoader, @@ -11,15 +10,12 @@ import { GlTooltipDirective, GlEmptyState, } from '@gitlab/ui'; -import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; -import * as Sentry from '@sentry/browser'; +import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw'; import { s__ } from '~/locale'; import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility'; -import { isPositiveInteger } from '~/lib/utils/number_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; -import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { @@ -111,11 +107,6 @@ export default { required: false, default: false, }, - workItemId: { - type: String, - required: false, - default: null, - }, workItemIid: { type: String, required: false, @@ -128,16 +119,12 @@ export default { }, }, data() { - const workItemId = getParameterByName('work_item_id'); - return { error: undefined, updateError: undefined, workItem: {}, updateInProgress: false, - modalWorkItemId: isPositiveInteger(workItemId) - ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId) - : null, + modalWorkItemId: undefined, modalWorkItemIid: getParameterByName('work_item_iid'), isReportDrawerOpen: false, reportedUrl: '', @@ -279,7 +266,7 @@ export default { // Once more types are moved to have Work Items involved // we need to handle this properly. if (this.parentWorkItemType === WORK_ITEM_TYPE_VALUE_ISSUE) { - return `../../issues/${this.parentWorkItem?.iid}`; + return `../../-/issues/${this.parentWorkItem?.iid}`; } return this.parentWorkItem?.webUrl; }, @@ -347,10 +334,10 @@ export default { }, }, mounted() { - if (this.modalWorkItemId || this.modalWorkItemIid) { + if (this.modalWorkItemIid) { this.openInModal({ event: undefined, - modalWorkItem: { id: this.modalWorkItemId, iid: this.modalWorkItemIid }, + modalWorkItem: { iid: this.modalWorkItemIid }, }); } }, @@ -410,71 +397,6 @@ export default { this.error = this.$options.i18n.fetchError; document.title = s__('404|Not found'); }, - addChild(child) { - const { defaultClient: client } = this.$apollo.provider.clients; - this.toggleChildFromCache(child, child.id, client); - }, - toggleChildFromCache(workItem, childId, store) { - const query = { - query: workItemByIidQuery, - variables: { fullPath: this.fullPath, iid: this.workItemIid }, - }; - - const sourceData = store.readQuery(query); - - const newData = produce(sourceData, (draftState) => { - const { widgets } = draftState.workspace.workItems.nodes[0]; - const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); - - const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(workItem); - } - }); - - store.writeQuery({ ...query, data: newData }); - }, - async updateWorkItem(workItem, childId, parentId) { - return this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { input: { id: childId, hierarchyWidget: { parentId } } }, - update: (store) => this.toggleChildFromCache(workItem, childId, store), - }); - }, - async undoChildRemoval(workItem, childId) { - try { - const { data } = await this.updateWorkItem(workItem, childId, this.workItem.id); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast?.hide(); - } - } catch (error) { - this.updateError = s__('WorkItem|Something went wrong while undoing child removal.'); - Sentry.captureException(error); - } finally { - this.activeToast?.hide(); - } - }, - async removeChild({ id }) { - try { - const { data } = await this.updateWorkItem(null, id, null); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { - action: { - text: s__('WorkItem|Undo'), - onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, id), - }, - }); - } - } catch (error) { - this.updateError = s__('WorkItem|Something went wrong while removing child.'); - Sentry.captureException(error); - } - }, updateHasNotes() { this.$emit('has-notes'); }, @@ -593,7 +515,6 @@ export default { @error="updateError = $event" /> <work-item-actions - v-if="canUpdate || canDelete" :work-item-id="workItem.id" :subscribed-to-notifications="workItemNotificationsSubscribed" :work-item-type="workItemType" @@ -602,6 +523,9 @@ export default { :can-update="canUpdate" :is-confidential="workItem.confidential" :is-parent-confidential="parentWorkItemConfidentiality" + :work-item-reference="workItem.reference" + :work-item-create-note-email="workItem.createNoteEmail" + :is-modal="isModal" @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" @@ -713,8 +637,10 @@ export default { /> <work-item-award-emoji v-if="workItemAwardEmoji" - :work-item="workItem" + :work-item-id="workItem.id" + :work-item-fullpath="workItem.project.fullPath" :award-emoji="workItemAwardEmoji.awardEmoji" + :work-item-iid="workItemIid" @error="updateError = $event" /> <work-item-tree @@ -726,8 +652,6 @@ export default { :children="children" :can-update="canUpdate" :confidential="workItem.confidential" - @addWorkItemChild="addChild" - @removeChild="removeChild" @show-modal="openInModal" /> <work-item-notes 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 f8422dda211..ce06a744983 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 @@ -31,16 +31,12 @@ export default { data() { return { error: undefined, - updatedWorkItemId: null, updatedWorkItemIid: null, isModalShown: false, hasNotes: false, }; }, computed: { - displayedWorkItemId() { - return this.updatedWorkItemId || this.workItemId; - }, displayedWorkItemIid() { return this.updatedWorkItemIid || this.workItemIid; }, @@ -72,7 +68,6 @@ export default { }); }, closeModal() { - this.updatedWorkItemId = null; this.updatedWorkItemIid = null; this.error = ''; this.isModalShown = false; @@ -88,7 +83,6 @@ export default { this.$refs.modal.show(); }, updateModal($event, workItem) { - this.updatedWorkItemId = workItem.id; this.updatedWorkItemIid = workItem.iid; this.$emit('update-modal', $event, workItem); }, @@ -126,7 +120,6 @@ export default { <work-item-detail is-modal - :work-item-id="displayedWorkItemId" :work-item-iid="displayedWorkItemIid" class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate" @close="hide" diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 636c9357170..07aa98a1b44 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -26,9 +26,6 @@ export default function initWorkItemLinks() { el: workItemLinksRoot, name: 'WorkItemLinksRoot', apolloProvider, - components: { - WorkItemLinks, - }, provide: { fullPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, @@ -39,9 +36,10 @@ export default function initWorkItemLinks() { reportAbusePath: wiReportAbusePath, }, render: (createElement) => - createElement('work-item-links', { + createElement(WorkItemLinks, { props: { issuableId: parseInt(workItemLinksRoot.dataset.issuableId, 10), + issuableIid: parseInt(workItemLinksRoot.dataset.issuableIid, 10), }, }), }); diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index 4b6f581d76d..bf427feaa35 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -1,16 +1,19 @@ <script> +import * as Sentry from '@sentry/browser'; import produce from 'immer'; import Draggable from 'vuedraggable'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__ } from '~/locale'; import { defaultSortableOptions } from '~/sortable/constants'; import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '../../constants'; -import { findHierarchyWidgets, getWorkItemQuery } from '../../utils'; -import workItemQuery from '../../graphql/work_item.query.graphql'; -import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { findHierarchyWidgets } from '../../utils'; +import { addHierarchyChild, removeHierarchyChild } from '../../graphql/cache_utils'; import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql'; +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WorkItemLinkChild from './work_item_link_child.vue'; export default { @@ -42,11 +45,6 @@ export default { required: false, default: false, }, - fetchByIid: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -78,44 +76,71 @@ export default { .map((child) => findHierarchyWidgets(child.widgets) || {}) .some((hierarchy) => hierarchy.hasChildren); }, - queryVariables() { - return this.fetchByIid - ? { - fullPath: this.fullPath, - iid: this.workItemIid, - } - : { - id: this.workItemId, - }; - }, }, methods: { - addWorkItemQuery({ id, iid }) { - const variables = this.fetchByIid - ? { - fullPath: this.fullPath, - iid, - } - : { - id, - }; + async removeChild(child) { + try { + const { data } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: child.id, hierarchyWidget: { parentId: null } } }, + update: (cache) => removeHierarchyChild(cache, this.fullPath, this.workItemIid, child), + }); + + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors); + } + + this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: (_, toast) => { + this.undoChildRemoval(child); + toast.hide(); + }, + }, + }); + } catch (error) { + this.$emit('error', s__('WorkItem|Something went wrong while removing child.')); + Sentry.captureException(error); + } + }, + async undoChildRemoval(child) { + try { + const { data } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: child.id, hierarchyWidget: { parentId: this.workItemId } } }, + update: (cache) => addHierarchyChild(cache, this.fullPath, this.workItemIid, child), + }); + + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors); + } + + this.$toast.show(s__('WorkItem|Child removal reverted')); + } catch (error) { + this.$emit('error', s__('WorkItem|Something went wrong while undoing child removal.')); + Sentry.captureException(error); + } + }, + addWorkItemQuery({ iid }) { this.$apollo.addSmartQuery('prefetchedWorkItem', { - query() { - return this.fetchByIid ? workItemByIidQuery : workItemQuery; + query: workItemByIidQuery, + variables: { + fullPath: this.fullPath, + iid, }, - variables, update(data) { - return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + return data.workspace.workItems.nodes[0]; }, context: { isSingleRequest: true, }, }); }, - prefetchWorkItem({ id, iid }) { + prefetchWorkItem({ iid }) { if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) { this.prefetch = setTimeout( - () => this.addWorkItemQuery({ id, iid }), + () => this.addWorkItemQuery({ iid }), DEFAULT_DEBOUNCE_AND_THROTTLE_MS, ); } @@ -180,12 +205,13 @@ export default { }, update: (store) => { store.updateQuery( - { query: getWorkItemQuery(this.fetchByIid), variables: this.queryVariables }, + { + query: workItemByIidQuery, + variables: { fullPath: this.fullPath, iid: this.workItemIid }, + }, (sourceData) => produce(sourceData, (draftData) => { - const widgets = this.fetchByIid - ? draftData.workspace.workItems.nodes[0].widgets - : draftData.workItem.widgets; + const { widgets } = draftData.workspace.workItems.nodes[0]; const hierarchyWidget = findHierarchyWidgets(widgets); hierarchyWidget.children.nodes = updatedChildren; }), @@ -210,7 +236,8 @@ export default { }, ) .catch((error) => { - this.updateError = error.message; + this.$emit('error', error.message); + Sentry.captureException(error); }); }, }, @@ -235,7 +262,7 @@ export default { :has-indirect-children="hasIndirectChildren" @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" - @removeChild="$emit('removeChild', $event)" + @removeChild="removeChild" @click="$emit('show-modal', { event: $event, child: $event.childItem || child })" /> </component> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 5728e33880e..b9fc92304c0 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -3,7 +3,6 @@ import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { isMetaKey } from '~/lib/utils/common_utils'; @@ -11,10 +10,8 @@ import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants'; -import { findHierarchyWidgetChildren, getWorkItemQuery } from '../../utils'; -import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql'; -import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql'; -import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import { findHierarchyWidgetChildren } from '../../utils'; +import { removeHierarchyChild } from '../../graphql/cache_utils'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; @@ -40,25 +37,30 @@ export default { props: { issuableId: { type: Number, - required: false, - default: null, + required: true, + }, + issuableIid: { + type: Number, + required: true, }, }, apollo: { workItem: { - query() { - return getWorkItemQuery(this.fetchByIid); - }, + query: workItemByIidQuery, variables() { return { - id: this.issuableGid, + fullPath: this.fullPath, + iid: this.iid, }; }, + update(data) { + return data.workspace.workItems.nodes[0] ?? {}; + }, context: { isSingleRequest: true, }, skip() { - return !this.issuableId; + return !this.iid; }, error(e) { this.error = e.message || this.$options.i18n.fetchError; @@ -88,8 +90,6 @@ export default { return { isShownAddForm: false, activeChild: {}, - activeToast: null, - prefetchedWorkItem: null, error: undefined, parentIssue: null, formType: null, @@ -100,12 +100,12 @@ export default { }; }, computed: { - fetchByIid() { - return false; - }, confidential() { return this.parentIssue?.confidential || this.workItem?.confidential || false; }, + iid() { + return String(this.issuableIid); + }, issuableIteration() { return this.parentIssue?.iteration; }, @@ -138,9 +138,6 @@ export default { return this.isLoading && this.children.length === 0 ? '...' : this.children.length; }, }, - mounted() { - this.addWorkItemQuery(getParameterByName('work_item_iid')); - }, methods: { showAddForm(formType) { this.$refs.wrapper.show(); @@ -167,82 +164,13 @@ export default { this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(child) { - this.removeHierarchyChild(child); - this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); + const { defaultClient: cache } = this.$apollo.provider.clients; + removeHierarchyChild(cache, this.fullPath, this.iid, child); + this.$toast.show(s__('WorkItem|Task deleted')); }, updateWorkItemIdUrlQuery({ iid } = {}) { updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true }); }, - async addHierarchyChild(workItem) { - return this.$apollo.mutate({ - mutation: addHierarchyChildMutation, - variables: { id: this.issuableGid, workItem }, - }); - }, - async removeHierarchyChild(workItem) { - return this.$apollo.mutate({ - mutation: removeHierarchyChildMutation, - variables: { id: this.issuableGid, workItem }, - }); - }, - async undoChildRemoval(workItem, childId) { - const { data } = await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { input: { id: childId, hierarchyWidget: { parentId: this.issuableGid } } }, - }); - - await this.addHierarchyChild(workItem); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast?.hide(); - } - }, - async removeChild(workItem) { - const childId = workItem.id; - const { data } = await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, - }); - - await this.removeHierarchyChild(workItem); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { - action: { - text: s__('WorkItem|Undo'), - onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), - }, - }); - } - }, - addWorkItemQuery(iid) { - if (!iid) { - return; - } - - this.$apollo.addSmartQuery('prefetchedWorkItem', { - query: workItemByIidQuery, - variables: { - fullPath: this.fullPath, - iid, - }, - update(data) { - return data.workspace.workItems.nodes[0]; - }, - context: { - isSingleRequest: true, - }, - }); - }, - prefetchWorkItem({ iid }) { - this.prefetch = setTimeout( - () => this.addWorkItemQuery(iid), - DEFAULT_DEBOUNCE_AND_THROTTLE_MS, - ); - }, - clearPrefetching() { - clearTimeout(this.prefetch); - }, toggleReportAbuseDrawer(isOpen, reply = {}) { this.isReportDrawerOpen = isOpen; this.reportedUrl = reply.url; @@ -321,6 +249,7 @@ export default { ref="wiLinksForm" data-testid="add-links-form" :issuable-gid="issuableGid" + :work-item-iid="iid" :children-ids="childrenIds" :parent-confidential="confidential" :parent-iteration="issuableIteration" @@ -328,13 +257,13 @@ export default { :form-type="formType" :parent-work-item-type="workItem.workItemType.name" @cancel="hideAddForm" - @addWorkItemChild="addHierarchyChild" /> <work-item-children-wrapper :children="children" :can-update="canUpdate" :work-item-id="issuableGid" - @removeChild="removeChild" + :work-item-iid="iid" + @error="error = $event" @show-modal="openChild" /> <work-item-detail-modal diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 51c83784d06..289a48b5eaf 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -13,7 +13,8 @@ import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; +import { addHierarchyChild } from '../../graphql/cache_utils'; +import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; @@ -48,6 +49,11 @@ export default { required: false, default: null, }, + workItemIid: { + type: String, + required: false, + default: null, + }, childrenIds: { type: Array, required: false, @@ -292,13 +298,14 @@ export default { variables: { input: this.workItemInput, }, + update: (cache, { data }) => + addHierarchyChild(cache, this.fullPath, this.workItemIid, data.workItemCreate.workItem), }) .then(({ data }) => { if (data.workItemCreate?.errors?.length) { [this.error] = data.workItemCreate.errors; } else { this.unsetError(); - this.$emit('addWorkItemChild', data.workItemCreate.workItem); } }) .catch(() => { 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 cbca78e4b14..44e8dac79c4 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 @@ -6,7 +6,6 @@ import { WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, } from '../../constants'; -import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WidgetWrapper from '../widget_wrapper.vue'; import OkrActionsSplitButton from './okr_actions_split_button.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; @@ -61,10 +60,10 @@ export default { }, data() { return { + error: undefined, isShownAddForm: false, formType: null, childType: null, - prefetchedWorkItem: null, }; }, computed: { @@ -95,31 +94,17 @@ export default { showModal({ event, child }) { this.$emit('show-modal', { event, modalWorkItem: child }); }, - addWorkItemQuery(iid) { - if (!iid) { - return; - } - - this.$apollo.addSmartQuery('prefetchedWorkItem', { - query: workItemByIidQuery, - variables: { - fullPath: this.fullPath, - iid, - }, - update(data) { - return data.workspace.workItems.nodes[0]; - }, - context: { - isSingleRequest: true, - }, - }); - }, }, }; </script> <template> - <widget-wrapper ref="wrapper" data-testid="work-item-tree"> + <widget-wrapper + ref="wrapper" + :error="error" + data-testid="work-item-tree" + @dismissAlert="error = undefined" + > <template #header> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} </template> @@ -151,12 +136,12 @@ export default { ref="wiLinksForm" data-testid="add-tree-form" :issuable-gid="workItemId" + :work-item-iid="workItemIid" :form-type="formType" :parent-work-item-type="parentWorkItemType" :children-type="childType" :children-ids="childrenIds" :parent-confidential="confidential" - @addWorkItemChild="$emit('addWorkItemChild', $event)" @cancel="hideAddForm" /> <work-item-children-wrapper @@ -165,8 +150,7 @@ export default { :work-item-id="workItemId" :work-item-iid="workItemIid" :work-item-type="workItemType" - fetch-by-iid - @removeChild="$emit('removeChild', $event)" + @error="error = $event" @show-modal="showModal" /> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 092b90a5731..8fc460294e6 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -21,6 +21,7 @@ import { updateCacheAfterDeletingNote, } from '~/work_items/graphql/cache_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; +import { collapseSystemNotes } from '~/work_items/notes/collapse_utils'; import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; import WorkItemHistoryOnlyFilterNote from '~/work_items/components/notes/work_item_history_only_filter_note.vue'; import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql'; @@ -128,7 +129,9 @@ export default { notesArray() { const notes = this.workItemNotes?.nodes || []; - const visibleNotes = notes.filter((note) => { + let visibleNotes = collapseSystemNotes(notes); + + visibleNotes = visibleNotes.filter((note) => { const isSystemNote = this.isSystemNote(note); if (this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS && isSystemNote) { @@ -145,6 +148,7 @@ export default { if (this.sortOrder === DESC) { return [...visibleNotes].reverse(); } + return visibleNotes; }, commentsDisabled() { diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 6710c762c2e..f3beaebf403 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -92,6 +92,17 @@ export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP = s__( 'WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}.', ); +export const I18N_WORK_ITEM_ERROR_COPY_REFERENCE = s__( + 'WorkItem|Something went wrong while copying the %{workItemType} reference. Please try again.', +); +export const I18N_WORK_ITEM_ERROR_COPY_EMAIL = s__( + 'WorkItem|Something went wrong while copying the %{workItemType} email address. Please try again.', +); + +export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__( + 'WorkItem|Copy %{workItemType} email address', +); + export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( @@ -217,6 +228,8 @@ export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action' export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form'; export const TEST_ID_DELETE_ACTION = 'delete-action'; export const TEST_ID_PROMOTE_ACTION = 'promote-action'; +export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action'; +export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action'; export const ADD = 'ADD'; export const MARK_AS_DONE = 'MARK_AS_DONE'; diff --git a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql deleted file mode 100644 index 30a5d2388b1..00000000000 --- a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation addHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) { - addHierarchyChild(id: $id, workItem: $workItem) @client -} diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql index 85b88990cd6..bed09974ef5 100644 --- a/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql @@ -2,5 +2,6 @@ fragment AwardEmojiFragment on AwardEmoji { name user { id + name } } diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js index 455d8b8ae7b..03b45a45c39 100644 --- a/app/assets/javascripts/work_items/graphql/cache_utils.js +++ b/app/assets/javascripts/work_items/graphql/cache_utils.js @@ -1,5 +1,7 @@ import { produce } from 'immer'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import { findHierarchyWidgetChildren } from '~/work_items/utils'; const isNotesWidget = (widget) => widget.type === WIDGET_TYPE_NOTES; @@ -17,7 +19,6 @@ const updateNotesWidgetDataInDraftData = (draftData, notesWidget) => { * @param currentNotes * @param subscriptionData */ - export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => { if (!subscriptionData.data?.workItemNoteCreated) { return currentNotes; @@ -49,7 +50,6 @@ export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => * @param currentNotes * @param subscriptionData */ - export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => { if (!subscriptionData.data?.workItemNoteDeleted) { return currentNotes; @@ -86,3 +86,37 @@ export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => updateNotesWidgetDataInDraftData(draftData, notesWidget); }); }; + +export const addHierarchyChild = (cache, fullPath, iid, workItem) => { + const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]).push(workItem); + }), + }); +}; + +export const removeHierarchyChild = (cache, fullPath, iid, workItem) => { + const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + const children = findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]); + const index = children.findIndex((child) => child.id === workItem.id); + children.splice(index, 1); + }), + }); +}; diff --git a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql index 5050aa7cbda..3286895215f 100644 --- a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item_note.fragment.graphql" +#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql" mutation createWorkItemNote($input: CreateNoteInput!) { createNote(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql index 3da8e7677e4..eb52eb912e7 100644 --- a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item_note.fragment.graphql" +#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql" mutation updateWorkItemNote($input: UpdateNoteInput!) { updateNote(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql index 58561e33e53..635faf27892 100644 --- a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" -#import "./work_item_note.fragment.graphql" +#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql" fragment WorkItemDiscussionNote on Note { id diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql index 93616c39e55..c8b7d379074 100644 --- a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql @@ -10,6 +10,8 @@ fragment WorkItemNote on Note { createdAt lastEditedAt url + authorIsContributor + maxAccessLevelOfAuthor lastEditedBy { ...User webPath @@ -28,4 +30,11 @@ fragment WorkItemNote on Note { resolveNote repositionNote } + systemNoteMetadata { + id + descriptionVersion { + id + description + } + } } diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql index c68d5f491cf..1a6f4e44ee0 100644 --- a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql @@ -1,4 +1,4 @@ -#import "./work_item_note.fragment.graphql" +#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql" subscription workItemNoteUpdated($noteableId: NoteableID) { workItemNoteUpdated(noteableId: $noteableId) { diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql index 6b37c68cb43..6022b280d72 100644 --- a/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "./work_item_note.fragment.graphql" +#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql" query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { workspace: project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql deleted file mode 100644 index 3fece06eefa..00000000000 --- a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation removeHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) { - removeHierarchyChild(id: $id, workItem: $workItem) @client -} diff --git a/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql new file mode 100644 index 00000000000..1506d13d2da --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql @@ -0,0 +1,6 @@ +mutation updateWorkItemAwardEmojiWidget($input: AwardEmojiToggleInput!) { + awardEmojiToggle(input: $input) { + errors + toggledOn + } +} 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 b045796579b..1ae5617f04d 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -11,10 +11,13 @@ fragment WorkItem on WorkItem { createdAt updatedAt closedAt + reference(full: true) + createNoteEmail project { id fullPath archived + name } author { ...Author @@ -27,8 +30,9 @@ fragment WorkItem on WorkItem { userPermissions { deleteWorkItem updateWorkItem - setWorkItemMetadata @client adminParentLink + setWorkItemMetadata + createNote } widgets { ...WorkItemWidgets diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql deleted file mode 100644 index 3b46fed97ec..00000000000 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -#import "./work_item.fragment.graphql" - -query workItem($id: WorkItemID!) { - workItem(id: $id) { - ...WorkItem - } -} diff --git a/app/assets/javascripts/work_items/mixins/description_version_history.js b/app/assets/javascripts/work_items/mixins/description_version_history.js new file mode 100644 index 00000000000..d1006e37a70 --- /dev/null +++ b/app/assets/javascripts/work_items/mixins/description_version_history.js @@ -0,0 +1,14 @@ +// Placeholder for GitLab FOSS +// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js +export default { + computed: { + canSeeDescriptionVersion() {}, + displayDeleteButton() {}, + shouldShowDescriptionVersion() {}, + descriptionVersionToggleIcon() {}, + }, + methods: { + toggleDescriptionVersion() {}, + deleteDescriptionVersion() {}, + }, +}; diff --git a/app/assets/javascripts/work_items/notes/collapse_utils.js b/app/assets/javascripts/work_items/notes/collapse_utils.js new file mode 100644 index 00000000000..db7b4530e2a --- /dev/null +++ b/app/assets/javascripts/work_items/notes/collapse_utils.js @@ -0,0 +1,92 @@ +import { DESCRIPTION_TYPE, TIME_DIFFERENCE_VALUE } from '~/notes/constants'; + +/** + * Checks the time difference between two notes from their 'created_at' dates + * returns an integer + */ +export const getTimeDifferenceInMinutes = (noteBeginning, noteEnd) => { + const descriptionNoteBegin = new Date(noteBeginning.createdAt); + const descriptionNoteEnd = new Date(noteEnd.createdAt); + const timeDifferenceMinutes = (descriptionNoteEnd - descriptionNoteBegin) / 1000 / 60; + + return Math.ceil(timeDifferenceMinutes); +}; + +/** + * Checks if a note is a system note and if the content is description + * + * @param {Object} note + * @returns {Boolean} + */ +export const isDescriptionSystemNote = (note) => { + return note.system && note.body === DESCRIPTION_TYPE; +}; + +/** + * Collapses the system notes of a description type, e.g. Changed the description, n minutes ago + * the notes will collapse as long as they happen no more than 10 minutes away from each away + * in between the notes can be anything, another type of system note + * (such as 'changed the weight') or a comment. + * + * @param {Array} notes + * @returns {Array} + */ +export const collapseSystemNotes = (notes) => { + let lastDescriptionSystemNote = null; + let lastDescriptionSystemNoteIndex = -1; + + return notes.reduce((acc, currentNote) => { + const note = currentNote.notes.nodes[0]; + let lastStartVersionId = ''; + + if (isDescriptionSystemNote(note)) { + // is it the first one? + if (!lastDescriptionSystemNote) { + lastDescriptionSystemNote = note; + } else { + const timeDifferenceMinutes = getTimeDifferenceInMinutes(lastDescriptionSystemNote, note); + + // are they less than 10 minutes apart from the same user? + if ( + timeDifferenceMinutes > TIME_DIFFERENCE_VALUE || + note.author.id !== lastDescriptionSystemNote.author.id || + lastDescriptionSystemNote.systemNoteMetadata.descriptionVersion?.deleted + ) { + // update the previous system note + lastDescriptionSystemNote = note; + } else { + // set the first version to fetch grouped system note versions + + lastStartVersionId = lastDescriptionSystemNote.systemNoteMetadata.descriptionVersion.id; + + // delete the previous one + acc.splice(lastDescriptionSystemNoteIndex, 1); + } + } + + // update the previous system note index + lastDescriptionSystemNoteIndex = acc.length; + + acc.push({ + notes: { + nodes: [ + { + ...note, + systemNoteMetadata: { + ...note.systemNoteMetadata, + descriptionVersion: { + ...note.systemNoteMetadata.descriptionVersion, + startVersionId: lastStartVersionId, + }, + }, + }, + ], + }, + }); + } else { + acc.push(currentNote); + } + + return acc; + }, []); +}; diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 4f8c720eb1f..60503add119 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,7 +1,5 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; import ZenMode from '~/zen_mode'; import WorkItemDetail from '../components/work_item_detail.vue'; @@ -19,7 +17,7 @@ export default { }, inject: ['issuesListPath'], props: { - id: { + iid: { type: String, required: true, }, @@ -29,11 +27,6 @@ export default { error: '', }; }, - computed: { - gid() { - return convertToGraphQLId(TYPENAME_WORK_ITEM, this.id); - }, - }, mounted() { this.ZenMode = new ZenMode(); }, @@ -70,10 +63,6 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail - :work-item-id="gid" - :work-item-iid="id" - @deleteWorkItem="deleteWorkItem($event)" - /> + <work-item-detail :work-item-iid="iid" @deleteWorkItem="deleteWorkItem($event)" /> </div> </template> diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js index 1e3a7e184bb..664f3dcc8f7 100644 --- a/app/assets/javascripts/work_items/router/routes.js +++ b/app/assets/javascripts/work_items/router/routes.js @@ -1,7 +1,7 @@ function getRoutes() { const routes = [ { - path: '/:id', + path: '/:iid', name: 'workItem', component: () => import('../pages/work_item_root.vue'), props: true, diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 653819904af..13fc521464f 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -9,18 +9,12 @@ import { WORK_ITEM_TYPENAME, WORK_ITEM_UPDATE_PAYLOAD_TYPENAME, } from '~/work_items/constants'; -import workItemQuery from './graphql/work_item.query.graphql'; -import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; - -export function getWorkItemQuery(isFetchedByIid) { - return isFetchedByIid ? workItemByIidQuery : workItemQuery; -} export const findHierarchyWidgets = (widgets) => widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); export const findHierarchyWidgetChildren = (workItem) => - findHierarchyWidgets(workItem.widgets).children.nodes; + findHierarchyWidgets(workItem?.widgets)?.children.nodes; const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => { return `${ diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 4e3fb819f4c..2ed955a56b6 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -8,7 +8,9 @@ background-color: transparent; } - &:not(.ProseMirror-hideselection) .content-editor-selection { + &:not(.ProseMirror-hideselection) .content-editor-selection, + a.ProseMirror-selectednode, + span.ProseMirror-selectednode { background-color: $blue-100; box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100; } @@ -31,6 +33,17 @@ outline-offset: -3px; } + .selectedCell::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba($blue-200, 0.25); + pointer-events: none; + } + video { max-width: 400px; } @@ -43,17 +56,15 @@ list-style: none; padding: 0; - li { - margin: 0 !important; - } - } - - [data-type='taskList'] { + ul, p { margin-bottom: 0; } - li { + > li { + display: flex; + margin: 0; + > label, > div { display: inline-block; @@ -113,6 +124,15 @@ display: inherit; } } + + .gl-new-dropdown-inner li { + margin-left: 0 !important; + + &.gl-new-dropdown-item { + padding-left: $gl-spacing-scale-2; + padding-right: $gl-spacing-scale-2; + } + } } .table-creator-grid-item { @@ -155,8 +175,10 @@ } } - +.content-editor-table-dropdown .gl-new-dropdown-panel { + min-width: auto; +} .bubble-menu-form { - width: 320px; + min-width: 320px; } diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index 35c619a2e2f..f8160c04031 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -1,5 +1,4 @@ .whats-new-drawer { - margin-top: calc(#{$header-height} + #{$calc-application-bars-height}); @include gl-shadow-none; overflow-y: hidden; width: 500px; diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss index a023b41083d..bc49d17fcbb 100644 --- a/app/assets/stylesheets/fonts.scss +++ b/app/assets/stylesheets/fonts.scss @@ -14,8 +14,33 @@ Usage: } /* ------------------------------------------------------- +Monospaced font: GitLab Mono. + +Usage: + html { font-family: 'GitLab Mono', sans-serif; } +*/ +@font-face { + font-family: 'GitLab Mono'; + font-weight: 100 900; + font-display: optional; + font-style: normal; + src: font-url('gitlab-mono/GitLabMono.woff2') format('woff2'); +} + +@font-face { + font-family: 'GitLab Mono'; + font-weight: 100 900; + font-display: optional; + font-style: italic; + src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2'); +} + +/* ------------------------------------------------------- Monospaced font: JetBrains Mono. +All of the definitions below can be removed once +`GitLab Mono` is properly rolled out. + Usage: html { font-family: 'JetBrains Mono', sans-serif; } */ @@ -49,11 +74,6 @@ Usage: src: font-url('jetbrains-mono/JetBrainsMono-BoldItalic.woff2') format('woff2'); } -:root { - --default-mono-font: 'JetBrains Mono', 'Menlo'; - --default-regular-font: 'GitLab Sans', -apple-system; -} - // This isn't the best solution, but we needed a quick fix // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/ * { diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index a0bfca79dc3..f81371828f2 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -46,3 +46,7 @@ min-height: 34px; } } + +.gl-broadcast-message-content p:last-child { + margin: 0; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index f828129cdf1..2ec7c891197 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -8,6 +8,10 @@ --application-bar-left: 0px; --application-bar-right: 0px; + + @each $name, $size in $grid-breakpoints { + --breakpoint-#{$name}: #{$size}; + } } .with-performance-bar { diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 6c40781670a..192cb82aaab 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -1,6 +1,6 @@ // Common .diff-file { - margin-bottom: $gl-padding; + padding-bottom: $gl-padding; &.has-body { .file-title { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 503e22742ba..2e88b45d646 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -3,6 +3,7 @@ * */ .file-holder { + background: $white; border: 1px solid $border-color; border-radius: $border-radius-default; @@ -99,8 +100,6 @@ } .file-content { - background: $white; - &.image_file, &.audio, &.video { @@ -246,7 +245,6 @@ span.idiff { justify-content: space-between; background-color: $gray-light; border-bottom: 1px solid $border-color; - border-top: 1px solid $border-color; padding: $gl-padding-8 $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; @@ -472,6 +470,8 @@ span.idiff { } .mr-tree-list:not(.tree-list-blobs) { + overflow: hidden; + .tree-list-parent::before { @include gl-content-empty; @include gl-absolute; @@ -514,7 +514,6 @@ span.idiff { } .blame-commit { - padding: 5px 10px; width: 400px; flex: none; background: $gray-light; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 104cdf5544d..b78b07f953b 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -439,20 +439,11 @@ .vue-filtered-search-bar-container { .gl-search-box-by-click { - // Absolute width is needed to prevent flex to grow - // beyond the available width. - .gl-filtered-search-scrollable { - width: 1px; - } + // This enforces width of flex items to be + // calculated in advance so that content + // does not overflow. - // There are several styling issues happening while using - // `GlFilteredSearch` in roadmap due to some of our global - // styles which we need to override until those are fixed - // at framework level. - // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/908 - .input-group-prepend + .gl-filtered-search-scrollable { - border-radius: 0; - } + min-width: 0; } .sort-dropdown-container { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 0c53b3fd866..b2ba1d8830d 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -76,7 +76,7 @@ $search-input-field-x-min-width: 200px; } } - .header-search { + .header-search-form { min-width: $search-input-field-min-width; // This is a temporary workaround! diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 23dbe440d33..7dfbd5485d8 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -36,12 +36,13 @@ body { } .layout-page { - padding-top: $calc-application-header-height; + padding-top: calc(#{$header-height} + #{$calc-application-bars-height}); padding-bottom: $calc-application-footer-height; } .content-wrapper { - padding-bottom: 100px; + padding-top: var(--top-bar-height); + padding-bottom: $content-wrapper-padding; } .container { diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index e57dad9e4cb..5fdab7891ec 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -105,6 +105,7 @@ padding: 5px; box-shadow: none; width: 100%; + resize: none !important; } .md-suggestion-diff { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 15a31fbb3d9..529f6acaf04 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -156,6 +156,12 @@ background: linear-gradient(to $gradient-direction, $gradient-color 45%, rgba($gradient-color, 0.4)); + border: 0; + padding: 0; + + &:hover { + @include gl-focus; + } &.scrolling { visibility: visible; @@ -164,8 +170,8 @@ } svg { - position: relative; - top: 5px; + position: absolute; + top: 12px; font-size: 18px; } } @@ -430,8 +436,7 @@ &:last-child { &::after { - content: ''; - padding: 0; + display: none; } } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 9fdf889f4e9..b7a674a35e7 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -489,14 +489,12 @@ padding: 0; .issuable-context-form { - $issue-sticky-header-height: 76px; - - 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)); + top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height}); + height: calc(#{$calc-application-viewport-height} - #{$mr-sticky-header-height} - var(--mr-review-bar-height)); position: sticky; overflow: auto; padding: 0 15px; - margin-bottom: calc((#{$header-height} + $issue-sticky-header-height) * -1); + margin-bottom: calc((#{$content-wrapper-padding} * -1) + var(--mr-review-bar-height)); } } } diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 699693bd354..a3b238d657d 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -40,8 +40,9 @@ &:target, &.target { - .timeline-content { - background: $line-target-blue !important; + .timeline-content, + + .public-note.discussion-reply-holder { + background-color: $line-target-blue !important; } &.system-note .note-body .note-text.system-note-commit-list::after { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1ba3de68662..f77804fb7fc 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -460,6 +460,7 @@ $browser-scrollbar-size: 10px; * Misc */ $header-height: var(--header-height, 48px); +$content-wrapper-padding: 100px; $header-zindex: 1000; $zindex-dropdown-menu: 300; $ide-statusbar-height: 25px; @@ -568,9 +569,9 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts */ -$monospace-font: var(--default-mono-font, 'Menlo'), 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', +$monospace-font: 'GitLab Mono', 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular-font: var(--default-regular-font, -apple-system), BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', +$regular-font: 'GitLab Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; $gl-monospace-font: $monospace-font; diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 02469cf5165..9ad7c1b796c 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -134,10 +134,6 @@ $dark-il: #de935f; // Line numbers - .file-line-num { - @include line-link($white, 'link'); - } - .file-line-blame { @include line-link($white, 'git'); } diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 30d04b4002e..b1d89d3c253 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -125,10 +125,6 @@ $monokai-gh: #75715e; @include hljs-override('params', $monokai-nb); // Line numbers - .file-line-num { - @include line-link($white, 'link'); - } - .file-line-blame { @include line-link($white, 'git'); } diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 8339d7eff80..4762aae1d12 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -24,10 +24,6 @@ } // Line numbers - .file-line-num { - @include line-link($black, 'link'); - } - .file-line-blame { @include line-link($black, 'git'); } diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 075510e6e5f..7958959bfc3 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -128,10 +128,6 @@ $solarized-dark-il: #2aa198; @include hljs-override('params', $solarized-dark-nb); // Line numbers - .file-line-num { - @include line-link($white, 'link'); - } - .file-line-blame { @include line-link($white, 'git'); } diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 4e244ed7420..f156077c64d 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -118,10 +118,6 @@ $solarized-light-il: #2aa198; @include hljs-override('params', $solarized-light-nb); // Line numbers - .file-line-num { - @include line-link($black, 'link'); - } - .file-line-blame { @include line-link($black, 'git'); } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 969a6665634..14524e163b2 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -94,10 +94,6 @@ $white-gc-bg: #eaf2f5; } // Line numbers -.file-line-num { - @include line-link($black, 'link'); -} - .file-line-blame { @include line-link($black, 'git'); } diff --git a/app/assets/stylesheets/notify_enhanced.scss b/app/assets/stylesheets/notify_enhanced.scss index b331d997a97..a3e02dabe0e 100644 --- a/app/assets/stylesheets/notify_enhanced.scss +++ b/app/assets/stylesheets/notify_enhanced.scss @@ -32,6 +32,10 @@ body { font-size: inherit; } +pre { + font-size: 14px; +} + .gl-mb-5 { @include gl-mb-5; } diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss index ed2707ffbcd..7abde7c1a11 100644 --- a/app/assets/stylesheets/page_bundles/alert_management_settings.scss +++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss @@ -3,21 +3,13 @@ $stroke-size: 1px; .right-arrow { - @include gl-relative; - @include gl-w-full; height: $stroke-size; - background-color: var(--gray-400, $gray-400); min-width: $gl-spacing-scale-5; &-head { - @include gl-absolute; top: -$gl-spacing-scale-2; left: calc(100% - #{$gl-spacing-scale-3} - #{2 * $stroke-size}); - border-color: var(--gray-400, $gray-400); - @include gl-border-solid; border-width: 0 $stroke-size $stroke-size 0; - @include gl-display-inline-block; - @include gl-p-2; transform: rotate(-45deg); } } diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss index b42e6fd85fa..c19561a5e5e 100644 --- a/app/assets/stylesheets/page_bundles/design_management.scss +++ b/app/assets/stylesheets/page_bundles/design_management.scss @@ -29,7 +29,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); } .design-list-item { - height: 280px; + height: 160px; text-decoration: none; .icon-version-status { @@ -37,15 +37,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); right: 10px; top: 10px; } - - .card-body { - height: 230px; - } -} - -// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197 -.design-list-item-new { - height: 210px; } .design-note-pin { @@ -147,7 +138,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); } .design-note-pin { - margin-left: $gl-padding; + margin-left: 9px; } .design-discussion { @@ -157,13 +148,13 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); content: ''; border-left: 1px solid var(--gray-100, $gray-100); position: absolute; - left: 28px; + left: 22px; top: -17px; height: 17px; } .design-note { - padding: $gl-padding; + padding: $gl-padding-8; list-style: none; transition: background $gl-transition-duration-medium $general-hover-transition-curve; border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box @@ -179,7 +170,9 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); } .reply-wrapper { - padding: $gl-padding; + padding: $gl-padding-8 $gl-padding-8 $gl-padding-4; + background: $gray-10; + border-radius: 0 0 $border-radius-default $border-radius-default; } } diff --git a/app/assets/stylesheets/page_bundles/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss index 9e9723d2e5a..55fffad4a0e 100644 --- a/app/assets/stylesheets/page_bundles/editor.scss +++ b/app/assets/stylesheets/page_bundles/editor.scss @@ -1,15 +1,6 @@ @import 'page_bundles/mixins_and_variables_and_functions'; .file-editor { - .nav-links { - border-top: 1px solid var(--border-color, $border-color); - border-right: 1px solid var(--border-color, $border-color); - border-left: 1px solid var(--border-color, $border-color); - border-bottom: 0; - border-radius: $border-radius-small $border-radius-small 0 0; - background: var(--gray-50, $gray-50); - } - #editor, .editor { @include gl-border-0; @@ -110,11 +101,13 @@ .file-buttons { display: flex; - flex-direction: column; + flex-direction: row; + justify-content: space-between; width: 100%; + padding: $gl-padding-8 0 0; .md-header-toolbar { - margin: $gl-padding 0; + margin-left: 0; } .soft-wrap-toggle { @@ -129,6 +122,17 @@ } } +@include media-breakpoint-down(sm) { + .file-editor .file-buttons { + flex-direction: column; + padding: 0; + + .md-header-toolbar { + margin: $gl-padding-8 0; + } + } +} + .blob-new-page-title, .blob-edit-page-title { margin: 19px 0 21px; @@ -166,8 +170,7 @@ .license-selector, .gitignore-selector, .gitlab-ci-yml-selector, - .dockerfile-selector, - .metrics-dashboard-selector { + .dockerfile-selector { display: inline-block; vertical-align: top; font-family: $regular_font; diff --git a/app/assets/stylesheets/page_bundles/error_tracking_details.scss b/app/assets/stylesheets/page_bundles/error_tracking_details.scss index a47c5cc9b3e..9b93fa7f6d8 100644 --- a/app/assets/stylesheets/page_bundles/error_tracking_details.scss +++ b/app/assets/stylesheets/page_bundles/error_tracking_details.scss @@ -1,36 +1,5 @@ @import 'page_bundles/mixins_and_variables_and_functions'; -.error-details { - li { - @include gl-line-height-32; - } - - .btn-outline-info { - color: var(--blue-500, $blue-500); - border-color: var(--blue-500, $blue-500); - } - - .error-details-header { - border-bottom: 1px solid var(--border-color, $border-color); - - @include media-breakpoint-down(xs) { - flex-flow: column; - - .error-details-meta-culprit { - display: flex; - } - - .error-details-options { - width: 100%; - - .dropdown-toggle { - text-align: center; - } - } - } - } -} - .stacktrace { .file-title { svg { diff --git a/app/assets/stylesheets/page_bundles/error_tracking_index.scss b/app/assets/stylesheets/page_bundles/error_tracking_index.scss index 5c49bcc0348..4baab693aed 100644 --- a/app/assets/stylesheets/page_bundles/error_tracking_index.scss +++ b/app/assets/stylesheets/page_bundles/error_tracking_index.scss @@ -1,29 +1,13 @@ @import 'page_bundles/mixins_and_variables_and_functions'; .error-list { - .dropdown { - min-width: auto; - } - .filtered-search-box .form-control { min-width: unset; } - .sort-control { - .btn { - padding-right: 2rem; - } - - .gl-dropdown-caret { - position: absolute; - right: 0.5rem; - top: 0.5rem; - } - } - @include media-breakpoint-down(sm) { .error-list-table { - .table-col { + td { min-height: 68px; &:last-child { diff --git a/app/assets/stylesheets/page_bundles/login.scss b/app/assets/stylesheets/page_bundles/login.scss index 98fa45e0e3d..355d2afc0ba 100644 --- a/app/assets/stylesheets/page_bundles/login.scss +++ b/app/assets/stylesheets/page_bundles/login.scss @@ -55,12 +55,12 @@ .omniauth-container { box-shadow: none; } + } - .g-recaptcha { - > div { - margin-left: auto; - margin-right: auto; - } + .g-recaptcha { + > div { + margin-left: auto; + margin-right: auto; } } @@ -103,6 +103,10 @@ .username .validation-error { color: $red-500; } + + .terms .gl-form-checkbox { + @include gl-reset-font-size; + } } } @@ -192,13 +196,6 @@ } } - .form-control { - &:active, - &:focus { - background-color: $white; - } - } - .submit-container { margin-top: 16px; } @@ -267,7 +264,7 @@ left: 0; right: 0; height: 40px; - background: $white; + background: var(--white, $white); } .login-page-broadcast { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 61f8f0de557..fc4a9d3dff9 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -301,10 +301,6 @@ $tabs-holder-z-index: 250; } .tree-list-icon { - top: 50%; - left: 10px; - transform: translateY(-50%); - &, svg { fill: var(--gray-400, $gray-400); @@ -327,15 +323,18 @@ $tabs-holder-z-index: 250; .diffs .files { .diff-tree-list { position: relative; + // height is fully handled on the javascript side in narrow view + min-height: 0; + height: auto; top: 0; // !important is required to override inline styles of resizable sidebar width: 100% !important; // avoid sticky elements overlap header and other elements z-index: 1; + @include gl-mb-3; } .tree-list-holder { - max-height: calc(50px + 50vh); padding-right: 0; } } @@ -550,7 +549,8 @@ $tabs-holder-z-index: 250; border-radius: $border-radius-default; } - .mr-widget-section:not(:first-child) > div { + .mr-widget-section:not(:first-child) > div, + .mr-widget-section .mr-widget-section > div { border-top: solid 1px var(--border-color, $border-color); } @@ -1271,3 +1271,32 @@ $tabs-holder-z-index: 250; margin-right: 8px; border: 2px solid var(--gray-50, $gray-50); } + +.diff-file-discussions-wrapper { + @include gl-w-full; + + max-width: 800px; + + .diff-discussions > .notes { + @include gl-p-5; + } + + .diff-discussions:not(:first-child) >.notes { + @include gl-pt-0; + } + + .note-discussion { + @include gl-rounded-base; + + border: 1px solid var(--gray-100, $gray-100) !important; + } + + .discussion-collapsible { + @include gl-m-0; + @include gl-border-l-0; + @include gl-border-r-0; + @include gl-border-b-0; + @include gl-rounded-top-left-none; + @include gl-rounded-top-right-none; + } +} diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index 51bffd99dd0..10cc6cbd78e 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -4,17 +4,6 @@ box-shadow: inset 0 0 0 $gl-border-size-1 $red-500 if-important($important); } -.timezone-dropdown { - .gl-dropdown-item-text-primary { - @include gl-overflow-hidden; - @include gl-text-overflow-ellipsis; - } - - .btn-block { - margin-bottom: 0; - } -} - .modal-footer { @include gl-bg-gray-10; } @@ -52,65 +41,17 @@ $scroll-top-gradient: linear-gradient(to bottom, $gradient-dark-gray 0%, $gradie $scroll-bottom-gradient: linear-gradient(to bottom, $gradient-gray 0%, $gradient-dark-gray 100%); $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradient-gray 100%); -.schedule-shell { - @include gl-relative; - @include gl-h-full; - @include gl-w-full; - @include gl-overflow-x-auto; -} - .timeline-section { - @include gl-sticky; - @include gl-top-0; z-index: 20; - .timeline-header-label, - .timeline-header-item { - @include gl-float-left; - } - .timeline-header-label { - @include gl-sticky; - @include gl-top-0; - @include gl-left-0; width: $details-cell-width; - z-index: 2; } .timeline-header-item { - .item-sublabel .sublabel-value { - color: var(--gray-700, $gray-700); - @include gl-font-weight-normal; - - &.label-dark { - color: var(--gray-900, $gray-900); - } - - &.label-bold { - @include gl-font-weight-bold; - } - } - - .item-sublabel { - @include gl-relative; - @include gl-display-flex; - - .sublabel-value { - @include gl-flex-grow-1; - @include gl-flex-basis-0; - - text-align: center; - @include gl-font-base; - } - } - .current-day-indicator-header { - @include gl-absolute; - @include gl-bottom-0; height: $grid-size; width: $grid-size; - background-color: var(--red-500, $red-500); - @include gl-rounded-full; transform: translate(-50%, 50%); } @@ -137,35 +78,19 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi .details-cell, .timeline-cell { - @include gl-float-left; height: $item-height; } .details-cell { - @include gl-sticky; - @include gl-left-0; width: $details-cell-width; - @include gl-font-base; z-index: 10; } .timeline-cell { - @include gl-relative; - @include gl-bg-transparent; - border-right: $border-style; - - &:last-child { - @include gl-border-r-0; - } - .current-day-indicator { - @include gl-absolute; top: -1px; width: $gl-spacing-scale-1; height: calc(100% + 1px); - background-color: var(--red-500, $red-500); - @include gl-pointer-events-none; - transform: translateX(-50%); } } diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index d37e87b5cd5..d1d14cbcddd 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -4,11 +4,8 @@ $search-dropdown-max-height: 400px; $search-avatar-size: 16px; $search-sidebar-min-width: 240px; $search-sidebar-max-width: 300px; -$search-keyboard-shortcut: '/'; $language-filter-max-height: 20rem; -$border-radius-medium: 3px; - .search-results { .search-result-row { border-bottom: 1px solid var(--border-color, $border-color); @@ -21,6 +18,13 @@ $border-radius-medium: 3px; } } +.hr-x { + margin-left: -$gl-spacing-scale-5; + margin-right: -$gl-spacing-scale-5; + margin-top: $gl-spacing-scale-3; + margin-bottom: $gl-spacing-scale-5; +} + .language-filter-checkbox { .custom-control-label { flex-grow: 1; @@ -28,8 +32,17 @@ $border-radius-medium: 3px; } .search-sidebar { - @include media-breakpoint-up(md) { + @include media-breakpoint-down(lg) { + max-width: 100%; + } + + @include media-breakpoint-down(xl) { min-width: $search-sidebar-min-width; + max-width: $search-sidebar-min-width; + } + + @include media-breakpoint-up(xl) { + min-width: $search-sidebar-max-width; max-width: $search-sidebar-max-width; } @@ -38,6 +51,44 @@ $border-radius-medium: 3px; } } +.issue-filters { + .label-filter { + list-style: none; + + .header-search-dropdown-menu { + max-height: $language-filter-max-height; + + @include media-breakpoint-down(xl) { + min-width: calc(#{$search-sidebar-min-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5})); + max-width: calc(#{$search-sidebar-min-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5})); + } + + @include media-breakpoint-up(xl) { + min-width: calc(#{$search-sidebar-max-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5})); + max-width: calc(#{$search-sidebar-max-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5})); + } + + .label-with-color-checkbox { + max-height: $gl-spacing-scale-5; + + .custom-control-label { + margin-bottom: 0; + max-height: $gl-spacing-scale-5; + + .label-title { + margin-left: -$gl-spacing-scale-2; + } + } + } + } + } +} + +.advanced-search-promote { + padding-left: 5px; + padding-right: 5px; +} + .search-max-w-inherit { max-width: inherit; } diff --git a/app/assets/stylesheets/page_bundles/web_ide_loader.scss b/app/assets/stylesheets/page_bundles/web_ide_loader.scss new file mode 100644 index 00000000000..f922cadc235 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/web_ide_loader.scss @@ -0,0 +1,38 @@ +.web-ide-loader { + max-width: 400px; +} + +.web-ide-loader .tanuki-logo { + width: 50px; + height: 50px; +} + +.web-ide-loader .tanuki, +.web-ide-loader .right-cheek, +.web-ide-loader .chin, +.web-ide-loader .left-cheek { + animation: animate-tanuki 1.5s infinite; +} + +.web-ide-loader .right-cheek { + animation-delay: 0.35s; +} + +.web-ide-loader .chin { + animation-delay: 0.7s; +} + +.web-ide-loader .left-cheek { + animation-delay: 1.05s; +} + +@keyframes animate-tanuki { + 0%, + 50% { + filter: brightness(1) grayscale(0); + } + + 25% { + filter: brightness(1.2) grayscale(0.2); + } +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index d029aa01e37..322363d7f4b 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -246,7 +246,7 @@ table { .commit-diff { .discussion-reply-holder { background-color: $gray-light; - border-radius: 0 0 3px 3px; + border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base; padding: $gl-padding; border-top: 1px solid $gray-50; @@ -257,6 +257,12 @@ table { &.is-replying { padding-bottom: $gl-padding; + background-color: $white; + } + + &.internal-note, + &.internal-note.is-replying { + background-color: $orange-50; } .user-avatar-link { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index b31ee069236..c5b644bd72f 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -44,7 +44,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; background: var(--gray-50, $gray-50); } - .timeline-entry:last-child::before { + .timeline-entry:not(.draft-note):last-child::before { background: var(--white); .gl-dark & { @@ -667,7 +667,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .discussion-reply-holder { border-top: 0; - border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base; + border-radius: $gl-border-radius-base $gl-border-radius-base; position: relative; .discussion-form { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index d26e29c4047..8f52422b4b8 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -63,12 +63,6 @@ } } - @include media-breakpoint-down(md) { - .time-ago { - align-items: flex-end; - } - } - .duration, .finished-at { color: $gl-text-color-secondary; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 74ffebd44ec..7be15c2d8f9 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -2,6 +2,9 @@ // Please see the feedback issue for more details and help: // https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; +:root { + --white: #333238; +} *, *::before, *::after { @@ -17,10 +20,9 @@ header { } body { margin: 0; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -48,7 +50,7 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono", + font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; font-size: 1em; @@ -413,10 +415,9 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #333238; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -581,8 +582,7 @@ html { .layout-page { padding-top: calc( var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) + - var(--top-bar-height) + calc(var(--system-header-height) + var(--performance-bar-height)) ); padding-bottom: var(--system-footer-height); } @@ -631,6 +631,11 @@ html { --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; } .with-top-bar { --top-bar-height: 48px; @@ -822,15 +827,15 @@ kbd { .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } -.navbar-gitlab .header-search { +.navbar-gitlab .header-search-form { min-width: 320px; } @media (min-width: 768px) and (max-width: 1199.98px) { - .navbar-gitlab .header-search { + .navbar-gitlab .header-search-form { min-width: 200px; } } -.navbar-gitlab .header-search .keyboard-shortcut-helper { +.navbar-gitlab .header-search-form .keyboard-shortcut-helper { transform: translateY(calc(50% - 2px)); box-shadow: none; border-color: transparent; @@ -1716,7 +1721,7 @@ body.gl-dark .navbar-gitlab .navbar-sub-nav { body.gl-dark .navbar-gitlab .nav > li { color: #ececef; } -body.gl-dark .navbar-gitlab .nav > li.header-search-new { +body.gl-dark .navbar-gitlab .nav > li.header-search { color: #ececef; } body.gl-dark .navbar-gitlab .nav > li > a .notification-dot { @@ -1753,25 +1758,25 @@ body.gl-dark .notification-dot { background-color: #ececef; } -body.gl-dark .header-search { +body.gl-dark .header-search-form { background-color: rgba(236, 236, 239, 0.2) !important; border-radius: 4px; } -body.gl-dark .header-search svg.gl-search-box-by-type-search-icon { +body.gl-dark .header-search-form svg.gl-search-box-by-type-search-icon { color: rgba(236, 236, 239, 0.8); } -body.gl-dark .header-search input { +body.gl-dark .header-search-form input { background-color: transparent; color: rgba(236, 236, 239, 0.8); box-shadow: inset 0 0 0 1px rgba(236, 236, 239, 0.4); } -body.gl-dark .header-search input::placeholder { +body.gl-dark .header-search-form input::placeholder { color: rgba(236, 236, 239, 0.8); } -body.gl-dark .header-search input:active::placeholder { +body.gl-dark .header-search-form input:active::placeholder { color: #737278; } -body.gl-dark .header-search .keyboard-shortcut-helper { +body.gl-dark .header-search-form .keyboard-shortcut-helper { color: #ececef; background-color: rgba(236, 236, 239, 0.2); } @@ -1795,11 +1800,11 @@ body.gl-dark .navbar-gitlab .navbar-nav li.active > button { color: var(--gl-text-color); background-color: var(--gray-200); } -body.gl-dark .navbar-gitlab .header-search { +body.gl-dark .navbar-gitlab .header-search-form { background-color: var(--gray-100) !important; box-shadow: inset 0 0 0 1px var(--border-color) !important; } -body.gl-dark .navbar-gitlab .header-search:active { +body.gl-dark .navbar-gitlab .header-search-form:active { background-color: var(--gray-100) !important; box-shadow: inset 0 0 0 1px var(--blue-200) !important; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index c5a5d1aa289..65500800ce3 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -2,6 +2,9 @@ // Please see the feedback issue for more details and help: // https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; +:root { + --white: #fff; +} *, *::before, *::after { @@ -17,10 +20,9 @@ header { } body { margin: 0; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -48,7 +50,7 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono", + font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; font-size: 1em; @@ -413,10 +415,9 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -581,8 +582,7 @@ html { .layout-page { padding-top: calc( var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) + - var(--top-bar-height) + calc(var(--system-header-height) + var(--performance-bar-height)) ); padding-bottom: var(--system-footer-height); } @@ -631,6 +631,11 @@ html { --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; } .with-top-bar { --top-bar-height: 48px; @@ -822,15 +827,15 @@ kbd { .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } -.navbar-gitlab .header-search { +.navbar-gitlab .header-search-form { min-width: 320px; } @media (min-width: 768px) and (max-width: 1199.98px) { - .navbar-gitlab .header-search { + .navbar-gitlab .header-search-form { min-width: 200px; } } -.navbar-gitlab .header-search .keyboard-shortcut-helper { +.navbar-gitlab .header-search-form .keyboard-shortcut-helper { transform: translateY(calc(50% - 2px)); box-shadow: none; border-color: transparent; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index f676782de2a..40e1e4b1996 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -2,6 +2,9 @@ // Please see the feedback issue for more details and help: // https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; +:root { + --white: #fff; +} *, *::before, *::after { @@ -16,10 +19,9 @@ header { } body { margin: 0; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -79,16 +81,11 @@ input { button { text-transform: none; } -[role="button"] { - cursor: pointer; -} button:not(:disabled), -[type="button"]:not(:disabled), [type="submit"]:not(:disabled) { cursor: pointer; } button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { padding: 0; border-style: none; @@ -216,6 +213,10 @@ hr { .form-group { margin-bottom: 1rem; } +.form-text { + display: block; + margin-top: 0.25rem; +} .btn { display: inline-block; font-weight: 400; @@ -248,8 +249,7 @@ fieldset:disabled a.btn { .btn-block + .btn-block { margin-top: 0.5rem; } -input.btn-block[type="submit"], -input.btn-block[type="button"] { +input.btn-block[type="submit"] { width: 100%; } .custom-control { @@ -382,10 +382,9 @@ input.btn-block[type="button"] { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -584,9 +583,7 @@ body { font-size: 0.875rem; } button, -html [type="button"], -[type="submit"], -[role="button"] { +[type="submit"] { cursor: pointer; } h1, @@ -670,6 +667,11 @@ body.navless { --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; } .tab-content { overflow: visible; @@ -722,9 +724,6 @@ label { label.custom-control-label { font-weight: 400; } -label.label-bold { - font-weight: 600; -} .form-control { border-radius: 4px; padding: 6px 10px; @@ -775,18 +774,12 @@ svg { .gl-display-flex { display: flex; } -.gl-display-inline-block { - display: inline-block; -} .gl-align-items-center { align-items: center; } .gl-justify-content-space-between { justify-content: space-between; } -.gl-float-right { - float: right; -} .gl-w-10 { width: 3.5rem; } @@ -801,16 +794,13 @@ svg { width: 100%; } } +.gl-p-5 { + padding: 1rem; +} .gl-px-5 { padding-left: 1rem; padding-right: 1rem; } -.gl-pt-5 { - padding-top: 1rem; -} -.gl-pb-5 { - padding-bottom: 1rem; -} .gl-py-5 { padding-top: 1rem; padding-bottom: 1rem; @@ -824,9 +814,6 @@ svg { .gl-mr-auto { margin-right: auto; } -.gl-mb-1 { - margin-bottom: 0.125rem; -} .gl-mb-2 { margin-bottom: 0.25rem; } @@ -844,6 +831,9 @@ svg { .gl-text-center { text-align: center; } +.gl-text-right { + text-align: right; +} .gl-font-size-h2 { font-size: 1.1875rem; } diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 3a18f735217..e004ca4bb4a 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -261,7 +261,7 @@ body.gl-dark { } } - .header-search { + .header-search-form { background-color: var(--gray-100) !important; box-shadow: inset 0 0 0 1px var(--border-color) !important; @@ -296,7 +296,8 @@ body.gl-dark { } .timeline-entry.internal-note:not(.note-form) .timeline-content, -.timeline-entry.draft-note:not(.note-form) .timeline-content { +.timeline-entry.draft-note:not(.note-form) .timeline-content, +.discussion-reply-holder.internal-note { // soften on darkmode background-color: mix($gray-50, $orange-50, 75%) !important; } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 6e46100dbb3..f841a9047cc 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -68,7 +68,7 @@ > li { color: $search-and-nav-links; - &.header-search-new { + &.header-search { color: $gray-900; } @@ -151,7 +151,7 @@ } } - .header-search { + .header-search-form { background-color: $search-and-nav-links-a20 !important; border-radius: $border-radius-default; diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss index a0cbec9a92b..9b7fc10e769 100644 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ b/app/assets/stylesheets/themes/theme_light_gray.scss @@ -52,7 +52,7 @@ body { } } - .header-search { + .header-search-form { background-color: $white !important; box-shadow: inset 0 0 0 1px $border-color !important; border-radius: $border-radius-default; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index fd378dc7008..08c4efce542 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -153,21 +153,3 @@ .gl-fill-red-500 { fill: $red-500; } - -/** - Note: used by app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue - Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab/-/issues/408643 - - Although this solution uses vendor-prefixes, it is supported by all browsers and it is - currently the only way to truncate text by lines. See https://caniuse.com/css-line-clamp -**/ -.gl-truncate-text-by-line { - // stylelint-disable-next-line value-no-vendor-prefix - display: -webkit-box; - -webkit-line-clamp: var(--lines); - -webkit-box-orient: vertical; - - @include gl-media-breakpoint-down(sm) { - -webkit-line-clamp: var(--mobile-lines); - } -} diff --git a/app/components/diffs/base_component.rb b/app/components/diffs/base_component.rb index f5bc59cb314..9e1347d1e84 100644 --- a/app/components/diffs/base_component.rb +++ b/app/components/diffs/base_component.rb @@ -2,8 +2,6 @@ module Diffs class BaseComponent < ViewComponent::Base - warn_on_deprecated_slot_setter - # To make converting the partials to components easier, # we delegate all missing methods to the helpers, # where they probably are. diff --git a/app/components/layouts/horizontal_section_component.rb b/app/components/layouts/horizontal_section_component.rb index caeaa1782c0..48c960f17d9 100644 --- a/app/components/layouts/horizontal_section_component.rb +++ b/app/components/layouts/horizontal_section_component.rb @@ -2,8 +2,6 @@ module Layouts class HorizontalSectionComponent < ViewComponent::Base - warn_on_deprecated_slot_setter - # @param [Boolean] border # @param [Hash] options def initialize(border: true, options: {}) diff --git a/app/components/pajamas/alert_component.html.haml b/app/components/pajamas/alert_component.html.haml index 13c458f05e9..a7be57311bb 100644 --- a/app/components/pajamas/alert_component.html.haml +++ b/app/components/pajamas/alert_component.html.haml @@ -2,10 +2,10 @@ - if @show_icon = sprite_icon(icon, css_class: icon_classes) - if @dismissible - %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ @close_button_options, - type: 'button', - aria: { label: _('Dismiss') } } - = sprite_icon('close') + = render Pajamas::ButtonComponent.new(category: :tertiary, + icon: 'close', + size: :small, + button_options: dismissible_button_options) .gl-alert-content{ role: 'alert' } - if @title %h4.gl-alert-title diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb index 4475f4cde6e..008d624b7e2 100644 --- a/app/components/pajamas/alert_component.rb +++ b/app/components/pajamas/alert_component.rb @@ -50,5 +50,13 @@ module Pajamas def icon_classes "gl-alert-icon#{' gl-alert-icon-no-title' if @title.nil?}" end + + def dismissible_button_options + new_options = @close_button_options.deep_symbolize_keys # in case strings were used + new_options[:class] = "js-close gl-dismiss-btn #{new_options[:class]}" + new_options[:aria] ||= {} + new_options[:aria][:label] = _('Dismiss') # this will wipe out label if already present + new_options + end end end diff --git a/app/components/pajamas/component.rb b/app/components/pajamas/component.rb index a7b45ffd7fd..3b1826a646c 100644 --- a/app/components/pajamas/component.rb +++ b/app/components/pajamas/component.rb @@ -2,8 +2,6 @@ module Pajamas class Component < ViewComponent::Base - warn_on_deprecated_slot_setter - private # Filter a given a value against a list of allowed values diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 5a4f80fcb32..4cd76311b27 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -57,8 +57,8 @@ class AbuseReportsController < ApplicationController if @user.nil? redirect_to root_path, alert: _("Cannot create the abuse report. The user has been deleted.") - elsif @user.blocked? - redirect_to @user, alert: _("Cannot create the abuse report. This user has been blocked.") + elsif @user.banned? + redirect_to @user, alert: _("Cannot create the abuse report. This user has been banned.") end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 84e5cc430ef..6b998c3d494 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -13,7 +13,12 @@ class Admin::AbuseReportsController < Admin::ApplicationController def show; end def update - Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute + response = Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute + if response.success? + render json: { message: response.message } + else + render json: { message: response.message }, status: :unprocessable_entity + end end def destroy diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb index a5211961d81..d1b87e67800 100644 --- a/app/controllers/admin/background_migrations_controller.rb +++ b/app/controllers/admin/background_migrations_controller.rb @@ -18,7 +18,7 @@ module Admin @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' @migrations = @relations_by_tab[@current_tab].page(params[:page]) @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) - @databases = Gitlab::Database.db_config_names + @databases = Gitlab::Database.db_config_names(with_schema: :gitlab_shared) end def show diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 821c3cc1635..7f85103816e 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -93,6 +93,7 @@ module Admin target_path broadcast_type dismissable + show_in_cli ], target_access_levels: []).reverse_merge!(target_access_levels: []) end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 0f9ecc60648..001f5242138 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, [:create, :destroy, :edit, :index, :members_update, :new, :show, :update] + feature_category :groups_and_projects, [: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/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index 57ef75f12e9..c6c0e7eac90 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -3,7 +3,6 @@ class Admin::HooksController < Admin::ApplicationController include ::WebHooks::HookActions - feature_category :integrations urgency :low, [:test] def test diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 84eb90ce334..e79a899cee7 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -6,7 +6,7 @@ class Admin::ProjectsController < Admin::ApplicationController before_action :project, only: [:show, :transfer, :repository_check, :destroy, :edit, :update] before_action :group, only: [:show, :transfer] - feature_category :projects, [:index, :show, :transfer, :destroy, :edit, :update] + feature_category :groups_and_projects, [:index, :show, :transfer, :destroy, :edit, :update] feature_category :source_code_management, [:repository_check] def index diff --git a/app/controllers/admin/topics/avatars_controller.rb b/app/controllers/admin/topics/avatars_controller.rb index 7acdec424b4..ee0a7e68bb3 100644 --- a/app/controllers/admin/topics/avatars_controller.rb +++ b/app/controllers/admin/topics/avatars_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::Topics::AvatarsController < Admin::ApplicationController - feature_category :projects + feature_category :groups_and_projects def destroy @topic = Projects::Topic.find(params[:topic_id]) diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb index 94d084932ad..c4de600dd1d 100644 --- a/app/controllers/admin/topics_controller.rb +++ b/app/controllers/admin/topics_controller.rb @@ -6,7 +6,11 @@ class Admin::TopicsController < Admin::ApplicationController before_action :topic, only: [:edit, :update, :destroy] - feature_category :projects + feature_category :groups_and_projects + + before_action do + push_frontend_feature_flag(:content_editor_on_issues, current_user) + end def index @topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count @@ -23,7 +27,7 @@ class Admin::TopicsController < Admin::ApplicationController @topic = Projects::Topic.new(topic_params) if @topic.save - redirect_to edit_admin_topic_path(@topic), notice: format(_('Topic %{topic_name} was successfully created.'), topic_name: @topic.name) + redirect_to admin_topics_path, notice: format(_('Topic %{topic_name} was successfully created.'), topic_name: @topic.name) else render "new" end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 45a7901b2c4..3c96e49499f 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -56,7 +56,7 @@ class Admin::UsersController < Admin::ApplicationController log_impersonation_event - flash[:alert] = format(_("You are now impersonating %{username}"), username: user.username) + flash[:notice] = format(_("You are now impersonating %{username}"), username: user.username) redirect_to root_path else @@ -87,12 +87,14 @@ class Admin::UsersController < Admin::ApplicationController end def activate - if user.blocked? - return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) - end + activate_service = Users::ActivateService.new(current_user) + result = activate_service.execute(user) - user.activate - redirect_back_or_admin_user(notice: _("Successfully activated")) + if result.success? + redirect_back_or_admin_user(notice: _("Successfully activated")) + else + redirect_back_or_admin_user(alert: result.message) + end end def deactivate diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9749af08dca..08e4f4956df 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -110,7 +110,7 @@ class ApplicationController < ActionController::Base rescue_from Gitlab::Git::ResourceExhaustedError do |e| response.headers.merge!(e.headers) - render plain: e.message, status: :too_many_requests + render plain: e.message, status: :service_unavailable end content_security_policy do |p| diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 01cc1ef21c6..c9cb1ca14e2 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -7,7 +7,7 @@ class AutocompleteController < ApplicationController before_action :check_search_rate_limit!, only: [:users, :projects] feature_category :user_profile, [:users, :user] - feature_category :projects, [:projects] + feature_category :groups_and_projects, [:projects] feature_category :team_planning, [:award_emojis] feature_category :code_review_workflow, [:merge_request_target_branches] feature_category :continuous_delivery, [:deploy_keys_with_owners] diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb index dd5be596ad1..e7b76b87ad9 100644 --- a/app/controllers/clusters/base_controller.rb +++ b/app/controllers/clusters/base_controller.rb @@ -10,7 +10,7 @@ class Clusters::BaseController < ApplicationController feature_category :deployment_management urgency :low, [ - :index, :show, :environments, :cluster_status, :prometheus_proxy, + :index, :show, :environments, :cluster_status, :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 873aa5e18dc..2f6331a6822 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -2,7 +2,6 @@ class Clusters::ClustersController < Clusters::BaseController include RoutableActions - include Metrics::Dashboard::PrometheusApiProxy include MetricsDashboard before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache] diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 53bb11090c8..896004045f4 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -23,6 +23,8 @@ module CreatesCommit commit_params = @commit_params.merge( start_project: start_project, start_branch: @start_branch, + source_project: @project, + target_project: target_project, branch_name: @branch_name ) diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb index e562cf5dbe4..aac55af0bac 100644 --- a/app/controllers/concerns/impersonation.rb +++ b/app/controllers/concerns/impersonation.rb @@ -6,7 +6,7 @@ module Impersonation SESSION_KEYS_TO_DELETE = %w[ github_access_token gitea_access_token gitlab_access_token bitbucket_token bitbucket_refresh_token bitbucket_server_personal_access_token - bulk_import_gitlab_access_token fogbugz_token + bulk_import_gitlab_access_token fogbugz_token cloud_platform_access_token ].freeze def current_user diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb index c0816c2fe9c..10e86bcc98d 100644 --- a/app/controllers/concerns/integrations/actions.rb +++ b/app/controllers/concerns/integrations/actions.rb @@ -7,7 +7,12 @@ module Integrations::Actions include Integrations::Params include IntegrationsHelper + # :overrides is defined in Admin:IntegrationsController + # rubocop:disable Rails/LexicallyScopedActionFilter + before_action :ensure_integration_enabled, only: [:edit, :update, :overrides, :test] before_action :integration, only: [:edit, :update, :overrides, :test] + # rubocop:enable Rails/LexicallyScopedActionFilter + before_action :render_404, only: :edit, if: -> do integration.to_param == 'prometheus' && Feature.enabled?(:remove_monitor_metrics) end @@ -58,6 +63,10 @@ module Integrations::Actions @integration ||= find_or_initialize_non_project_specific_integration(params[:id]) end + def ensure_integration_enabled + render_404 unless integration + end + def success_message if integration.active? format(s_('Integrations|%{integration} settings saved and active.'), integration: integration.title) diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index af984776828..19e458307a1 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -9,6 +9,7 @@ module Integrations :app_store_key_id, :app_store_private_key, :app_store_private_key_file_name, + :app_store_protected_refs, :active, :alert_events, :api_key, diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 09b82e36b1a..31675a58163 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -156,7 +156,7 @@ module MembershipActions [:inherited] else if Feature.enabled?(:webui_members_inherited_users, current_user) - [:inherited, :direct, :shared_from_groups] + [:inherited, :direct, :shared_from_groups, (:invited_groups if params[:project_id])].compact else [:inherited, :direct] end diff --git a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb deleted file mode 100644 index ea9fd2de961..00000000000 --- a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module Metrics::Dashboard::PrometheusApiProxy - extend ActiveSupport::Concern - include RenderServiceResults - - included do - before_action :authorize_read_prometheus!, only: [:prometheus_proxy] - end - - def prometheus_proxy - variable_substitution_result = - proxy_variable_substitution_service.new(proxyable, permit_params).execute - - return error_response(variable_substitution_result) if variable_substitution_result[:status] == :error - - prometheus_result = ::Prometheus::ProxyService.new( - proxyable, - proxy_method, - proxy_path, - variable_substitution_result[:params] - ).execute - - return continue_polling_response if prometheus_result.nil? - return error_response(prometheus_result) if prometheus_result[:status] == :error - - success_response(prometheus_result) - end - - private - - def proxyable - raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" - end - - def proxy_variable_substitution_service - raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" - end - - def permit_params - params.permit! - end - - def proxy_method - request.method - end - - def proxy_path - params[:proxy_path] - end -end diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index 7e202235cfa..7a84c597424 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -10,6 +10,8 @@ module MetricsDashboard extend ActiveSupport::Concern def metrics_dashboard + return not_found if Feature.enabled?(:remove_monitor_metrics) + result = dashboard_finder.find( project_for_dashboard, current_user, diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 06b9c901e4a..7b2cf131fce 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -62,7 +62,7 @@ module NotesActions end if @note.errors.present? && @note.errors.attribute_names != [:commands_only, :command_names] - render json: json, status: :unprocessable_entity + render json: { errors: errors_on_create(@note.errors) }, status: :unprocessable_entity else render json: json end @@ -75,15 +75,21 @@ module NotesActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def update @note = Notes::UpdateService.new(project, current_user, update_note_params).execute(note) - unless @note + if @note.destroyed? head :gone return end - prepare_notes_for_rendering([@note]) - respond_to do |format| - format.json { render json: note_json(@note) } + format.json do + if @note.errors.present? + render json: { errors: @note.errors.full_messages.to_sentence }, status: :unprocessable_entity + else + prepare_notes_for_rendering([@note]) + render json: note_json(@note) + end + end + format.html { redirect_back_or_default } end end @@ -309,6 +315,12 @@ module NotesActions noteable.discussions_rendered_on_frontend? end + + def errors_on_create(errors) + return { commands_only: errors.messages[:commands_only] } if errors.key?(:commands_only) + + errors.full_messages.to_sentence + end end NotesActions.prepend_mod_with('NotesActions') diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index 889d3f0a9d2..d768dae03a2 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -2,7 +2,7 @@ module RendersNotes # rubocop:disable Gitlab/ModuleWithInstanceVariables - def prepare_notes_for_rendering(notes, noteable = nil) + def prepare_notes_for_rendering(notes) preload_noteable_for_regular_notes(notes) preload_max_access_for_authors(notes, @project) preload_author_status(notes) diff --git a/app/controllers/concerns/search_rate_limitable.rb b/app/controllers/concerns/search_rate_limitable.rb index 7cce30dbb3c..1105e9bbbfd 100644 --- a/app/controllers/concerns/search_rate_limitable.rb +++ b/app/controllers/concerns/search_rate_limitable.rb @@ -20,9 +20,7 @@ module SearchRateLimitable def safe_search_scope # Sometimes search scope can have abusive length or invalid keyword. We don't want # to send those to redis for rate limit checks, so we guard against that here. - return if Feature.disabled?(:search_rate_limited_scopes) || abuse_detected? - - params[:scope] + params[:scope] unless abuse_detected? end def abuse_detected? diff --git a/app/controllers/concerns/skips_already_signed_in_message.rb b/app/controllers/concerns/skips_already_signed_in_message.rb new file mode 100644 index 00000000000..7630cf4f4e1 --- /dev/null +++ b/app/controllers/concerns/skips_already_signed_in_message.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# This concern can be included in devise controllers to skip showing an "already signed in" +# warning on registrations and logins +module SkipsAlreadySignedInMessage + extend ActiveSupport::Concern + + included do + # replaced with :require_no_authentication_without_flash + # rubocop: disable Rails/LexicallyScopedActionFilter + # The actions are defined in Devise + skip_before_action :require_no_authentication, only: [:new, :create] + before_action :require_no_authentication_without_flash, only: [:new, :create] + # rubocop: enable Rails/LexicallyScopedActionFilter + end + + def require_no_authentication_without_flash + require_no_authentication + + return unless flash[:alert] == I18n.t('devise.failure.already_authenticated') + + flash[:alert] = nil + end +end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 62c5aee16e4..b14ef8dffa9 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -56,7 +56,7 @@ module SnippetsActions @noteable = @snippet @discussions = @snippet.discussions - @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable) + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) render 'show' end diff --git a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb index 23db6a4b368..9cad61ed362 100644 --- a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb +++ b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb @@ -28,8 +28,13 @@ module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport # recaptcha gem. This is a field which is automatically included by calling the # `#recaptcha_tags` method within a HAML template's form. def convert_html_spam_params_to_headers + return unless params['g-recaptcha-response'] || params[:spam_log_id] + request.headers['X-GitLab-Captcha-Response'] = params['g-recaptcha-response'] if params['g-recaptcha-response'] request.headers['X-GitLab-Spam-Log-Id'] = params[:spam_log_id] if params[:spam_log_id] + + # Reset the spam_params on the request context, since they have changed mid-request + Gitlab::RequestContext.instance.spam_params = ::Spam::SpamParams.new_from_request(request: request) end end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 0d64a685065..222fcc17222 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -11,7 +11,7 @@ module UploadsActions prepend_before_action :set_request_format_from_path_extension rescue_from FileUploader::InvalidSecret, with: :render_404 - rescue_from ::Gitlab::Utils::PathTraversalAttackError do + rescue_from ::Gitlab::PathTraversal::PathTraversalAttackError do head :bad_request end end @@ -37,7 +37,7 @@ module UploadsActions # - or redirect to its URL # def show - Gitlab::Utils.check_path_traversal!(params[:filename]) + Gitlab::PathTraversal.check_path_traversal!(params[:filename]) return render_404 unless uploader&.exists? @@ -129,6 +129,14 @@ module UploadsActions return unless uploader = build_uploader uploader.retrieve_from_store!(params[:filename]) + + Gitlab::AppJsonLogger.info( + message: 'Deprecated usage of build_uploader_from_params', + uploader_class: uploader.class.name, + path: params[:filename], + exists: uploader.exists? + ) + uploader end diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb index ae971b7bc95..076347922c8 100644 --- a/app/controllers/concerns/web_hooks/hook_actions.rb +++ b/app/controllers/concerns/web_hooks/hook_actions.rb @@ -9,6 +9,7 @@ module WebHooks attr_writer :hooks, :hook before_action :hook_logs, only: :edit + feature_category :webhooks end def index diff --git a/app/controllers/concerns/web_hooks/hook_log_actions.rb b/app/controllers/concerns/web_hooks/hook_log_actions.rb index f3378d7c857..321cee5a452 100644 --- a/app/controllers/concerns/web_hooks/hook_log_actions.rb +++ b/app/controllers/concerns/web_hooks/hook_log_actions.rb @@ -11,7 +11,7 @@ module WebHooks respond_to :html - feature_category :integrations + feature_category :webhooks urgency :low, [:retry] end diff --git a/app/controllers/concerns/web_ide_csp.rb b/app/controllers/concerns/web_ide_csp.rb index c2d66abb538..0327020a0c2 100644 --- a/app/controllers/concerns/web_ide_csp.rb +++ b/app/controllers/concerns/web_ide_csp.rb @@ -5,25 +5,27 @@ module WebIdeCSP included do before_action :include_web_ide_csp + end - # We want to include frames from `/assets/webpack` of the request's host to - # support URL flexibility with the Web IDE. - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118875 - def include_web_ide_csp - return if request.content_security_policy.directives.blank? + # We want to include frames from `/assets/webpack` of the request's host to + # support URL flexibility with the Web IDE. + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118875 + def include_web_ide_csp + return if request.content_security_policy.directives.blank? - base_uri = URI(request.url) - base_uri.path = ::Gitlab.config.gitlab.relative_url_root || '/' - # `.path +=` handles combining `x/` and `/foo` - base_uri.path += '/assets/webpack/' - webpack_url = base_uri.to_s + base_uri = URI(request.url) + base_uri.path = ::Gitlab.config.gitlab.relative_url_root || '/' + # `.path +=` handles combining `x/` and `/foo` + base_uri.path += '/assets/webpack/' + webpack_url = base_uri.to_s - default_src = Array(request.content_security_policy.directives['default-src'] || []) - request.content_security_policy.directives['frame-src'] ||= default_src - request.content_security_policy.directives['frame-src'].concat([webpack_url, 'https://*.vscode-cdn.net/']) + default_src = Array(request.content_security_policy.directives['default-src'] || []) + request.content_security_policy.directives['frame-src'] ||= default_src + request.content_security_policy.directives['frame-src'].concat([webpack_url, 'https://*.vscode-cdn.net/']) - request.content_security_policy.directives['worker-src'] ||= default_src - request.content_security_policy.directives['worker-src'].concat([webpack_url]) - end + request.content_security_policy.directives['worker-src'] ||= default_src + request.content_security_policy.directives['worker-src'].concat([webpack_url]) end end + +WebIdeCSP.prepend_mod_with('WebIdeCSP') diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 265cf2a7698..c606ccf4a07 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -13,9 +13,10 @@ module WikiActions included do content_security_policy do |p| next if p.directives.blank? + next unless Gitlab::CurrentSettings.diagramsnet_enabled? default_frame_src = p.directives['frame-src'] || p.directives['default-src'] - frame_src_values = Array.wrap(default_frame_src) | ['https://embed.diagrams.net'].compact + frame_src_values = Array.wrap(default_frame_src) | [Gitlab::CurrentSettings.diagramsnet_url].compact p.frame_src(*frame_src_values) end diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb index 552d74686d6..39bee37ee05 100644 --- a/app/controllers/dashboard/groups_controller.rb +++ b/app/controllers/dashboard/groups_controller.rb @@ -5,7 +5,7 @@ class Dashboard::GroupsController < Dashboard::ApplicationController skip_cross_project_access_check :index - feature_category :subgroups + feature_category :groups_and_projects urgency :low, [:index] diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index e26ac083622..eee172995cf 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -14,7 +14,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController before_action :projects, only: [:index] skip_cross_project_access_check :index, :starred - feature_category :projects + feature_category :groups_and_projects urgency :low, [:starred, :index] def index diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d70b2e57a95..188a8540a58 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -12,6 +12,10 @@ class DashboardController < Dashboard::ApplicationController before_action :set_show_full_reference, only: [:issues, :merge_requests] before_action :check_filters_presence!, only: [:issues, :merge_requests] + before_action only: :issues do + push_frontend_feature_flag(:frontend_caching) + end + before_action only: :merge_requests do push_frontend_feature_flag(:mr_approved_filter, type: :ops) end diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb index 96a7b5b144d..6cb0736d7ef 100644 --- a/app/controllers/explore/groups_controller.rb +++ b/app/controllers/explore/groups_controller.rb @@ -3,7 +3,7 @@ class Explore::GroupsController < Explore::ApplicationController include GroupTree - feature_category :subgroups + feature_category :groups_and_projects urgency :low def index diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index eebcbe88ebf..577bd04d656 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -23,7 +23,7 @@ class Explore::ProjectsController < Explore::ApplicationController rescue_from PageOutOfBoundsError, with: :page_out_of_bounds - feature_category :projects + feature_category :groups_and_projects # TODO: Set higher urgency after addressing https://gitlab.com/gitlab-org/gitlab/-/issues/357913 # and https://gitlab.com/gitlab-org/gitlab/-/issues/358945 urgency :low, [:index, :topics, :trending, :starred, :topic] @@ -113,7 +113,9 @@ class Explore::ProjectsController < Explore::ApplicationController end def load_topic - @topic = Projects::Topic.find_by_name_case_insensitive(params[:topic_name]) + topic_name = Feature.enabled?(:explore_topics_cleaned_path) ? URI.decode_www_form_component(params[:topic_name]) : params[:topic_name] + + @topic = Projects::Topic.find_by_name_case_insensitive(topic_name) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index ff4fce9ad1e..3d3b7f31dfd 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -12,6 +12,9 @@ class GraphqlController < ApplicationController # Max size of the query text in characters MAX_QUERY_SIZE = 10_000 + # The query string of a standard IntrospectionQuery, used to compare incoming requests for caching + CACHED_INTROSPECTION_QUERY_STRING = CachedIntrospectionQuery.query_string + # If a user is using their session to access GraphQL, we need to have session # storage, since the admin-mode check is session wide. # We can't enable this for anonymous users because that would cause users using @@ -32,6 +35,7 @@ class GraphqlController < ApplicationController before_action :set_user_last_activity before_action :track_vs_code_usage before_action :track_jetbrains_usage + before_action :track_jetbrains_bundled_usage before_action :track_gitlab_cli_usage before_action :disable_query_limiting before_action :limit_query_size @@ -54,7 +58,12 @@ class GraphqlController < ApplicationController urgency :low, [:execute] def execute - result = multiplex? ? execute_multiplex : execute_query + result = if Feature.enabled?(:cache_introspection_query) && params[:operationName] == 'IntrospectionQuery' + execute_introspection_query + else + multiplex? ? execute_multiplex : execute_query + end + render json: result end @@ -80,7 +89,7 @@ class GraphqlController < ApplicationController log_exception(exception) response.headers.merge!(exception.headers) - render_error(exception.message, status: :too_many_requests) + render_error(exception.message, status: :service_unavailable) end rescue_from Gitlab::Graphql::Variables::Invalid do |exception| @@ -169,6 +178,11 @@ class GraphqlController < ApplicationController .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) end + def track_jetbrains_bundled_usage + Gitlab::UsageDataCounters::JetBrainsBundledPluginActivityUniqueCounter + .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) + end + def track_gitlab_cli_usage Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) @@ -259,4 +273,46 @@ class GraphqlController < ApplicationController def logs RequestStore.store[:graphql_logs].to_a end + + def execute_introspection_query + if introspection_query_can_use_cache? + Gitlab::AppLogger.info(message: "IntrospectionQueryCache hit") + log_introspection_query_cache_details(true) + + # Context for caching: https://gitlab.com/gitlab-org/gitlab/-/issues/409448 + Rails.cache.fetch( + introspection_query_cache_key, + expires_in: 1.day) do + execute_query.to_json + end + else + Gitlab::AppLogger.info(message: "IntrospectionQueryCache miss") + log_introspection_query_cache_details(false) + + execute_query + end + end + + def introspection_query_can_use_cache? + graphql_query = GraphQL::Query.new(GitlabSchema, query: query, variables: build_variables(params[:variables])) + + CACHED_INTROSPECTION_QUERY_STRING == graphql_query.query_string.squish + end + + def introspection_query_cache_key + # We use context[:remove_deprecated] here as an introspection query result can differ based on the + # visibility of schema items. Visibility can be affected by the remove_deprecated param. For more context, see: + # https://gitlab.com/gitlab-org/gitlab/-/issues/409448#note_1377558096 + ['introspection-query-cache', Gitlab.revision, context[:remove_deprecated]] + end + + def log_introspection_query_cache_details(can_use_introspection_query_cache) + Gitlab::AppLogger.info( + message: "IntrospectionQueryCache", + can_use_introspection_query_cache: can_use_introspection_query_cache.to_s, + query: query, + variables: build_variables(params[:variables]).to_s, + introspection_query_cache_key: introspection_query_cache_key.to_s + ) + end end diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 3cad9e1fbad..414461d9e93 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Groups::AutocompleteSourcesController < Groups::ApplicationController - feature_category :subgroups, [:members] + feature_category :groups_and_projects, [:members] feature_category :team_planning, [:issues, :labels, :milestones, :commands] feature_category :code_review_workflow, [:merge_requests] diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb index 1f13be449a9..3b34a1947ae 100644 --- a/app/controllers/groups/avatars_controller.rb +++ b/app/controllers/groups/avatars_controller.rb @@ -5,7 +5,7 @@ class Groups::AvatarsController < Groups::ApplicationController skip_cross_project_access_check :destroy - feature_category :subgroups + feature_category :groups_and_projects def destroy @group.remove_avatar! diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb index ca3be1542aa..98d7487b21a 100644 --- a/app/controllers/groups/children_controller.rb +++ b/app/controllers/groups/children_controller.rb @@ -9,7 +9,7 @@ module Groups skip_cross_project_access_check :index - feature_category :subgroups + feature_category :groups_and_projects # TODO: Set to higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/331494 urgency :low, [:index] diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index 1b1aed0ec2e..1fc631f299b 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -121,7 +121,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy end def manifest_file_name - @manifest_file_name ||= Gitlab::Utils.check_path_traversal!("#{image}:#{tag}.json") + @manifest_file_name ||= Gitlab::PathTraversal.check_path_traversal!("#{image}:#{tag}.json") end def group diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index c74c48a960d..a874c7d164d 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -4,7 +4,7 @@ class Groups::GroupLinksController < Groups::ApplicationController before_action :authorize_admin_group! before_action :group_link, only: [:update, :destroy] - feature_category :subgroups + feature_category :groups_and_projects def update Groups::GroupLinks::UpdateService.new(@group_link, current_user).execute(group_link_params) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index d614cc1cb24..de47c5fb5e3 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -24,7 +24,7 @@ class Groups::GroupMembersController < Groups::ApplicationController skip_cross_project_access_check :index, :update, :destroy, :request_access, :approve_access_request, :leave, :resend_invite, :override - feature_category :subgroups + feature_category :groups_and_projects urgency :low def index diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 903c8c214ae..5f6b55ea928 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -44,7 +44,19 @@ class Groups::MilestonesController < Groups::ApplicationController def update Milestones::UpdateService.new(@milestone.parent, current_user, milestone_params).execute(@milestone) - redirect_to milestone_path(@milestone) + respond_to do |format| + format.html do + redirect_to milestone_path(@milestone) + end + + format.json do + if @milestone.valid? + head :no_content + else + render json: { errors: @milestone.errors.full_messages }, status: :unprocessable_entity + end + end + end rescue ActiveRecord::StaleObjectError respond_to do |format| format.html do diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb index 0a63c3d304b..59b24e8103d 100644 --- a/app/controllers/groups/settings/integrations_controller.rb +++ b/app/controllers/groups/settings/integrations_controller.rb @@ -12,7 +12,9 @@ module Groups layout 'group_settings' def index - @integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_group(group)).sort_by(&:title) + @integrations = Integration + .find_or_initialize_all_non_project_specific(Integration.for_group(group)) + .sort_by(&:title) end def edit diff --git a/app/controllers/groups/shared_projects_controller.rb b/app/controllers/groups/shared_projects_controller.rb index 2d2664c02e8..73f951d6ce4 100644 --- a/app/controllers/groups/shared_projects_controller.rb +++ b/app/controllers/groups/shared_projects_controller.rb @@ -6,7 +6,7 @@ module Groups before_action :group skip_cross_project_access_check :index - feature_category :subgroups + feature_category :groups_and_projects urgency :low, [:index] def index diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb index 22e6549aa04..cd1ebc39411 100644 --- a/app/controllers/groups/uploads_controller.rb +++ b/app/controllers/groups/uploads_controller.rb @@ -9,7 +9,7 @@ class Groups::UploadsController < Groups::ApplicationController before_action :authorize_upload_file!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] - feature_category :subgroups + feature_category :groups_and_projects urgency :low, [:show] private diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb index 125c8fde004..be4e08f6c49 100644 --- a/app/controllers/groups/usage_quotas_controller.rb +++ b/app/controllers/groups/usage_quotas_controller.rb @@ -24,7 +24,7 @@ module Groups render_404 unless group.usage_quotas_enabled? end - # To be overriden in ee/app/controllers/ee/groups/usage_quotas_controller.rb + # To be overridden in ee/app/controllers/ee/groups/usage_quotas_controller.rb def seat_count_data; end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index d2f65104d86..ec16be8f85e 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -36,6 +36,7 @@ class GroupsController < Groups::ApplicationController push_frontend_feature_flag(:or_issuable_queries, group) push_frontend_feature_flag(:frontend_caching, group) push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?) + push_frontend_feature_flag(:issues_grid_view) end before_action only: :merge_requests do @@ -51,14 +52,12 @@ class GroupsController < Groups::ApplicationController layout :determine_layout - feature_category :subgroups, [ + feature_category :groups_and_projects, [ :index, :new, :create, :show, :edit, :update, - :destroy, :details, :transfer, :activity + :destroy, :details, :transfer, :activity, :projects ] - feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown] feature_category :code_review_workflow, [:merge_requests, :unfoldered_environment_names] - feature_category :projects, [:projects] feature_category :importers, [:export, :download_export] urgency :low, [:export, :download_export] diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index 3c50d54fa10..2c498820a1e 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -8,7 +8,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController skip_before_action :verify_atlassian_jwt! def show - render json: { + result = { name: Atlassian::JiraConnect.app_name, description: 'Integrate commits, branches and merge requests from GitLab into Jira', key: Atlassian::JiraConnect.app_key, @@ -36,10 +36,15 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController gdpr: true } } + + result[:links][:feedback] = URI.join(HOME_URL, FEEDBACK_URL) if Feature.enabled?(:jira_for_cloud_app_feedback_link) + + render json: result end private + FEEDBACK_URL = '/gitlab-org/gitlab/-/issues/413652' HOME_URL = 'https://gitlab.com' DOC_URL = 'https://docs.gitlab.com/ee/integration/jira/' diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index a2e0670d7e1..eda72400f17 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -15,7 +15,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController feature_category :system_access def handle_omniauth - omniauth_flow(Gitlab::Auth::OAuth) + if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym) + saml + else + omniauth_flow(Gitlab::Auth::OAuth) + end end AuthHelper.providers_for_base_controller.each do |provider| @@ -30,6 +34,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # Extend the standard implementation to also increment # the number of failed sign in attempts def failure + update_login_counter_metric(failed_strategy.name, 'failed') + if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name) user = User.find_by_login(params[:username]) @@ -79,6 +85,21 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController private + def track_event(user, provider, status) + log_audit_event(user, with: provider) + update_login_counter_metric(provider, status) + end + + def update_login_counter_metric(provider, status) + omniauth_login_counter.increment(omniauth_provider: provider, status: status) + end + + def omniauth_login_counter + @counter ||= Gitlab::Metrics.counter( + :gitlab_omniauth_login_total, + 'Counter of OmniAuth login attempts') + end + def log_failed_login(user, provider) # overridden in EE end @@ -99,7 +120,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController if current_user return render_403 unless link_provider_allowed?(oauth['provider']) - log_audit_event(current_user, with: oauth['provider']) + track_event(current_user, oauth['provider'], 'succeeded') if Gitlab::CurrentSettings.admin_mode return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested? @@ -151,7 +172,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # from that in `#context_user`. Pushing it manually here makes the information # available in the logs for this request. Gitlab::ApplicationContext.push(user: user) - log_audit_event(user, with: oauth['provider']) + track_event(user, oauth['provider'], 'succeeded') Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: user) if new_user set_remember_me(user) @@ -167,7 +188,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController accept_pending_invitations(user: user) if new_user persist_accepted_terms_if_required(user) if new_user - store_after_sign_up_path_for_user if intent_to_register? + perform_registration_tasks(user, oauth['provider']) if new_user sign_in_and_redirect_or_verify_identity(user, auth_user, new_user) end else @@ -249,11 +270,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController (request_params['remember_me'] == '1') if request_params.present? end - def intent_to_register? - request_params = request.env['omniauth.params'] - (request_params['intent'] == 'register') if request_params.present? - end - def store_redirect_fragment(redirect_fragment) key = stored_location_key_for(:user) location = session[key] @@ -295,8 +311,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController Users::RespondToTermsService.new(user, terms).execute(accepted: true) end - def store_after_sign_up_path_for_user - store_location_for(:user, users_sign_up_welcome_path) + def perform_registration_tasks(_user, _provider) + store_location_for(:user, after_sign_up_path) + end + + def after_sign_up_path + users_sign_up_welcome_path end # overridden in EE diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb new file mode 100644 index 00000000000..5f5a57d176b --- /dev/null +++ b/app/controllers/organizations/application_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Organizations + class ApplicationController < ::ApplicationController + before_action :organization + + private + + def organization + return unless params[:organization_path] + + @organization = Organizations::Organization.find_by_path(params[:organization_path]) + end + strong_memoize_attr :organization + + def authorize_action!(action) + access_denied! if Feature.disabled?(:ui_for_organizations) + access_denied! unless can?(current_user, action, organization) + end + end +end diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb new file mode 100644 index 00000000000..0eb5c3aa6fd --- /dev/null +++ b/app/controllers/organizations/organizations_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationsController < ApplicationController + feature_category :cell + + before_action { authorize_action!(:admin_organization) } + + def directory; end + end +end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index a5a2cbf3733..f19113276c2 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -54,6 +54,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :sourcegraph_enabled, :gitpod_enabled, :render_whitespace_in_code, + :project_shortcut_buttons, :markdown_surround_selection, :markdown_automatic_lists, :use_new_navigation diff --git a/app/controllers/profiles/slacks_controller.rb b/app/controllers/profiles/slacks_controller.rb new file mode 100644 index 00000000000..7c78c01416a --- /dev/null +++ b/app/controllers/profiles/slacks_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Profiles + class SlacksController < Profiles::ApplicationController + include IntegrationsHelper + + skip_before_action :authenticate_user! + + layout 'application' + + feature_category :integrations + + def edit + @projects = disabled_projects.inc_routes if current_user + end + + def slack_link + project = disabled_projects.find(params[:project_id]) + link = add_to_slack_link(project, Gitlab::CurrentSettings.slack_app_id) + + render json: { add_to_slack_link: link } + end + + private + + def disabled_projects + @disabled_projects ||= current_user + .authorized_projects(Gitlab::Access::MAINTAINER) + .with_slack_application_disabled + end + end +end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index bc6e67a3a7d..e83b72b71a8 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -35,9 +35,9 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController render 'create' else @error = { message: _('Invalid pin code.') } - @qr_code = build_qr_code @account_string = account_string - setup_webauthn_registration + + setup_show_page render 'show' end diff --git a/app/controllers/profiles/webauthn_registrations_controller.rb b/app/controllers/profiles/webauthn_registrations_controller.rb index 345d7bdbca8..ef3144f6f8c 100644 --- a/app/controllers/profiles/webauthn_registrations_controller.rb +++ b/app/controllers/profiles/webauthn_registrations_controller.rb @@ -4,8 +4,7 @@ class Profiles::WebauthnRegistrationsController < Profiles::ApplicationControlle feature_category :system_access def destroy - webauthn_registration = current_user.webauthn_registrations.find(params[:id]) - webauthn_registration.destroy + Webauthn::DestroyService.new(current_user, current_user, params[:id]).execute redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted WebAuthn device.") end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index b5b023a4d64..2828d17c36f 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -19,10 +19,6 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :validate_artifacts!, except: [:index, :download, :raw, :destroy] before_action :entry, only: [:external_file, :file] - before_action only: :index do - push_frontend_feature_flag(:ci_job_artifact_bulk_destroy, @project) - end - MAX_PER_PAGE = 20 feature_category :build_artifacts diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index ffe6071ab3c..480e3408023 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -6,7 +6,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts] feature_category :code_review_workflow, [:merge_requests] - feature_category :user_profile, [:members] + feature_category :groups_and_projects, [:members] feature_category :source_code_management, [:snippets] urgency :low, [:merge_requests, :members] diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb index 5db7609e07a..3728406afd3 100644 --- a/app/controllers/projects/avatars_controller.rb +++ b/app/controllers/projects/avatars_controller.rb @@ -5,7 +5,7 @@ class Projects::AvatarsController < Projects::ApplicationController before_action :authorize_admin_project!, only: [:destroy] - feature_category :projects + feature_category :groups_and_projects urgency :low, [:show] diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index bb1b8760c42..f621adbebc7 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -9,6 +9,7 @@ class Projects::BlameController < Projects::ApplicationController before_action :assign_ref_vars before_action :authorize_read_code! before_action :load_blob + before_action :require_non_binary_blob feature_category :source_code_management urgency :low, [:show] @@ -40,6 +41,10 @@ class Projects::BlameController < Projects::ApplicationController redirect_to_tree_root_for_missing_path(@project, @ref, @path) end + def require_non_binary_blob + redirect_to project_blob_path(@project, File.join(@ref, @path)), notice: _('Blame for binary files is not supported.') if @blob.binary? + end + def load_environment environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } environment_params[:find_latest] = true diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 727a4e0251d..28393e1f365 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -49,7 +49,6 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) - push_frontend_feature_flag(:synchronize_fork, @project&.fork_source) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 1e17dd586c7..e60544129ff 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -27,6 +27,7 @@ class Projects::BranchesController < Projects::ApplicationController # Fetch branches for the specified mode fetch_branches_by_mode + fetch_merge_requests_for_branches @refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name)) @merged_branch_names = repository.merged_branch_names(@branches.map(&:name)) @@ -199,6 +200,15 @@ class Projects::BranchesController < Projects::ApplicationController Projects::BranchesByModeService.new(@project, params.merge(sort: @sort, mode: @mode)).execute end + def fetch_merge_requests_for_branches + @related_merge_requests = @project + .source_of_merge_requests + .including_target_project + .by_target_branch(@project.default_branch) + .by_sorted_source_branches(@branches.map(&:name)) + .group_by(&:source_branch) + end + def fetch_branches_for_overview # Here we get one more branch to indicate if there are more data we're not showing limit = @overview_max_branches + 1 diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index d874c60daec..8499bf0ced7 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -4,7 +4,8 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! before_action do push_frontend_feature_flag(:ci_job_assistant_drawer, @project) - push_frontend_feature_flag(:ai_ci_config_generator, @project) + push_frontend_feature_flag(:ai_ci_config_generator, @user) + push_frontend_feature_flag(:ci_graphql_pipeline_mini_graph, @project) end feature_category :pipeline_composition diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 8aca6a3fd5b..88e9113188a 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -19,6 +19,9 @@ class Projects::CommitController < Projects::ApplicationController before_action :define_commit_box_vars, only: [:show, :pipelines] before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] + before_action do + push_frontend_feature_flag(:ci_graphql_pipeline_mini_graph, @project) + end BRANCH_SEARCH_LIMIT = 1000 COMMIT_DIFFS_PER_PAGE = 20 @@ -219,7 +222,7 @@ class Projects::CommitController < Projects::ApplicationController end @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes) - @notes = prepare_notes_for_rendering(@notes, @commit) + @notes = prepare_notes_for_rendering(@notes) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 10dd18c0c86..9d7569047f6 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -26,7 +26,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController 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 diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index a61930d4b99..59de4fbb698 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -34,7 +34,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_discussion if serialize_notes? - prepare_notes_for_rendering(discussion.notes, merge_request) + prepare_notes_for_rendering(discussion.notes) render_json_with_discussions_serializer else render_json_with_html diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb deleted file mode 100644 index cbb16d596a0..00000000000 --- a/app/controllers/projects/environments/prometheus_api_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class Projects::Environments::PrometheusApiController < Projects::ApplicationController - include Metrics::Dashboard::PrometheusApiProxy - - before_action :proxyable - - feature_category :metrics - urgency :low - - private - - def proxyable - @proxyable ||= project.environments.find(params[:id]) - end - - def proxy_variable_substitution_service - ::Prometheus::ProxyVariableSubstitutionService - end -end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index f91ec55573d..10d0d03e56d 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -1,22 +1,13 @@ # frozen_string_literal: true class Projects::EnvironmentsController < Projects::ApplicationController - # Metrics dashboard code is getting decoupled from environments and is being moved - # into app/controllers/projects/metrics_dashboard_controller.rb - # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details. - MIN_SEARCH_LENGTH = 3 - include MetricsDashboard include ProductAnalyticsTracking include KasCookie layout 'project' - before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do - authorize_metrics_dashboard! - end - before_action only: [:show] do push_frontend_feature_flag(:environment_details_vue, @project) end @@ -25,15 +16,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController push_frontend_feature_flag(:kas_user_access_project, @project) end - before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect] + before_action only: [:edit, :new] do + push_frontend_feature_flag(:environment_settings_to_graphql, @project) + end + + before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] before_action :authorize_update_environment!, only: [:edit, :update, :cancel_auto_stop] before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize] - before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop] + before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } - before_action :set_kas_cookie, only: [:index], if: -> { current_user } + before_action :set_kas_cookie, only: [:index], if: -> { current_user && request.format.html? } after_action :expire_etag_cache, only: [:cancel_auto_stop] track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal, @@ -175,41 +170,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end - def metrics_redirect - return not_found if Feature.enabled?(:remove_monitor_metrics) - - redirect_to project_metrics_dashboard_path(project) - end - - def metrics - return not_found if Feature.enabled?(:remove_monitor_metrics) - - respond_to do |format| - format.html do - redirect_to project_metrics_dashboard_path(project, environment: environment) - end - format.json do - # Currently, this acts as a hint to load the metrics details into the cache - # if they aren't there already - @metrics = environment.metrics || {} - - render json: @metrics, status: @metrics.any? ? :ok : :no_content - end - end - end - - def additional_metrics - return not_found if Feature.enabled?(:remove_monitor_metrics) - - respond_to do |format| - format.json do - additional_metrics = environment.additional_metrics(*metrics_params) || {} - - render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content - end - end - end - def search respond_to do |format| format.json do @@ -261,16 +221,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController @search_environments ||= Environments::EnvironmentsFinder.new(project, current_user, type: type, search: search).execute end - def metrics_params - params.require([:start, :end]) - end - - def metrics_dashboard_params - params - .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment, :sample_metrics, :embed_json) - .merge(dashboard_path: params[:dashboard], environment: environment) - end - def include_all_dashboards? !params[:embedded] end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 451f1d1363b..60300f78bbb 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -6,7 +6,7 @@ class Projects::GroupLinksController < Projects::ApplicationController before_action :authorize_admin_project_group_link!, only: [:destroy] before_action :authorize_admin_project_member!, only: [:update] - feature_category :subgroups + feature_category :groups_and_projects def update Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params) diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 570fe74f31f..412ed529446 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -12,7 +12,6 @@ class Projects::HooksController < Projects::ApplicationController layout "project_settings" - feature_category :integrations urgency :low, [:test] def test diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 642d5943854..6311907a859 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -50,6 +50,7 @@ class Projects::IssuesController < Projects::ApplicationController 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) + push_frontend_feature_flag(:issues_grid_view) end before_action only: [:index, :show] do @@ -157,8 +158,7 @@ class Projects::IssuesController < Projects::ApplicationController discussion_to_resolve: params[:discussion_to_resolve] ) - spam_params = ::Spam::SpamParams.new_from_request(request: request) - service = ::Issues::CreateService.new(container: project, current_user: current_user, params: create_params, spam_params: spam_params) + service = ::Issues::CreateService.new(container: project, current_user: current_user, params: create_params) result = service.execute # Only irrecoverable errors such as unauthorized user won't contain an issue in the response @@ -372,8 +372,11 @@ class Projects::IssuesController < Projects::ApplicationController end def update_service - spam_params = ::Spam::SpamParams.new_from_request(request: request) - ::Issues::UpdateService.new(container: project, current_user: current_user, params: issue_params, spam_params: spam_params) + ::Issues::UpdateService.new( + container: project, + current_user: current_user, + params: issue_params, + perform_spam_check: true) end def finder_type diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 3a03831ab88..06381315614 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -91,15 +91,17 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def target_projects - projects = MergeRequestTargetProjectFinder - .new(current_user: current_user, source_project: @project, project_feature: :repository) - .execute(include_routes: false, search: params[:search]).limit(20) - - render json: ProjectSerializer.new.represent(projects) + render json: ProjectSerializer.new.represent(get_target_projects) end private + def get_target_projects + MergeRequestTargetProjectFinder + .new(current_user: current_user, source_project: @project, project_feature: :repository) + .execute(include_routes: false, search: params[:search]).limit(20) + end + def build_merge_request params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 6ca885cee4c..f3a01fd3223 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -13,7 +13,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic around_action :allow_gitaly_ref_name_caching - after_action :track_viewed_diffs_events, only: [:diffs_batch] + after_action :track_viewed_diffs_events, only: [:diffs_batch, :diff_for_path] urgency :low, [ :show, @@ -196,7 +196,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) - @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request) + @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end def render_merge_ref_head_diff? diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index ad3b79b604c..60f619a8d20 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -41,19 +41,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo 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(: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(:realtime_approvals, 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) push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?) push_frontend_feature_flag(:mr_activity_filters, current_user) + push_frontend_feature_flag(:review_apps_redeploy_mr_widget, project) + push_frontend_feature_flag(:comment_on_files, current_user) + push_frontend_feature_flag(:ci_job_failures_in_mr, project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] @@ -196,10 +194,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end + # documented in doc/development/rails_endpoints/index.md def codequality_mr_diff_reports reports_response(@merge_request.find_codequality_mr_diff_reports, head_pipeline) end + # documented in doc/development/rails_endpoints/index.md def codequality_reports reports_response(@merge_request.compare_codequality_reports) end @@ -613,8 +613,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo Feature.enabled?(:summarize_my_code_review, current_user) && namespace.group_namespace? && namespace.licensed_feature_available?(:summarize_my_mr_code_review) && - Gitlab::Llm::StageCheck.available?(namespace, :summarize_my_mr_code_review) && - merge_request.send_to_ai? + Gitlab::Llm::StageCheck.available?(namespace, :summarize_my_mr_code_review) end end diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb deleted file mode 100644 index 510c882d537..00000000000 --- a/app/controllers/projects/metrics_dashboard_controller.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true -module Projects - class MetricsDashboardController < Projects::ApplicationController - # Metrics dashboard code is in the process of being decoupled from environments - # and is getting moved to this controller. Some code may be duplicated from - # app/controllers/projects/environments_controller.rb - # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details. - - include Gitlab::Utils::StrongMemoize - - before_action :authorize_metrics_dashboard! - before_action :render_404, only: :show, if: -> do - Feature.enabled?(:remove_monitor_metrics) - end - - feature_category :metrics - urgency :low - - def show - if environment - render 'projects/environments/metrics' - elsif default_environment - redirect_to project_metrics_dashboard_path( - project, - # Reverse merge the query parameters so that a query parameter named dashboard_path doesn't - # override the dashboard_path path parameter. - **permitted_params.to_h.symbolize_keys - .merge(environment: default_environment.id) - .reverse_merge(request.query_parameters.symbolize_keys) - ) - else - render 'projects/environments/empty_metrics' - end - end - - private - - def permitted_params - @permitted_params ||= params.permit(:dashboard_path, :environment, :page) - end - - def environment - strong_memoize(:environment) do - env = permitted_params[:environment] - project.environments.find(env) if env - end - end - - def default_environment - strong_memoize(:default_environment) do - project.default_environment - end - end - end -end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 569a514b23b..35b65dbce7e 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -76,7 +76,6 @@ class Projects::MilestonesController < Projects::ApplicationController @milestone = Milestones::UpdateService.new(project, current_user, milestone_params).execute(milestone) respond_to do |format| - format.js format.html do if @milestone.valid? redirect_to project_milestone_path(@project, @milestone) @@ -84,6 +83,16 @@ class Projects::MilestonesController < Projects::ApplicationController render :edit end end + + format.js + + format.json do + if @milestone.valid? + head :no_content + else + render json: { errors: @milestone.errors.full_messages }, status: :unprocessable_entity + end + end end rescue ActiveRecord::StaleObjectError respond_to do |format| diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb index e534000f494..ed7155fc5f4 100644 --- a/app/controllers/projects/ml/candidates_controller.rb +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -3,7 +3,7 @@ module Projects module Ml class CandidatesController < ApplicationController - before_action :check_feature_flag, :set_candidate + before_action :check_feature_enabled, :set_candidate feature_category :mlops @@ -26,8 +26,8 @@ module Projects render_404 unless @candidate.present? end - def check_feature_flag - render_404 unless Feature.enabled?(:ml_experiment_tracking, @project) + def check_feature_enabled + render_404 unless can?(current_user, :read_model_experiments, @project) end end end diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb index dece3f98c57..a620e9919e7 100644 --- a/app/controllers/projects/ml/experiments_controller.rb +++ b/app/controllers/projects/ml/experiments_controller.rb @@ -5,7 +5,7 @@ module Projects class ExperimentsController < ::Projects::ApplicationController include Projects::Ml::ExperimentsHelper - before_action :check_feature_flag + before_action :check_feature_enabled before_action :set_experiment, only: [:show, :destroy] feature_category :mlops @@ -55,8 +55,8 @@ module Projects private - def check_feature_flag - render_404 unless Feature.enabled?(:ml_experiment_tracking, @project) + def check_feature_enabled + render_404 unless can?(current_user, :read_model_experiments, @project) end def set_experiment diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index 5cb69e8bf99..8682d35aae7 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -12,6 +12,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController feature_category :pages def show + return unless domain_presenter.needs_verification? + + flash.now[:warning] = _("This domain is not verified. You will need to verify ownership before access is enabled.") end def new diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 39ebcd60e9a..98e6459b543 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -22,6 +22,7 @@ class Projects::PipelinesController < Projects::ApplicationController 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, :builds, :dag, :failures, :test_report] # 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? } @@ -190,7 +191,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def cancel - pipeline.cancel_running + ::Ci::CancelPipelineService.new(pipeline: pipeline, current_user: @current_user).execute respond_to do |format| format.html do @@ -349,6 +350,10 @@ class Projects::PipelinesController < Projects::ApplicationController def tracking_project_source project end + + def push_frontend_feature_flags + push_frontend_feature_flag(:pipeline_details_header_vue, @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 f4b96177b0f..5390df449e8 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -8,7 +8,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] - feature_category :projects + feature_category :groups_and_projects urgency :low def index diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb index 27ac64e5758..80a8dbf4729 100644 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ b/app/controllers/projects/prometheus/alerts_controller.rb @@ -3,8 +3,6 @@ module Projects module Prometheus class AlertsController < Projects::ApplicationController - include MetricsDashboard - respond_to :json protect_from_forgery except: [:notify] @@ -14,7 +12,6 @@ module Projects prepend_before_action :repository, :project_without_auth, only: [:notify] before_action :authorize_read_prometheus_alerts!, except: [:notify] - before_action :alert, only: [:metrics_dashboard] feature_category :incident_management urgency :low @@ -33,17 +30,6 @@ module Projects .new(project, params.permit!) end - def alert - @alert ||= alerts_finder(metric: params[:id]).execute.first || render_404 - end - - def alerts_finder(opts = {}) - Projects::Prometheus::AlertsFinder.new({ - project: project, - environment: params[:environment_id] - }.reverse_merge(opts)) - end - def extract_alert_manager_token(request) Doorkeeper::OAuth::Token.from_bearer_authorization(request) end @@ -52,13 +38,6 @@ module Projects @project ||= Project .find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}") end - - def metrics_dashboard_params - { - embedded: true, - prometheus_alert_id: alert.id - } - end end end end diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb index c20c80ba334..396841e667d 100644 --- a/app/controllers/projects/prometheus/metrics_controller.rb +++ b/app/controllers/projects/prometheus/metrics_controller.rb @@ -3,6 +3,7 @@ module Projects module Prometheus class MetricsController < Projects::ApplicationController + before_action :check_feature_availability! before_action :authorize_admin_project! before_action :require_prometheus_metrics! @@ -127,6 +128,10 @@ module Projects def metrics_params params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group) end + + def check_feature_availability! + render_404 if Feature.enabled?(:remove_monitor_metrics) + end end end end diff --git a/app/controllers/projects/redirect_controller.rb b/app/controllers/projects/redirect_controller.rb index 6bcbe87ee42..c66a99be02b 100644 --- a/app/controllers/projects/redirect_controller.rb +++ b/app/controllers/projects/redirect_controller.rb @@ -6,7 +6,7 @@ class Projects::RedirectController < ::ApplicationController skip_before_action :authenticate_user! - feature_category :projects + feature_category :groups_and_projects def redirect_from_id project = Project.find(params[:id]) diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 7c569df7267..6a6a47bc33d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -74,6 +74,6 @@ class Projects::ReleasesController < Projects::ApplicationController end def validate_suffix_path - Gitlab::Utils.check_path_traversal!(params[:suffix_path]) if params[:suffix_path] + Gitlab::PathTraversal.check_path_traversal!(params[:suffix_path]) if params[:suffix_path] end end diff --git a/app/controllers/projects/settings/branch_rules_controller.rb b/app/controllers/projects/settings/branch_rules_controller.rb index 0a415b60124..68ef7f49cc3 100644 --- a/app/controllers/projects/settings/branch_rules_controller.rb +++ b/app/controllers/projects/settings/branch_rules_controller.rb @@ -7,9 +7,7 @@ module Projects feature_category :source_code_management - def index - render_404 unless Feature.enabled?(:branch_rules, project) - end + def index; end end end end diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 952c9e90a2c..5f30edd3db8 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -122,22 +122,14 @@ module Projects { incident_management_setting_attributes: ::Gitlab::Tracking::IncidentManagement.tracking_keys.keys, - metrics_setting_attributes: [:external_dashboard_url, :dashboard_timezone], - error_tracking_setting_attributes: [ :enabled, :integrated, :api_host, :token, project: [:slug, :name, :organization_slug, :organization_name, :sentry_project_id] - ], - - grafana_integration_attributes: [:token, :grafana_url, :enabled] - }.tap do |potential_params| - if Feature.enabled?(:remove_monitor_metrics) - potential_params.except!(:metrics_setting_attributes, :grafana_integration_attributes) - end - end + ] + } end end end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index a6f4e2fcd73..38b23b24c9a 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -12,7 +12,6 @@ module Projects urgency :low, [:show, :create_deploy_token] def show - push_frontend_feature_flag(:branch_rules, @project) render_show end diff --git a/app/controllers/projects/settings/slacks_controller.rb b/app/controllers/projects/settings/slacks_controller.rb new file mode 100644 index 00000000000..4e55103cb4c --- /dev/null +++ b/app/controllers/projects/settings/slacks_controller.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Projects + module Settings + class SlacksController < Projects::ApplicationController + before_action :handle_oauth_error, only: :slack_auth + before_action :check_oauth_state, only: :slack_auth + before_action :authorize_admin_project! + before_action :slack_integration, only: [:edit, :update] + before_action :service, only: [:destroy, :edit, :update] + + layout 'project_settings' + + feature_category :integrations + + def slack_auth + result = Projects::SlackApplicationInstallService.new(project, current_user, params).execute + + flash[:alert] = result[:message] if result[:status] == :error + + session[:slack_install_success] = true + redirect_to_service_page + end + + def destroy + slack_integration.destroy + + redirect_to_service_page + end + + def edit; end + + def update + if slack_integration.update(slack_integration_params) + flash[:notice] = 'The project alias was updated successfully' + + redirect_to_service_page + else + render :edit + end + end + + private + + def redirect_to_service_page + redirect_to edit_project_settings_integration_path( + project, + project.gitlab_slack_application_integration || project.build_gitlab_slack_application_integration + ) + end + + def check_oauth_state + render_403 unless valid_authenticity_token?(session, params[:state]) + + true + end + + def handle_oauth_error + return unless params[:error] == 'access_denied' + + flash[:alert] = 'Access denied' + redirect_to_service_page + end + + def slack_integration + @slack_integration ||= project.gitlab_slack_application_integration.slack_integration + end + + def service + @service = project.gitlab_slack_application_integration + end + + def slack_integration_params + params.require(:slack_integration).permit(:alias) + end + end + end +end diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb index 06996e8e5fc..f9f71ce72d3 100644 --- a/app/controllers/projects/starrers_controller.rb +++ b/app/controllers/projects/starrers_controller.rb @@ -3,7 +3,7 @@ class Projects::StarrersController < Projects::ApplicationController include SortingHelper - feature_category :projects + feature_category :groups_and_projects urgency :low, [:index] diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 495241df912..c8f698d6193 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) - push_frontend_feature_flag(:synchronize_fork, @project.fork_source) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 0f3143606ff..81f205a6457 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -38,9 +38,9 @@ class ProjectsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) - push_frontend_feature_flag(:synchronize_fork, @project&.fork_source) push_frontend_feature_flag(:remove_monitor_metrics, @project) push_frontend_feature_flag(:explain_code_chat, current_user) + push_frontend_feature_flag(:ci_namespace_catalog_experimental, @project) 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?) @@ -50,7 +50,7 @@ class ProjectsController < Projects::ApplicationController layout :determine_layout - feature_category :projects, [ + feature_category :groups_and_projects, [ :index, :show, :new, :create, :edit, :update, :transfer, :destroy, :archive, :unarchive, :toggle_star, :activity ] @@ -263,12 +263,12 @@ class ProjectsController < Projects::ApplicationController @project.add_export_job(current_user: current_user) redirect_to( - edit_project_path(@project, anchor: 'js-export-project'), + edit_project_path(@project, anchor: 'js-project-advanced-settings'), notice: _("Project export started. A download link will be sent by email and made available on this page.") ) rescue Project::ExportLimitExceeded => e redirect_to( - edit_project_path(@project, anchor: 'js-export-project'), + edit_project_path(@project, anchor: 'js-project-advanced-settings'), alert: e.to_s ) end @@ -279,13 +279,13 @@ class ProjectsController < Projects::ApplicationController send_upload(@project.export_file, attachment: @project.export_file.filename) else redirect_to( - edit_project_path(@project, anchor: 'js-export-project'), + edit_project_path(@project, anchor: 'js-project-advanced-settings'), alert: _("The file containing the export is not available yet; it may still be transferring. Please try again later.") ) end else redirect_to( - edit_project_path(@project, anchor: 'js-export-project'), + edit_project_path(@project, anchor: 'js-project-advanced-settings'), alert: _("Project export link has expired. Please generate a new export from your project settings.") ) end @@ -298,7 +298,7 @@ class ProjectsController < Projects::ApplicationController flash[:alert] = _("Project export could not be deleted.") end - redirect_to(edit_project_path(@project, anchor: 'js-export-project')) + redirect_to(edit_project_path(@project, anchor: 'js-project-advanced-settings')) end def generate_new_export @@ -306,7 +306,7 @@ class ProjectsController < Projects::ApplicationController export else redirect_to( - edit_project_path(@project, anchor: 'js-export-project'), + edit_project_path(@project, anchor: 'js-project-advanced-settings'), alert: _("Project export could not be deleted.") ) end @@ -456,6 +456,7 @@ class ProjectsController < Projects::ApplicationController feature_flags_access_level monitor_access_level infrastructure_access_level + model_experiments_access_level ] end diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index ac8959e0f52..76aa4afbe80 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -4,6 +4,7 @@ module Registrations class WelcomeController < ApplicationController include OneTrustCSP include GoogleAnalyticsCSP + include ::Gitlab::Utils::StrongMemoize layout 'minimal' skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update] @@ -24,6 +25,7 @@ module Registrations if result.success? track_event('successfully_submitted_form') + finish_onboarding_on_welcome_page unless complete_signup_onboarding? redirect_to update_success_path else @@ -34,6 +36,8 @@ module Registrations private def registering_from_invite?(members) + # If there are more than one member it will mean we have been invited to multiple projects/groups and + # are not able to distinguish which one we should putting the user in after registration members.count == 1 && members.last.source.present? end @@ -61,30 +65,37 @@ module Registrations end # overridden in EE - def redirect_to_signup_onboarding? + def complete_signup_onboarding? false end - def redirect_for_tasks_to_be_done? - MemberTask.for_members(current_user.members).exists? + def invites_with_tasks_to_be_done? + MemberTask.for_members(user_members).exists? end def update_success_path - return issues_dashboard_path(assignee_username: current_user.username) if redirect_for_tasks_to_be_done? - - return signup_onboarding_path if redirect_to_signup_onboarding? - - members = current_user.members - - if registering_from_invite?(members) - flash[:notice] = helpers.invite_accepted_notice(members.last) - members_activity_path(members) + if invites_with_tasks_to_be_done? + issues_dashboard_path(assignee_username: current_user.username) + elsif complete_signup_onboarding? # trials/regular registration on .com + signup_onboarding_path + elsif registering_from_invite?(user_members) # invites w/o tasks due to order + flash[:notice] = helpers.invite_accepted_notice(user_members.last) + members_activity_path(user_members) else - # subscription registrations goes through here as well + # Subscription registrations goes through here as well. + # Invites will come here too if there is more than 1. path_for_signed_in_user(current_user) end end + def user_members + current_user.members + end + strong_memoize_attr :user_members + + # overridden in EE + def finish_onboarding_on_welcome_page; end + # overridden in EE def signup_onboarding_path; end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 3e6683fc867..f481681da02 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -10,6 +10,7 @@ class RegistrationsController < Devise::RegistrationsController include GoogleAnalyticsCSP include PreferredLanguageSwitcher include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent + include SkipsAlreadySignedInMessage layout 'devise' diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index a3c6499bc54..45aefe48538 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -34,6 +34,7 @@ class SearchController < ApplicationController before_action only: :show do update_scope_for_code_search end + rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' @@ -111,11 +112,12 @@ class SearchController < ApplicationController @project = search_service.project @ref = params[:project_ref] if params[:project_ref].present? @filter = params[:filter] + @scope = params[:scope] # Cache the response on the frontend expires_in 1.minute - render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter)) + render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter, scope: @scope)) end def opensearch diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 6069924b39a..6c5e709a98a 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -29,6 +29,10 @@ class SentNotificationsController < ApplicationController def unsubscribe_and_redirect noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project) + if noteable.is_a?(Issue) && @sent_notification.recipient_id == User.support_bot.id + noteable.unsubscribe_email_participant(noteable.external_author) + end + flash[:notice] = _("You have been unsubscribed from this thread.") if current_user diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8a79353f490..a9972cbd885 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -14,13 +14,11 @@ class SessionsController < Devise::SessionsController include VerifiesWithEmail include GoogleAnalyticsCSP include PreferredLanguageSwitcher + include SkipsAlreadySignedInMessage skip_before_action :check_two_factor_requirement, only: [:destroy] skip_before_action :check_password_expiration, only: [:destroy] - # replaced with :require_no_authentication_without_flash - skip_before_action :require_no_authentication, only: [:new, :create] - prepend_before_action :check_initial_setup, only: [:new] prepend_before_action :authenticate_with_two_factor, if: -> { action_name == 'create' && two_factor_enabled? } @@ -29,7 +27,6 @@ class SessionsController < Devise::SessionsController prepend_before_action :require_no_authentication_without_flash, only: [:new, :create] prepend_before_action :check_forbidden_password_based_login, if: -> { action_name == 'create' && password_based_login? } prepend_before_action :ensure_password_authentication_enabled!, if: -> { action_name == 'create' && password_based_login? } - before_action :auto_sign_in_with_provider, only: [:new] before_action :init_preferred_language, only: :new before_action :store_unauthenticated_sessions, only: [:new] @@ -96,14 +93,6 @@ class SessionsController < Devise::SessionsController private - def require_no_authentication_without_flash - require_no_authentication - - if flash[:alert] == I18n.t('devise.failure.already_authenticated') - flash[:alert] = nil - end - end - def captcha_enabled? request.headers[CAPTCHA_HEADER] && helpers.recaptcha_enabled? end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 1a966739401..b797a204d7f 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -29,7 +29,7 @@ class UploadsController < ApplicationController before_action :authorize_create_access!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned + feature_category :team_planning def self.model_classes MODEL_CLASSES diff --git a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt index e1dddcb2c66..4f9c00980e1 100644 --- a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt +++ b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt @@ -31,7 +31,7 @@ git push -uf origin <%= @project.default_branch_or_main %> - [ ] [Create a new merge request](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html") %>) - [ ] [Automatically close issues from merge requests](<%= redirect("https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically") %>) - [ ] [Enable merge request approvals](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/approvals/") %>) -- [ ] [Automatically merge when pipeline succeeds](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>) +- [ ] [Set auto-merge](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>) ## Test and Deploy diff --git a/app/finders/alert_management/http_integrations_finder.rb b/app/finders/alert_management/http_integrations_finder.rb index e8e85da11b7..77a3824576f 100644 --- a/app/finders/alert_management/http_integrations_finder.rb +++ b/app/finders/alert_management/http_integrations_finder.rb @@ -2,7 +2,9 @@ module AlertManagement class HttpIntegrationsFinder - def initialize(project, params) + TYPE_IDENTIFIERS = ::AlertManagement::HttpIntegration.type_identifiers + + def initialize(project, params = {}) @project = project @params = params end @@ -13,6 +15,7 @@ module AlertManagement filter_by_availability filter_by_endpoint_identifier filter_by_active + filter_by_type collection end @@ -21,15 +24,13 @@ module AlertManagement attr_reader :project, :params, :collection + # Overridden in EE def filter_by_availability - return if multiple_alert_http_integrations? - - first_id = project.alert_management_http_integrations - .ordered_by_id - .select(:id) - .limit(1) - - @collection = collection.id_in(first_id) + # Re-find by id so subsequent filters don't expose unavailable records + @collection = collection.id_in(collection + .select('DISTINCT ON (type_identifier) id') + .ordered_by_type_and_id + .limit(TYPE_IDENTIFIERS.length)) end def filter_by_endpoint_identifier @@ -44,9 +45,11 @@ module AlertManagement @collection = collection.active end - # Overridden in EE - def multiple_alert_http_integrations? - false + def filter_by_type + return unless params[:type_identifier] + return unless TYPE_IDENTIFIERS.include?(params[:type_identifier]) + + @collection = collection.for_type(params[:type_identifier]) end end end diff --git a/app/finders/crm/organizations_finder.rb b/app/finders/crm/organizations_finder.rb index 69f72235c71..8701d26dd6e 100644 --- a/app/finders/crm/organizations_finder.rb +++ b/app/finders/crm/organizations_finder.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Finder for retrieving organizations scoped to a group +# Finder for retrieving crm_organizations scoped to a group # # Arguments: # current_user - user performing the action. Must have the correct permission level for the group. @@ -29,22 +29,22 @@ module Crm def execute return CustomerRelations::Organization.none unless root_group - organizations = root_group.organizations - organizations = by_ids(organizations) - organizations = by_search(organizations) - organizations = by_state(organizations) - sort_organizations(organizations) + crm_organizations = root_group.crm_organizations + crm_organizations = by_ids(crm_organizations) + crm_organizations = by_search(crm_organizations) + crm_organizations = by_state(crm_organizations) + sort_crm_organizations(crm_organizations) end private - def sort_organizations(organizations) - return organizations.sort_by_name unless @params.key?(:sort) - return organizations if @params[:sort].nil? + def sort_crm_organizations(crm_organizations) + return crm_organizations.sort_by_name unless @params.key?(:sort) + return crm_organizations if @params[:sort].nil? field = @params[:sort][:field] direction = @params[:sort][:direction] - organizations.sort_by_field(field, direction) + crm_organizations.sort_by_field(field, direction) end def root_group @@ -57,22 +57,22 @@ module Crm end end - def by_search(organizations) - return organizations unless search? + def by_search(crm_organizations) + return crm_organizations unless search? - organizations.search(params[:search]) + crm_organizations.search(params[:search]) end - def by_state(organizations) - return organizations unless state? + def by_state(crm_organizations) + return crm_organizations unless state? - organizations.search_by_state(params[:state]) + crm_organizations.search_by_state(params[:state]) end - def by_ids(organizations) - return organizations unless ids? + def by_ids(crm_organizations) + return crm_organizations unless ids? - organizations.id_in(params[:ids]) + crm_organizations.id_in(params[:ids]) end def search? diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index 316dffcb3b2..5241a3b3907 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -57,22 +57,8 @@ class DeploymentsFinder raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified' end - # Currently, the inefficient parameters are allowed in order to avoid breaking changes in Deployment API. - # We'll switch to a hard error in https://gitlab.com/gitlab-org/gitlab/-/issues/328500. if filter_by_updated_at? && !order_by_updated_at? - error = InefficientQueryError.new('`updated_at` filter requires `updated_at` sort') - - Gitlab::ErrorTracking.log_exception(error) - - # We are adding a Feature Flag to introduce the breaking change indicated in - # https://gitlab.com/gitlab-org/gitlab/-/issues/328500 - # We are also adding a way to override this flag for special case users that - # are running into large volume of errors when the flag is enabled. - # These Feature Flags must be removed by 16.1 - if Feature.enabled?(:deployments_raise_updated_at_inefficient_error) && - Feature.disabled?(:deployments_raise_updated_at_inefficient_error_override, params[:project]) - raise error - end + raise InefficientQueryError, '`updated_at` filter requires `updated_at` sort' end if filter_by_finished_at? && !order_by_finished_at? diff --git a/app/finders/groups/environment_scopes_finder.rb b/app/finders/groups/environment_scopes_finder.rb new file mode 100644 index 00000000000..886be7881ee --- /dev/null +++ b/app/finders/groups/environment_scopes_finder.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Groups::EnvironmentsScopesFinder +# +# Arguments: +# group +# params: +# search: string +# +module Groups + class EnvironmentScopesFinder + DEFAULT_ENVIRONMENT_SCOPES_LIMIT = 100 + + def initialize(group:, params: {}) + @group = group + @params = params + end + + EnvironmentScope = Struct.new(:name) + + def execute + variables = group.variables + variables = by_name(variables) + variables = by_search(variables) + variables = variables.limit(DEFAULT_ENVIRONMENT_SCOPES_LIMIT) + environment_scope_names = variables.environment_scope_names + environment_scope_names.map { |environment_scope| EnvironmentScope.new(environment_scope) } + end + + private + + attr_reader :group, :params + + def by_name(group_variables) + if params[:name].present? + group_variables.by_environment_scope(params[:name]) + else + group_variables + end + end + + def by_search(group_variables) + if params[:search].present? + group_variables.for_environment_scope_like(params[:search]) + else + group_variables + end + end + end +end diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 24003111f88..63f7616884f 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -72,17 +72,10 @@ class GroupsFinder < UnionFinder # rubocop: disable CodeReuse/ActiveRecord def groups_with_min_access_level - groups = current_user + current_user .groups .where('members.access_level >= ?', params[:min_access_level]) - - if Feature.enabled?(:use_traversal_ids_groups_finder, current_user) - groups.self_and_descendants - else - Gitlab::ObjectHierarchy - .new(groups) - .base_and_descendants - end + .self_and_descendants end # rubocop: enable CodeReuse/ActiveRecord @@ -110,13 +103,11 @@ class GroupsFinder < UnionFinder end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def by_search(groups) return groups unless params[:search].present? groups.search(params[:search], include_parents: params[:parent].blank?) end - # rubocop: enable CodeReuse/ActiveRecord def owned_groups current_user&.owned_groups || Group.none @@ -145,20 +136,13 @@ class GroupsFinder < UnionFinder def get_groups_for_user groups = [] - if Feature.enabled?(:use_traversal_ids_groups_finder, current_user) - groups << if include_ancestors? - current_user.authorized_groups.self_and_ancestors - else - current_user.authorized_groups - end + groups << if include_ancestors? + current_user.authorized_groups.self_and_ancestors + else + current_user.authorized_groups + end - groups << current_user.groups.self_and_descendants - elsif include_ancestors? - groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects - else - groups << current_user.authorized_groups - groups << Gitlab::ObjectHierarchy.new(groups_for_descendants).base_and_descendants - end + groups << current_user.groups.self_and_descendants groups end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index f1c5d5e08ad..f7ee90ab870 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -158,19 +158,15 @@ class MergeRequestsFinder < IssuableFinder return items if draft_param.nil? if draft_param - items.where(wip_match(items.arel_table)) + items.where(draft_match(items.arel_table)) else - items.where.not(wip_match(items.arel_table)) + items.where.not(draft_match(items.arel_table)) end end # rubocop: enable CodeReuse/ActiveRecord - # WIP is deprecated in favor of Draft. Currently both options are supported - def wip_match(table) - table[:title].matches('WIP:%') - .or(table[:title].matches('WIP %')) - .or(table[:title].matches('[WIP]%')) - .or(table[:title].matches('Draft - %')) + def draft_match(table) + table[:title].matches('Draft - %') .or(table[:title].matches('Draft:%')) .or(table[:title].matches('[Draft]%')) .or(table[:title].matches('(Draft)%')) diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb index c96f9527dd8..0194ee40801 100644 --- a/app/finders/namespaces/projects_finder.rb +++ b/app/finders/namespaces/projects_finder.rb @@ -32,6 +32,8 @@ module Namespaces namespace.projects.with_route end + collection = collection.not_aimed_for_deletion if params[:not_aimed_for_deletion].present? + collection = filter_projects(collection) sort(collection) diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb index 78240e0a050..c7d35f62673 100644 --- a/app/finders/releases_finder.rb +++ b/app/finders/releases_finder.rb @@ -6,18 +6,19 @@ class ReleasesFinder attr_reader :parent, :current_user, :params def initialize(parent, current_user = nil, params = {}) - @parent = parent + @parent = Array.wrap(parent) @current_user = current_user @params = params params[:order_by] ||= 'released_at' + params[:order_by_for_latest] ||= 'released_at' params[:sort] ||= 'desc' end def execute(preload: true) - return Release.none if projects.empty? + return Release.none if authorized_projects.empty? - releases = get_releases + releases = params[:latest] ? get_latest_releases : get_releases releases = by_tag(releases) releases = releases.preloaded if preload order_releases(releases) @@ -26,17 +27,22 @@ class ReleasesFinder private def get_releases - Release.where(project_id: projects).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord + Release.where(project_id: authorized_projects).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord end - def projects - strong_memoize(:projects) do - if parent.is_a?(Project) - Ability.allowed?(current_user, :read_release, parent) ? [parent] : [] - end - end + def get_latest_releases + Release.latest_for_projects(authorized_projects, order_by: params[:order_by_for_latest]).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord end + def authorized_projects + # Preload policy for all projects to avoid N+1 queries + projects = Project.id_in(parent.map(&:id)).include_project_feature + Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute + + projects.select { |project| authorized?(project) } + end + strong_memoize_attr :authorized_projects + # rubocop: disable CodeReuse/ActiveRecord def by_tag(releases) return releases unless params[:tag].present? @@ -48,4 +54,8 @@ class ReleasesFinder def order_releases(releases) releases.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") end + + def authorized?(project) + Ability.allowed?(current_user, :read_release, project) + end end diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb index c6c5c30cbf7..a8dd2cde20f 100644 --- a/app/finders/template_finder.rb +++ b/app/finders/template_finder.rb @@ -7,7 +7,6 @@ class TemplateFinder dockerfiles: ::Gitlab::Template::DockerfileTemplate, gitignores: ::Gitlab::Template::GitignoreTemplate, gitlab_ci_ymls: ::Gitlab::Template::GitlabCiYmlTemplate, - metrics_dashboard_ymls: ::Gitlab::Template::MetricsDashboardTemplate, issues: ::Gitlab::Template::IssueTemplate, merge_requests: ::Gitlab::Template::MergeRequestTemplate ).freeze @@ -28,14 +27,9 @@ class TemplateFinder end def type_allowed?(type) - case type.to_s - when 'licenses' - true - when 'metrics_dashboard_ymls' - !Feature.enabled?(:remove_monitor_metrics) - else - VENDORED_TEMPLATES.key?(type) - end + return true if type.to_s == 'licenses' + + VENDORED_TEMPLATES.key?(type) end end diff --git a/app/finders/uploader_finder.rb b/app/finders/uploader_finder.rb index 0d1de0d56fd..e4a0e831720 100644 --- a/app/finders/uploader_finder.rb +++ b/app/finders/uploader_finder.rb @@ -16,12 +16,12 @@ class UploaderFinder retrieve_file_state! uploader - rescue ::Gitlab::Utils::PathTraversalAttackError + rescue ::Gitlab::PathTraversal::PathTraversalAttackError nil # no-op if for incorrect files end def prevent_path_traversal_attack! - Gitlab::Utils.check_path_traversal!(@file_path) + Gitlab::PathTraversal.check_path_traversal!(@file_path) end def retrieve_file_state! diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 11e3c341c1f..57dbeca5c51 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -80,7 +80,15 @@ class UsersFinder def by_search(users) return users unless params[:search].present? - users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?) + if Feature.enabled?(:autocomplete_users_use_search_service) + users.search( + params[:search], + with_private_emails: current_user&.can_admin_all_resources?, + use_minimum_char_limit: params[:use_minimum_char_limit] + ) + else + users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?) + end end def by_blocked(users) diff --git a/app/graphql/cached_introspection_query.rb b/app/graphql/cached_introspection_query.rb new file mode 100644 index 00000000000..f2b98426714 --- /dev/null +++ b/app/graphql/cached_introspection_query.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module CachedIntrospectionQuery + def self.query_string + <<~QUERY.squish + query IntrospectionQuery { + __schema { + queryType { + name + } + mutationType { + name + } + subscriptionType { + name + } + types { + ...FullType + } + directives { + name + description + locations + args { + ...InputValue + } + } + } + } + + fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef + } + } + + fragment InputValue on __InputValue { + name + description + type { + ...TypeRef + } + defaultValue + } + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } + } + } + QUERY + end +end diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index d1798d2ade7..527eb50b644 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -48,8 +48,6 @@ module GraphqlTriggers end def self.merge_request_merge_status_updated(merge_request) - return unless Feature.enabled?(:realtime_mr_status_change, merge_request.project) - GitlabSchema.subscriptions.trigger( :merge_request_merge_status_updated, { issuable_id: merge_request.to_gid }, merge_request ) @@ -60,6 +58,14 @@ module GraphqlTriggers :merge_request_approval_state_updated, { issuable_id: merge_request.to_gid }, merge_request ) end + + def self.work_item_updated(work_item) + # becomes is necessary here since this can be triggered with both a WorkItem and also an Issue + # depending on the update service the call comes from + work_item = work_item.becomes(::WorkItem) if work_item.is_a?(::Issue) # rubocop:disable Cop/AvoidBecomes + + ::GitlabSchema.subscriptions.trigger('workItemUpdated', { work_item_id: work_item.to_gid }, work_item) + end end GraphqlTriggers.prepend_mod diff --git a/app/graphql/mutations/achievements/delete_user_achievement.rb b/app/graphql/mutations/achievements/delete_user_achievement.rb new file mode 100644 index 00000000000..f1527c2981a --- /dev/null +++ b/app/graphql/mutations/achievements/delete_user_achievement.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Achievements + class DeleteUserAchievement < BaseMutation + graphql_name 'UserAchievementsDelete' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :user_achievement, + ::Types::Achievements::UserAchievementType, + null: true, + description: 'Deleted user achievement.' + + argument :user_achievement_id, ::Types::GlobalIDType[::Achievements::UserAchievement], + required: true, + description: 'Global ID of the user achievement being deleted.' + + authorize :destroy_user_achievement + + def resolve(args) + user_achievement = authorized_find!(id: args[:user_achievement_id]) + + result = ::Achievements::DestroyUserAchievementService.new(current_user, user_achievement).execute + { user_achievement: result.payload, errors: result.errors } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Achievements::UserAchievement) + end + end + end +end diff --git a/app/graphql/mutations/ci/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb deleted file mode 100644 index a7d99d2a496..00000000000 --- a/app/graphql/mutations/ci/ci_cd_settings_update.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module Ci - # TODO: Remove after 16.0, see https://gitlab.com/gitlab-org/gitlab/-/issues/361801#note_1373963840 - class CiCdSettingsUpdate < ProjectCiCdSettingsUpdate - graphql_name 'CiCdSettingsUpdate' - - def ready?(**args) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`remove_cicd_settings_update` feature flag is enabled.' \ - if Feature.enabled?(:remove_cicd_settings_update) - - super - end - end - end -end diff --git a/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb b/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb index 53036496de4..1ef59fbeba4 100644 --- a/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb +++ b/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb @@ -38,11 +38,6 @@ module Mutations project = authorized_find!(id: project_id) - if Feature.disabled?(:ci_job_artifact_bulk_destroy, project) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - '`ci_job_artifact_bulk_destroy` feature flag is disabled.' - end - raise Gitlab::Graphql::Errors::ArgumentError, 'IDs array of job artifacts can not be empty' if ids.empty? result = ::Ci::JobArtifacts::BulkDeleteByProjectService.new( diff --git a/app/graphql/mutations/ci/pipeline/cancel.rb b/app/graphql/mutations/ci/pipeline/cancel.rb index c52e3b4f4b8..810f458fd75 100644 --- a/app/graphql/mutations/ci/pipeline/cancel.rb +++ b/app/graphql/mutations/ci/pipeline/cancel.rb @@ -11,12 +11,12 @@ module Mutations def resolve(id:) pipeline = authorized_find!(id: id) - if pipeline.cancelable? - pipeline.cancel_running + result = ::Ci::CancelPipelineService.new(pipeline: pipeline, current_user: current_user).execute + if result.success? { success: true, errors: [] } else - { success: false, errors: ['Pipeline is not cancelable'] } + { success: false, errors: [result.message] } end end end diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb index 6be07edd883..ee510373f34 100644 --- a/app/graphql/mutations/dependency_proxy/group_settings/update.rb +++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb @@ -8,10 +8,11 @@ module Mutations include Mutations::ResolvesGroup - description 'These settings can be adjusted by the group Owner or Maintainer. However, in GitLab 16.0, we ' \ - 'will be limiting this to the Owner role. ' \ - '[GitLab-#364441](https://gitlab.com/gitlab-org/gitlab/-/issues/364441) proposes making ' \ - 'this change to match the permissions level in the user interface.' + description <<~DESC + These settings can be adjusted by the group Owner or Maintainer. + [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting + this to Owners only to match the permissions level in the user interface. + DESC authorize :admin_dependency_proxy diff --git a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb index 79d7a93c4e2..0759b8e1beb 100644 --- a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb +++ b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb @@ -8,6 +8,12 @@ module Mutations include Mutations::ResolvesGroup + description <<~DESC + These settings can be adjusted by the group Owner or Maintainer. + [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting + this to Owners only to match the permissions level in the user interface. + DESC + authorize :admin_dependency_proxy argument :group_path, diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb new file mode 100644 index 00000000000..271585eb06c --- /dev/null +++ b/app/graphql/mutations/environments/create.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Mutations + module Environments + class Create < ::Mutations::BaseMutation + graphql_name 'EnvironmentCreate' + description 'Create an environment.' + + include FindsProject + + authorize :create_environment + + argument :project_path, + GraphQL::Types::ID, + required: true, + description: 'Full path of the project.' + + argument :name, + GraphQL::Types::String, + required: true, + description: 'Name of the environment.' + + argument :external_url, + GraphQL::Types::String, + required: false, + description: 'External URL of the environment.' + + argument :tier, + Types::DeploymentTierEnum, + required: false, + description: 'Tier of the environment.' + + argument :cluster_agent_id, + ::Types::GlobalIDType[::Clusters::Agent], + required: false, + description: 'Cluster agent of the environment.' + + field :environment, + Types::EnvironmentType, + null: true, + description: 'Created environment.' + + def resolve(project_path:, **kwargs) + project = authorized_find!(project_path) + + kwargs[:cluster_agent] = GitlabSchema.find_by_gid(kwargs.delete(:cluster_agent_id))&.sync + + response = ::Environments::CreateService.new(project, current_user, kwargs).execute + + if response.success? + { environment: response.payload[:environment], errors: [] } + else + { environment: response.payload[:environment], errors: response.errors } + end + end + end + end +end diff --git a/app/graphql/mutations/environments/delete.rb b/app/graphql/mutations/environments/delete.rb new file mode 100644 index 00000000000..5e3958b7936 --- /dev/null +++ b/app/graphql/mutations/environments/delete.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Environments + class Delete < ::Mutations::BaseMutation + graphql_name 'EnvironmentDelete' + description 'Delete an environment.' + + authorize :destroy_environment + + argument :id, + ::Types::GlobalIDType[::Environment], + required: true, + description: 'Global ID of the environment to Delete.' + + def resolve(id:, **kwargs) + environment = authorized_find!(id: id) + + response = ::Environments::DestroyService.new(environment.project, current_user, kwargs).execute(environment) + + if response.success? + { errors: [] } + else + { errors: response.errors } + end + end + end + end +end diff --git a/app/graphql/mutations/environments/update.rb b/app/graphql/mutations/environments/update.rb new file mode 100644 index 00000000000..431a7add00e --- /dev/null +++ b/app/graphql/mutations/environments/update.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Mutations + module Environments + class Update < ::Mutations::BaseMutation + graphql_name 'EnvironmentUpdate' + description 'Update an environment.' + + authorize :update_environment + + argument :id, + ::Types::GlobalIDType[::Environment], + required: true, + description: 'Global ID of the environment to update.' + + argument :external_url, + GraphQL::Types::String, + required: false, + description: 'External URL of the environment.' + + argument :tier, + Types::DeploymentTierEnum, + required: false, + description: 'Tier of the environment.' + + argument :cluster_agent_id, + ::Types::GlobalIDType[::Clusters::Agent], + required: false, + description: 'Cluster agent of the environment.' + + field :environment, + Types::EnvironmentType, + null: true, + description: 'Environment after attempt to update.' + + def resolve(id:, **kwargs) + environment = authorized_find!(id: id) + + convert_cluster_agent_id(kwargs) + + response = ::Environments::UpdateService.new(environment.project, current_user, kwargs).execute(environment) + + if response.success? + { environment: response.payload[:environment], errors: [] } + else + { environment: response.payload[:environment], errors: response.errors } + end + end + + private + + def convert_cluster_agent_id(kwargs) + return unless kwargs.key?(:cluster_agent_id) + + kwargs[:cluster_agent] = if kwargs[:cluster_agent_id] + ::Clusters::Agent.find_by_id(kwargs[:cluster_agent_id].model_id) + end + end + end + end +end diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 0c1acdf316e..c8a4d0aaa86 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -81,9 +81,7 @@ module Mutations def resolve(project_path:, **attributes) project = authorized_find!(project_path) params = build_create_issue_params(attributes.merge(author_id: current_user.id), project) - - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - result = ::Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: spam_params).execute + result = ::Issues::CreateService.new(container: project, current_user: current_user, params: params).execute check_spam_action_response!(result[:issue]) if result[:issue] diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb index 08578881a13..79216e0f821 100644 --- a/app/graphql/mutations/issues/set_confidential.rb +++ b/app/graphql/mutations/issues/set_confidential.rb @@ -15,11 +15,8 @@ module Mutations def resolve(project_path:, iid:, confidential:) issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - # Changing confidentiality affects spam checking rules, therefore we need to provide - # spam_params so a check can be performed. - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - - ::Issues::UpdateService.new(container: project, current_user: current_user, params: { confidential: confidential }, spam_params: spam_params) + # Changing confidentiality affects spam checking rules, therefore we need to perform a spam check + ::Issues::UpdateService.new(container: project, current_user: current_user, params: { confidential: confidential }, perform_spam_check: true) .execute(issue) check_spam_action_response!(issue) diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index b5af048dc07..2a863893cf1 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -41,8 +41,7 @@ module Mutations args = parse_arguments(args) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - ::Issues::UpdateService.new(container: project, current_user: current_user, params: args, spam_params: spam_params).execute(issue) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: args, perform_spam_check: true).execute(issue) { issue: issue, diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index ea72b71715c..96bee693a1e 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -8,6 +8,12 @@ module Mutations include Mutations::ResolvesNamespace + description <<~DESC + These settings can be adjusted by the group Owner or Maintainer. + [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting + this to Owners only to match the permissions level in the user interface. + DESC + authorize :admin_package argument :namespace_path, diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb index 4c6df2776cc..09b814d903e 100644 --- a/app/graphql/mutations/notes/update/base.rb +++ b/app/graphql/mutations/notes/update/base.rb @@ -24,12 +24,9 @@ module Mutations note_params(note, args) ).execute(note) - # It's possible for updated_note to be `nil`, in the situation - # where the note is deleted within `Notes::UpdateService` due to - # the body of the note only containing Quick Actions. { - note: updated_note&.reset, - errors: updated_note ? errors_on_object(updated_note) : [] + note: updated_note.destroyed? ? nil : updated_note.reset, + errors: updated_note.destroyed? ? [] : errors_on_object(updated_note) } end diff --git a/app/graphql/mutations/projects/sync_fork.rb b/app/graphql/mutations/projects/sync_fork.rb index 4520f6388c5..6a4ec4a26b8 100644 --- a/app/graphql/mutations/projects/sync_fork.rb +++ b/app/graphql/mutations/projects/sync_fork.rb @@ -22,9 +22,6 @@ module Mutations def resolve(project_path:, target_branch:) 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, ['Target branch does not exist']) unless project.repository.branch_exists?(target_branch) details_resolver = Resolvers::Projects::ForkDetailsResolver.new(object: project, context: context, field: nil) diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 96ac3f8a113..1c7dbfa751d 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -48,8 +48,7 @@ module Mutations process_args_for_params!(args) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - service = ::Snippets::CreateService.new(project: project, current_user: current_user, params: args, spam_params: spam_params) + service = ::Snippets::CreateService.new(project: project, current_user: current_user, params: args) service_response = service.execute # Only when the user is not an api user and the operation was successful diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index 39843a3714a..7faf9cf9019 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -33,8 +33,7 @@ module Mutations process_args_for_params!(args) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - service = ::Snippets::UpdateService.new(project: snippet.project, current_user: current_user, params: args, spam_params: spam_params) + service = ::Snippets::UpdateService.new(project: snippet.project, current_user: current_user, params: args, perform_spam_check: true) service_response = service.execute(snippet) # TODO: DRY this up - From here down, this is all duplicated with Mutations::Snippets::Create#resolve, except for diff --git a/app/graphql/mutations/users/set_namespace_commit_email.rb b/app/graphql/mutations/users/set_namespace_commit_email.rb new file mode 100644 index 00000000000..72ef0635bb3 --- /dev/null +++ b/app/graphql/mutations/users/set_namespace_commit_email.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Mutations + module Users + class SetNamespaceCommitEmail < BaseMutation + graphql_name 'UserSetNamespaceCommitEmail' + + argument :namespace_id, + ::Types::GlobalIDType[::Namespace], + required: true, + description: 'ID of the namespace to set the namespace commit email for.' + + argument :email_id, + ::Types::GlobalIDType[::Email], + required: false, + description: 'ID of the email to set.' + + field :namespace_commit_email, + Types::Users::NamespaceCommitEmailType, + null: true, + description: 'User namespace commit email after mutation.' + + authorize :read_namespace + + def resolve(args) + namespace = authorized_find!(args[:namespace_id]) + args[:email_id] = args[:email_id].model_id + + result = ::Users::SetNamespaceCommitEmailService.new(current_user, namespace, args[:email_id], {}).execute + { + namespace_commit_email: result.payload[:namespace_commit_email], + errors: result.errors + } + end + + private + + def find_object(id) + GitlabSchema.object_from_id( + id, expected_type: [::Namespace, ::Namespaces::UserNamespace, ::Namespaces::ProjectNamespace]).sync + end + end + end +end diff --git a/app/graphql/mutations/work_items/convert.rb b/app/graphql/mutations/work_items/convert.rb index 83bca56d900..b1936027fdc 100644 --- a/app/graphql/mutations/work_items/convert.rb +++ b/app/graphql/mutations/work_items/convert.rb @@ -27,13 +27,11 @@ module Mutations 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 + perform_spam_check: true ).execute(work_item) check_spam_action_response!(work_item) diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index dfd2d5d1f88..9f7b7b5db97 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -60,7 +60,6 @@ module Mutations 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) type = ::WorkItems::Type.find(attributes[:work_item_type_id]) widget_params = extract_widget_params!(type, params) @@ -69,7 +68,6 @@ module Mutations container: container, current_user: current_user, params: params, - spam_params: spam_params, widget_params: widget_params ).execute diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb index 23ae09b23fd..bf5c999bf75 100644 --- a/app/graphql/mutations/work_items/create_from_task.rb +++ b/app/graphql/mutations/work_items/create_from_task.rb @@ -30,13 +30,10 @@ module Mutations def resolve(id:, work_item_data:) work_item = authorized_find!(id: id) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - result = ::WorkItems::CreateFromTaskService.new( work_item: work_item, current_user: current_user, - work_item_params: work_item_data, - spam_params: spam_params + work_item_params: work_item_data ).execute check_spam_action_response!(result[:work_item]) if result[:work_item] diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index 3fd0f5aab62..f22e9bcf393 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -21,7 +21,6 @@ module Mutations work_item = authorized_find!(id: id) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) widget_params = extract_widget_params!(work_item.work_item_type, attributes) interpret_quick_actions!(work_item, current_user, widget_params, attributes) @@ -31,7 +30,7 @@ module Mutations current_user: current_user, params: attributes, widget_params: widget_params, - spam_params: spam_params + perform_spam_check: true ).execute(work_item) check_spam_action_response!(work_item) diff --git a/app/graphql/mutations/work_items/update_task.rb b/app/graphql/mutations/work_items/update_task.rb index 8dcc4c325ea..d3df235f894 100644 --- a/app/graphql/mutations/work_items/update_task.rb +++ b/app/graphql/mutations/work_items/update_task.rb @@ -29,13 +29,11 @@ module Mutations work_item = authorized_find!(id: id) task = authorized_find_task!(task_data_hash[:id]) - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - ::WorkItems::UpdateService.new( container: task.project, current_user: current_user, params: task_data_hash.except(:id), - spam_params: spam_params + perform_spam_check: true ).execute(task) check_spam_action_response!(task) diff --git a/app/graphql/queries/snippet/snippet.query.graphql b/app/graphql/queries/snippet/snippet.query.graphql index 5c0c7ebaa1b..8712a6f4b01 100644 --- a/app/graphql/queries/snippet/snippet.query.graphql +++ b/app/graphql/queries/snippet/snippet.query.graphql @@ -53,6 +53,7 @@ query GetSnippetQuery($ids: [SnippetID!]) { id fullPath webUrl + visibility } author { __typename diff --git a/app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb b/app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb new file mode 100644 index 00000000000..230301ca5da --- /dev/null +++ b/app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + module AuditEvents + class AuditEventDefinitionsResolver < BaseResolver + type [Types::AuditEvents::DefinitionType], null: false + + def resolve + Gitlab::Audit::Type::Definition.definitions.values + end + end + end +end diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb index 0b8180dbce7..546eeb76ff5 100644 --- a/app/graphql/resolvers/blobs_resolver.rb +++ b/app/graphql/resolvers/blobs_resolver.rb @@ -17,6 +17,10 @@ module Resolvers required: false, default_value: nil, description: 'Commit ref to get the blobs from. Default value is HEAD.' + argument :ref_type, Types::RefTypeEnum, + required: false, + default_value: nil, + description: 'Type of ref.' # We fetch blobs from Gitaly efficiently but it still scales O(N) with the # number of paths being fetched, so apply a scaling limit to that. @@ -24,7 +28,7 @@ module Resolvers super + (args[:paths] || []).size end - def resolve(paths:, ref:) + def resolve(paths:, ref:, ref_type:) authorize!(repository.container) return [] if repository.empty? @@ -32,7 +36,13 @@ module Resolvers ref ||= repository.root_ref validate_ref(ref) - repository.blobs_at(paths.map { |path| [ref, path] }) + ref = ExtractsRef.qualify_ref(ref, ref_type) + + repository.blobs_at(paths.map { |path| [ref, path] }).tap do |blobs| + blobs.each do |blob| + blob.ref_type = ref_type + end + end end private diff --git a/app/graphql/resolvers/group_environment_scopes_resolver.rb b/app/graphql/resolvers/group_environment_scopes_resolver.rb new file mode 100644 index 00000000000..61ccb2eefbb --- /dev/null +++ b/app/graphql/resolvers/group_environment_scopes_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class GroupEnvironmentScopesResolver < BaseResolver + type Types::Ci::GroupEnvironmentScopeType.connection_type, null: true + + alias_method :group, :object + + argument :name, GraphQL::Types::String, + required: false, + description: 'Name of the environment scope.' + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for environment scope name.' + + def resolve(**args) + return unless group.present? + + ::Groups::EnvironmentScopesFinder.new(group: group, params: args).execute + end + end +end diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb index 00c43bdfee6..acf7826ab13 100644 --- a/app/graphql/resolvers/last_commit_resolver.rb +++ b/app/graphql/resolvers/last_commit_resolver.rb @@ -11,7 +11,8 @@ module Resolvers def resolve(**args) # Ensure merge commits can be returned by sending nil to Gitaly instead of '/' path = tree.path == '/' ? nil : tree.path - commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path, literal_pathspec: true) + commit = Gitlab::Git::Commit.last_for_path(tree.repository, + ExtractsRef.qualify_ref(tree.sha, tree.ref_type), path, literal_pathspec: true) ::Commit.new(commit, tree.repository.project) if commit end diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb index 726e78f9971..f0781058bea 100644 --- a/app/graphql/resolvers/namespace_projects_resolver.rb +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -7,6 +7,11 @@ module Resolvers default_value: false, description: 'Include also subgroup projects.' + argument :not_aimed_for_deletion, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Include projects that are not aimed for deletion.' + argument :search, GraphQL::Types::String, required: false, default_value: nil, @@ -60,6 +65,7 @@ module Resolvers def finder_params(args) { include_subgroups: args.dig(:include_subgroups), + not_aimed_for_deletion: args.dig(:not_aimed_for_deletion), sort: args.dig(:sort), search: args.dig(:search), ids: parse_gids(args.dig(:ids)), diff --git a/app/graphql/resolvers/noteable/notes_resolver.rb b/app/graphql/resolvers/noteable/notes_resolver.rb new file mode 100644 index 00000000000..0d25c747ffb --- /dev/null +++ b/app/graphql/resolvers/noteable/notes_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Resolvers + module Noteable + class NotesResolver < BaseResolver + include LooksAhead + + type Types::Notes::NoteType.connection_type, null: false + + before_connection_authorization do |nodes, current_user| + next if nodes.blank? + + # For all noteables where we use this resolver, we can assume that all notes will belong to the same project + project = nodes.first.project + + ::Preloaders::Projects::NotesPreloader.new(project, current_user).call(nodes) + end + + def resolve_with_lookahead(*) + apply_lookahead(object.notes.fresh) + end + + private + + def unconditional_includes + [:author, :project] + end + + def preloads + { + award_emoji: [:award_emoji] + } + end + end + end +end diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb index 8fd80b1a9b9..de48fbafb04 100644 --- a/app/graphql/resolvers/paginated_tree_resolver.rb +++ b/app/graphql/resolvers/paginated_tree_resolver.rb @@ -18,6 +18,9 @@ module Resolvers argument :ref, GraphQL::Types::String, required: false, description: 'Commit ref to get the tree for. Default value is HEAD.' + argument :ref_type, Types::RefTypeEnum, + required: false, + description: 'Type of ref.' alias_method :repository, :object @@ -25,7 +28,6 @@ module Resolvers return if repository.empty? cursor = args.delete(:after) - args[:ref] ||= :head pagination_params = { limit: @field.max_page_size || 100, @@ -33,9 +35,11 @@ module Resolvers } tree = repository.tree( - args[:ref], args[:path], recursive: args[:recursive], - skip_flat_paths: false, - pagination_params: pagination_params + args[:ref].presence || :head, + args[:path], recursive: args[:recursive], + skip_flat_paths: false, + pagination_params: pagination_params, + ref_type: args[:ref_type] ) next_cursor = tree.cursor&.next_cursor diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb index d2b67451698..4f52db6801d 100644 --- a/app/graphql/resolvers/timelog_resolver.rb +++ b/app/graphql/resolvers/timelog_resolver.rb @@ -37,7 +37,7 @@ module Resolvers argument :sort, Types::TimeTracking::TimelogSortEnum, description: 'List timelogs in a particular order.', required: false, - default_value: { field: 'spent_at', direction: :asc } + default_value: :spent_at_asc def resolve_with_lookahead(**args) validate_args!(object, args) @@ -144,10 +144,7 @@ module Resolvers def apply_sorting(timelogs, args) return timelogs unless args[:sort] - field = args[:sort][:field] - direction = args[:sort][:direction] - - timelogs.sort_by_field(field, direction) + timelogs.sort_by_field(args[:sort]) end def raise_argument_error(message) diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb index 553f9aa6cd9..6b88f120d1b 100644 --- a/app/graphql/resolvers/tree_resolver.rb +++ b/app/graphql/resolvers/tree_resolver.rb @@ -17,14 +17,18 @@ module Resolvers argument :ref, GraphQL::Types::String, required: false, description: 'Commit ref to get the tree for. Default value is HEAD.' + argument :ref_type, Types::RefTypeEnum, + required: false, + description: 'Type of ref.' alias_method :repository, :object def resolve(**args) return unless repository.exists? - args[:ref] ||= :head - repository.tree(args[:ref], args[:path], recursive: args[:recursive]) + ref = (args[:ref].presence || :head) + + repository.tree(ref, args[:path], recursive: args[:recursive], ref_type: args[:ref_type]) end end end diff --git a/app/graphql/subscriptions/work_item_updated.rb b/app/graphql/subscriptions/work_item_updated.rb new file mode 100644 index 00000000000..f7bb8372e50 --- /dev/null +++ b/app/graphql/subscriptions/work_item_updated.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Subscriptions + class WorkItemUpdated < BaseSubscription + include Gitlab::Graphql::Laziness + + payload_type Types::WorkItemType + + argument :work_item_id, Types::GlobalIDType[WorkItem], + required: true, + description: 'ID of the work item.' + + def authorized?(work_item_id:) + work_item = force(GitlabSchema.find_by_gid(work_item_id)) + + unauthorized! unless work_item && Ability.allowed?(current_user, :"read_#{work_item.to_ability_name}", work_item) + + true + end + end +end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 5784c7a4872..36dd930c3d9 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -144,10 +144,6 @@ module Types null: false, description: 'URL of the alert.' - def notes - object.ordered_notes - end - def metrics_dashboard_url return if Feature.enabled?(:remove_monitor_metrics) diff --git a/app/graphql/types/audit_events/definition_type.rb b/app/graphql/types/audit_events/definition_type.rb new file mode 100644 index 00000000000..575b99c5815 --- /dev/null +++ b/app/graphql/types/audit_events/definition_type.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Types + module AuditEvents + class DefinitionType < ::Types::BaseObject + graphql_name 'AuditEventDefinition' + description 'Represents the YAML definitions for audit events defined ' \ + 'in `ee/config/audit_events/types/<event-type-name>.yml` ' \ + 'and `config/audit_events/types/<event-type-name>.yml`.' + + authorize :audit_event_definitions + + field :name, GraphQL::Types::String, + null: false, + description: 'Key name of the audit event.' + + field :description, GraphQL::Types::String, + null: false, + description: 'Description of what action the audit event tracks.' + + field :introduced_by_issue, GraphQL::Types::String, + null: true, + description: 'Link to the issue introducing the event. For older' \ + 'audit events, it can be a commit URL rather than a' \ + 'merge request URL.' + + field :introduced_by_mr, GraphQL::Types::String, + null: true, + description: 'Link to the merge request introducing the event. For' \ + 'older audit events, it can be a commit URL rather than' \ + 'a merge request URL.' + + field :feature_category, GraphQL::Types::String, + null: false, + description: 'Feature category associated with the event.' + + field :milestone, GraphQL::Types::String, + null: false, + description: 'Milestone the event was introduced in.' + + field :saved_to_database, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if the event is saved to PostgreSQL database.' + + field :streamed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if the event is streamed to an external destination.' + end + end +end diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb deleted file mode 100644 index b5947826fa1..00000000000 --- a/app/graphql/types/ci/catalog/resource_type.rb +++ /dev/null @@ -1,27 +0,0 @@ -# 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/group_environment_scope_connection_type.rb b/app/graphql/types/ci/group_environment_scope_connection_type.rb new file mode 100644 index 00000000000..ddbc00d3870 --- /dev/null +++ b/app/graphql/types/ci/group_environment_scope_connection_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupEnvironmentScopeConnectionType < GraphQL::Types::Relay::BaseConnection + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/group_environment_scope_type.rb b/app/graphql/types/ci/group_environment_scope_type.rb new file mode 100644 index 00000000000..3a3a5a3f59f --- /dev/null +++ b/app/graphql/types/ci/group_environment_scope_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupEnvironmentScopeType < BaseObject + graphql_name 'CiGroupEnvironmentScope' + description 'Ci/CD environment scope for a group.' + + connection_type_class(Types::Ci::GroupEnvironmentScopeConnectionType) + + field :name, GraphQL::Types::String, + null: true, + description: 'Scope name defininig the enviromnments that can use the variable.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb index 6346d50de3a..2280fcd1370 100644 --- a/app/graphql/types/ci/job_artifact_type.rb +++ b/app/graphql/types/ci/job_artifact_type.rb @@ -19,7 +19,7 @@ module Types description: 'File name of the artifact.', method: :filename - field :size, GraphQL::Types::Int, null: false, + field :size, GraphQL::Types::BigInt, null: false, description: 'Size of the artifact in bytes.' field :expire_at, Types::TimeType, null: true, diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index e77c2a38608..a779ceb2e2a 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -263,3 +263,5 @@ module Types end end end + +Types::Ci::JobType.prepend_mod_with('Types::Ci::JobType') diff --git a/app/graphql/types/ci/runner_manager_type.rb b/app/graphql/types/ci/runner_manager_type.rb index 2a5053f8f07..9c89b6537ea 100644 --- a/app/graphql/types/ci/runner_manager_type.rb +++ b/app/graphql/types/ci/runner_manager_type.rb @@ -47,3 +47,5 @@ module Types end end end + +Types::Ci::RunnerManagerType.prepend_mod_with('Types::Ci::RunnerManagerType') diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index a3737cbcd0d..936ad52200c 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -79,6 +79,13 @@ module Types null: true, description: 'Deployment freeze periods of the environment.' + field :cluster_agent, + Types::Clusters::AgentType, + description: 'Cluster agent of the environment.', + null: true do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + def tier object.tier.to_sym end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index 7ebd98ff2e7..295a20c645e 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -7,7 +7,11 @@ module Types A global identifier. A global identifier represents an object uniquely across the application. - An example of such an identifier is `"gid://gitlab/User/1"`. + An example of a global identifier is `"gid://gitlab/User/1"`. + + `gid://gitlab` stands for the root name. + `User` is the name of the ActiveRecord class of the record. + `1` is the record id as per the id in the db table. Global identifiers are encoded as strings. DESC diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index da2c06d04b7..5fd6ee948d3 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -83,6 +83,12 @@ module Types description: 'Merge requests for projects in this group.', resolver: Resolvers::GroupMergeRequestsResolver + field :environment_scopes, + Types::Ci::GroupEnvironmentScopeType.connection_type, + description: 'Environment scopes of the group.', + null: true, + resolver: Resolvers::GroupEnvironmentScopesResolver + field :milestones, description: 'Milestones of the group.', extras: [:lookahead], @@ -170,8 +176,14 @@ module Types field :dependency_proxy_total_size_in_bytes, GraphQL::Types::Int, null: false, + deprecated: { reason: 'Use `dependencyProxyTotalSizeBytes`', milestone: '16.1' }, description: 'Total size of the dependency proxy cached images in bytes.' + field :dependency_proxy_total_size_bytes, + GraphQL::Types::BigInt, + null: false, + description: 'Total size of the dependency proxy cached images in bytes, encoded as a string.' + field :dependency_proxy_image_prefix, GraphQL::Types::String, null: false, @@ -289,6 +301,10 @@ module Types end def dependency_proxy_total_size_in_bytes + dependency_proxy_total_size_bytes + end + + def dependency_proxy_total_size_bytes group.dependency_proxy_manifests.sum(:size) + group.dependency_proxy_blobs.sum(:size) end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 7e436d74dcf..16c46d172f3 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -9,6 +9,7 @@ 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::DeleteUserAchievement, alpha: { milestone: '16.1' } mount_mutation Mutations::Achievements::Revoke, alpha: { milestone: '15.10' } mount_mutation Mutations::Achievements::Update, alpha: { milestone: '15.11' } mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs @@ -52,7 +53,10 @@ module Types mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update mount_mutation Mutations::DependencyProxy::GroupSettings::Update mount_mutation Mutations::Environments::CanaryIngress::Update + mount_mutation Mutations::Environments::Create + mount_mutation Mutations::Environments::Delete mount_mutation Mutations::Environments::Stop + mount_mutation Mutations::Environments::Update mount_mutation Mutations::IncidentManagement::TimelineEvent::Create, alpha: { milestone: '15.6' } mount_mutation Mutations::IncidentManagement::TimelineEvent::PromoteFromNote mount_mutation Mutations::IncidentManagement::TimelineEvent::Update @@ -139,11 +143,6 @@ module Types mount_mutation Mutations::Ci::PipelineSchedule::Play mount_mutation Mutations::Ci::PipelineSchedule::Create mount_mutation Mutations::Ci::PipelineSchedule::Update - mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: { - reason: :renamed, - replacement: 'ProjectCiCdSettingsUpdate', - milestone: '15.0' - } mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate mount_mutation Mutations::Ci::Job::ArtifactsDestroy mount_mutation Mutations::Ci::Job::Play @@ -183,6 +182,7 @@ module Types mount_mutation Mutations::Pages::MarkOnboardingComplete mount_mutation Mutations::SavedReplies::Destroy mount_mutation Mutations::Uploads::Delete + mount_mutation Mutations::Users::SetNamespaceCommitEmail end end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 5055facb21b..eb1963f976a 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -11,6 +11,11 @@ module Types implements(Types::ResolvableInterface) + field :max_access_level_of_author, GraphQL::Types::String, + null: true, + description: "Max access level of the note author in the project.", + method: :human_max_access + field :id, ::Types::GlobalIDType[::Note], null: false, description: 'ID of the note.' @@ -36,6 +41,10 @@ module Types method: :note, description: 'Content of the note.' + field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type, + null: true, + description: 'List of award emojis associated with the note.' + field :confidential, GraphQL::Types::Boolean, null: true, description: 'Indicates if this note is confidential.', @@ -74,6 +83,12 @@ module Types null: true, description: 'User who last edited the note.' + field :author_is_contributor, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the note author is a contributor.', + method: :contributor?, + calls_gitaly: true + field :system_note_metadata, Types::Notes::SystemNoteMetadataType, null: true, description: 'Metadata for the given note if it is a system note.' diff --git a/app/graphql/types/notes/noteable_interface.rb b/app/graphql/types/notes/noteable_interface.rb index 537084dff62..9971511d6ce 100644 --- a/app/graphql/types/notes/noteable_interface.rb +++ b/app/graphql/types/notes/noteable_interface.rb @@ -5,7 +5,7 @@ module Types module NoteableInterface include Types::BaseInterface - field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable." + field :notes, resolver: Resolvers::Noteable::NotesResolver, null: false, description: "All notes on this noteable." field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable." field :commenters, Types::UserType.connection_type, null: false, description: "All commenters on this noteable." diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb index 0b6a384ec0e..d9946fc4ea6 100644 --- a/app/graphql/types/permission_types/work_item.rb +++ b/app/graphql/types/permission_types/work_item.rb @@ -7,7 +7,8 @@ module Types description 'Check permissions for the current user on a work item' abilities :read_work_item, :update_work_item, :delete_work_item, - :admin_work_item, :admin_parent_link, :set_work_item_metadata + :admin_work_item, :admin_parent_link, :set_work_item_metadata, + :create_note end end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 20dce54d740..b26e447f622 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -165,6 +165,12 @@ module Types alpha: { milestone: '15.1' }, description: 'Find a work item.' + field :audit_event_definitions, + Types::AuditEvents::DefinitionType.connection_type, + null: false, + description: 'Definitions for all audit events available on the instance.', + resolver: Resolvers::AuditEvents::AuditEventDefinitionsResolver + def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/ref_type_enum.rb b/app/graphql/types/ref_type_enum.rb new file mode 100644 index 00000000000..f56d4cd512a --- /dev/null +++ b/app/graphql/types/ref_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class RefTypeEnum < BaseEnum + graphql_name 'RefType' + description 'Type of ref' + + value 'HEADS', description: 'Ref type for branches.', value: 'heads' + value 'TAGS', description: 'Ref type for tags.', value: 'tags' + end +end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index 33fc0cbe20e..7f33f77ec14 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -47,6 +47,11 @@ module Types description: 'Triggered when a note is updated.', alpha: { milestone: '15.9' } + field :work_item_updated, + subscription: Subscriptions::WorkItemUpdated, + null: true, + description: 'Triggered when a work item is updated.' + field :merge_request_reviewers_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the reviewers of a merge request are updated.' diff --git a/app/graphql/types/time_tracking/timelog_sort_enum.rb b/app/graphql/types/time_tracking/timelog_sort_enum.rb index ad21c084d78..40b9e0cfb67 100644 --- a/app/graphql/types/time_tracking/timelog_sort_enum.rb +++ b/app/graphql/types/time_tracking/timelog_sort_enum.rb @@ -6,16 +6,10 @@ module Types graphql_name 'TimelogSort' description 'Values for sorting timelogs' - sortable_fields = ['Spent at', 'Time spent'] - - sortable_fields.each do |field| - value "#{field.upcase.tr(' ', '_')}_ASC", - value: { field: field.downcase.tr(' ', '_'), direction: :asc }, - description: "#{field} by ascending order." - value "#{field.upcase.tr(' ', '_')}_DESC", - value: { field: field.downcase.tr(' ', '_'), direction: :desc }, - description: "#{field} by descending order." - end + value 'SPENT_AT_ASC', 'Spent at ascending order.', value: :spent_at_asc + value 'SPENT_AT_DESC', 'Spent at descending order.', value: :spent_at_desc + value 'TIME_SPENT_ASC', 'Time spent ascending order.', value: :time_spent_asc + value 'TIME_SPENT_DESC', 'Time spent descending order.', value: :time_spent_desc end end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index b4950cc60e3..5357f2f8e66 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -162,6 +162,41 @@ module Types extras: [:lookahead], resolver: ::Resolvers::Achievements::UserAchievementsResolver + field :bio, + type: ::GraphQL::Types::String, + null: true, + description: 'Bio of the user.' + + field :linkedin, + type: ::GraphQL::Types::String, + null: true, + description: 'LinkedIn profile name of the user.' + + field :twitter, + type: ::GraphQL::Types::String, + null: true, + description: 'Twitter username of the user.' + + field :discord, + type: ::GraphQL::Types::String, + null: true, + description: 'Discord ID of the user.' + + field :organization, + type: ::GraphQL::Types::String, + null: true, + description: 'Who the user represents or works for.' + + field :job_title, + type: ::GraphQL::Types::String, + null: true, + description: 'Job title of the user.' + + field :created_at, + type: Types::TimeType, + null: true, + description: 'Timestamp of when the user was created.' + definition_methods do def resolve_type(object, context) # in the absence of other information, we cannot tell - just default to diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb index 1741d6a953a..0a7f20caa02 100644 --- a/app/helpers/admin/application_settings/settings_helper.rb +++ b/app/helpers/admin/application_settings/settings_helper.rb @@ -15,6 +15,55 @@ module Admin def project_missing_pipeline_yaml?(project) project.repository&.gitlab_ci_yml.blank? end + + def code_suggestions_token_explanation + link_start = code_suggestions_link_start(code_suggestions_pat_docs_url) + + # rubocop:disable Layout/LineLength + # rubocop:disable Style/FormatString + s_('CodeSuggestionsSM|Your personal access token from GitLab.com. See the %{link_start}documentation%{link_end} for information on creating a personal access token.') + .html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + # rubocop:enable Style/FormatString + # rubocop:enable Layout/LineLength + end + + def code_suggestions_agreement + terms_link_start = code_suggestions_link_start(code_suggestions_agreement_url) + ai_docs_link_start = code_suggestions_link_start(code_suggestions_ai_docs_url) + + # rubocop:disable Layout/LineLength + # rubocop:disable Style/FormatString + s_('CodeSuggestionsSM|• Agree to the %{terms_link_start}GitLab Testing Agreement%{link_end}.%{br} • Acknowledge that GitLab will send data from the instance, including personal data, to Google for cloud hosting.%{br} We may also send data to %{ai_docs_link_start}third-party AI providers%{link_end} to provide this feature.') + .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe, br: '</br>'.html_safe } + # rubocop:enable Style/FormatString + # rubocop:enable Layout/LineLength + end + + private + + # rubocop:disable Gitlab/DocUrl + # We want to link SaaS docs for flexibility for every URL related to Code Suggestions on Self Managed. + # We expect to update docs often during the Beta and we want to point user to the most up to date information. + def code_suggestions_docs_url + 'https://docs.gitlab.com/ee/user/project/repository/code_suggestions.html' + end + + def code_suggestions_agreement_url + 'https://about.gitlab.com/handbook/legal/testing-agreement/' + end + + def code_suggestions_ai_docs_url + 'https://docs.gitlab.com/ee/user/ai_features.html' + end + + def code_suggestions_pat_docs_url + 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token' + end + # rubocop:enable Gitlab/DocUrl + + def code_suggestions_link_start(url) + "<a href=\"#{url}\" target=\"_blank\" rel=\"noopener noreferrer\">".html_safe + end end end end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index e9465e0db22..5beefbb943c 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -53,8 +53,12 @@ module AppearancesHelper image_path('logo.svg') end - def brand_text - markdown_field(current_appearance, :description) + def custom_sign_in_description + [ + markdown_field(current_appearance, :description), + markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text), + markdown(Gitlab::CurrentSettings.help_text) + ].compact_blank.join("<br>").html_safe end def brand_new_project_guidelines diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 71f8478544b..7f1c28de8a7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -191,16 +191,17 @@ module ApplicationHelper } end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) - return unless object.edited? + def edited_time_ago_with_tooltip(editable_object, placement: 'top', html_class: 'time_ago', exclude_author: false) + return unless editable_object.edited? content_tag :small, class: 'edited-text' do - output = content_tag(:span, 'Edited ') - output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) + timeago = time_ago_with_tooltip(editable_object.last_edited_at, placement: placement, html_class: html_class) - if !exclude_author && object.last_edited_by - output << content_tag(:span, ' by ') - output << link_to_member(object.project, object.last_edited_by, avatar: false, extra_class: 'gl-hover-text-decoration-underline', author_class: nil) + if !exclude_author && editable_object.last_edited_by + author_link = link_to_member(editable_object.project, editable_object.last_edited_by, avatar: false, extra_class: 'gl-hover-text-decoration-underline', author_class: nil) + output = safe_format(_("Edited %{timeago} by %{author}"), timeago: timeago, author: author_link) + else + output = safe_format(_("Edited %{timeago}"), timeago: timeago) end output diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index dab682d88e0..adbf7ab7cf2 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -99,11 +99,11 @@ module ApplicationSettingsHelper checked_value: level, unchecked_value: nil ) do |c| - c.label do + c.with_label do visibility_level_icon(level) + content_tag(:span, label, { class: 'gl-ml-2' }) end - c.help_text do + c.with_help_text do restricted_visibility_levels_help_text.fetch(level) end end @@ -218,6 +218,7 @@ module ApplicationSettingsHelper :admin_mode, :after_sign_out_path, :after_sign_up_text, + :ai_access_token, :akismet_api_key, :akismet_enabled, :allow_local_requests_from_hooks_and_services, @@ -309,6 +310,7 @@ module ApplicationSettingsHelper :inactive_projects_delete_after_months, :inactive_projects_min_size_mb, :inactive_projects_send_warning_email_after_months, + :instance_level_code_suggestions_enabled, :invisible_captcha_enabled, :jira_connect_application_key, :jira_connect_public_key_storage_enabled, @@ -337,6 +339,8 @@ module ApplicationSettingsHelper :kroki_formats, :plantuml_enabled, :plantuml_url, + :diagramsnet_enabled, + :diagramsnet_url, :polling_interval_multiplier, :project_export_enabled, :prometheus_metrics_enabled, @@ -450,6 +454,7 @@ module ApplicationSettingsHelper :group_export_limit, :group_download_export_limit, :wiki_page_max_content_bytes, + :wiki_asciidoc_allow_uri_includes, :container_registry_delete_tags_service_timeout, :rate_limiting_response_text, :package_registry_cleanup_policies_worker_capacity, @@ -491,7 +496,8 @@ module ApplicationSettingsHelper :deactivation_email_additional_text, :projects_api_rate_limit_unauthenticated, :gitlab_dedicated_instance, - :ci_max_includes + :ci_max_includes, + :allow_account_deletion ].tap do |settings| next if Gitlab.com? diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 0ee08ba1820..0feaee2bd93 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -16,6 +16,7 @@ module AuthHelper jwt openid_connect salesforce + shibboleth twitter ).freeze LDAP_PROVIDER = /\Aldap/.freeze @@ -51,7 +52,8 @@ module AuthHelper { saml: 'saml_login_button', openid_connect: 'oidc_login_button', - github: 'github_login_button' + github: 'github_login_button', + gitlab: 'gitlab_oauth_login_button' }[provider.to_sym] end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 17f995ec0ad..d62498aea0b 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -27,11 +27,17 @@ module AvatarsHelper end end - def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true) + def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true, by_commit_email: false) return default_avatar if email.blank? Gitlab::AvatarCache.by_email(email, size, scale, only_path) do - avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path) + avatar_icon_by_user_email_or_gravatar( + email, + size, + scale, + only_path: only_path, + by_commit_email: by_commit_email + ) end end @@ -115,8 +121,13 @@ module AvatarsHelper private - def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:) - user = User.with_public_email(email).first + def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:, by_commit_email: false) + user = + if by_commit_email + User.find_by_any_email(email) + else + User.with_public_email(email).first + end if user avatar_icon_for_user(user, size, scale, only_path: only_path) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 02f69327dff..be9306ce80b 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -141,10 +141,6 @@ module BlobHelper @gitlab_ci_ymls ||= TemplateFinder.all_template_names(project, :gitlab_ci_ymls) end - def metrics_dashboard_ymls(project) - @metrics_dashboard_ymls ||= TemplateFinder.all_template_names(project, :metrics_dashboard_ymls) - end - def dockerfile_names(project) @dockerfile_names ||= TemplateFinder.all_template_names(project, :dockerfiles) end diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index a500a695029..9fadd5ece14 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -20,6 +20,27 @@ module BranchesHelper end end end + + def merge_request_status(merge_request) + return unless merge_request.present? + return if merge_request.closed? + + if merge_request.open? || merge_request.locked? + variant = :success + variant = :warning if merge_request.draft? + + mr_icon = 'merge-request-open' + mr_status = _('Open') + elsif merge_request.merged? + variant = :info + mr_icon = 'merge' + mr_status = _('Merged') + else + return + end + + { icon: mr_icon, title: "#{mr_status} - #{merge_request.title}", variant: variant } + end end BranchesHelper.prepend_mod_with('BranchesHelper') diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 2f14c907b12..a62ffa144f1 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -95,7 +95,8 @@ module BroadcastMessagesHelper target_path: broadcast_message.target_path, starts_at: broadcast_message.starts_at.iso8601, ends_at: broadcast_message.ends_at.iso8601, - target_access_level_options: target_access_level_options.to_json + target_access_level_options: target_access_level_options.to_json, + show_in_cli: broadcast_message.show_in_cli.to_s } end diff --git a/app/helpers/ci/catalog/resources_helper.rb b/app/helpers/ci/catalog/resources_helper.rb index 9f70410f17f..bc77e0cd33a 100644 --- a/app/helpers/ci/catalog/resources_helper.rb +++ b/app/helpers/ci/catalog/resources_helper.rb @@ -3,6 +3,10 @@ module Ci module Catalog module ResourcesHelper + def can_add_catalog_resource?(_project) + false + end + def can_view_namespace_catalog?(_project) false end diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 6b15f0c9e20..b222ca5538d 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -93,7 +93,7 @@ module Ci pipeline_schedule_url: pipeline_schedules_path(project), empty_state_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'), error_state_svg_path: image_path('illustrations/pipelines_failed.svg'), - no_pipelines_svg_path: image_path('illustrations/pipelines_pending.svg'), + no_pipelines_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'), can_create_pipeline: can?(current_user, :create_pipeline, project).to_s, new_pipeline_path: can?(current_user, :create_pipeline, project) && new_project_pipeline_path(project), ci_lint_path: can?(current_user, :create_pipeline, project) && project_ci_lint_path(project), @@ -101,7 +101,8 @@ 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, - full_path: project.full_path + full_path: project.full_path, + visibility_pipeline_id_type: visibility_pipeline_id_type } experiment(:ios_specific_templates, actor: current_user, project: project, sticky_to: project) do |e| @@ -114,6 +115,12 @@ module Ci data end + def visibility_pipeline_id_type + return 'id' unless current_user.present? + + current_user.user_preference.visibility_pipeline_id_type + end + private def warning_markdown(pipeline) diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 7177ddd3f31..b1481f668bb 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -22,12 +22,13 @@ module Ci icon = 'warning-solid' when :offline title = s_("Runners|Runner is offline; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) } - icon = 'status-failed' - span_class = 'gl-text-red-500' + icon = 'status-waiting' + span_class = 'gl-text-gray-500' when :stale # runner may have contacted (or not) and be stale: consider both cases. title = contacted_at ? s_("Runners|Runner is stale; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) } : s_("Runners|Runner is stale; it has never contacted this instance") - icon = 'warning-solid' + icon = 'time-out' + span_class = 'gl-text-orange-500' end content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner_status_icon', qa_selector: "runner_status_#{status}_content" }) do diff --git a/app/helpers/ci/secure_files_helper.rb b/app/helpers/ci/secure_files_helper.rb index fca89ddab1e..c4cc178f930 100644 --- a/app/helpers/ci/secure_files_helper.rb +++ b/app/helpers/ci/secure_files_helper.rb @@ -3,6 +3,7 @@ module Ci module SecureFilesHelper def show_secure_files_setting(project, user) return false if user.nil? + return false unless Gitlab.config.ci_secure_files.enabled user.can?(:read_secure_files, project) end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index b1d61474700..458d81b3401 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -55,12 +55,6 @@ module ClustersHelper case tab when 'environments' render_if_exists 'clusters/clusters/environments' - when 'health' - if Feature.enabled?(:remove_monitor_metrics) - render('details', expanded: expanded) - else - render_if_exists 'clusters/clusters/health' - end when 'apps' render 'applications' when 'integrations' diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index ed8cca20241..3d0b899e867 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -37,7 +37,7 @@ module FormHelper dismissible: false, alert_options: { id: 'error_explanation', class: 'gl-mb-5' } ) do |c| - c.body do + c.with_body do tag.ul(class: 'gl-pl-5 gl-mb-0') do messages end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 2ced1bec5e9..a4f463a23be 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -85,6 +85,7 @@ module GroupsHelper end end + # Overridden in EE def remove_group_message(group) _("You are going to remove %{group_name}. This will also delete all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % { group_name: group.name } @@ -128,7 +129,8 @@ module GroupsHelper { parent_group_url: group.parent && group_url(group.parent), parent_group_name: group.parent&.name, - import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane') + import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane'), + is_saas: Gitlab.com?.to_s } end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 448909543c4..696790b9dcb 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -8,8 +8,8 @@ module IdeHelper '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', + 'editor-font-src-url' => font_url('gitlab-mono/GitLabMono.woff2'), + 'editor-font-family' => 'GitLab Mono', 'editor-font-format' => 'woff2' }.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project)) @@ -29,20 +29,24 @@ module IdeHelper private + def new_ide_code_suggestions_data + {} + end + def new_ide_data(project:) { 'project-path' => project&.path_with_namespace, 'csp-nonce' => content_security_policy_nonce, # We will replace these placeholders in the FE 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path') - } + }.merge(new_ide_code_suggestions_data) end def legacy_ide_data(project:) { 'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'), 'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'), - 'committed-state-svg-path' => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), + 'committed-state-svg-path' => image_path('illustrations/rocket-launch-md.svg'), 'pipelines-empty-state-svg-path': image_path('illustrations/empty-state/empty-pipeline-md.svg'), 'switch-editor-svg-path': image_path('illustrations/rocket-launch-md.svg'), 'promotion-svg-path': image_path('illustrations/web-ide_promotion.svg'), diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 5471109e6d5..ffea23bf55d 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -136,6 +136,11 @@ module IntegrationsHelper form_data[:jira_issue_transition_id] = integration.jira_issue_transition_id end + if integration.is_a?(::Integrations::GitlabSlackApplication) + form_data[:upgrade_slack_url] = add_to_slack_link(project, slack_app_id) + form_data[:should_upgrade_slack] = integration.upgrade_needed?.to_s + end + form_data end @@ -212,6 +217,28 @@ module IntegrationsHelper event_i18n_map[event] || event.to_s.humanize end + def add_to_slack_link(project, slack_app_id) + query = { + scope: SlackIntegration::SCOPES.join(','), + client_id: slack_app_id, + redirect_uri: slack_auth_project_settings_slack_url(project), + state: form_authenticity_token + } + + "#{::Projects::SlackApplicationInstallService::SLACK_AUTHORIZE_URL}?#{query.to_query}" + end + + def gitlab_slack_application_data(projects) + { + projects: (projects || []).to_json(only: [:id, :name], methods: [:avatar_url, :name_with_namespace]), + sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'), + is_signed_in: current_user.present?.to_s, + slack_link_path: slack_link_profile_slack_path, + gitlab_logo_path: image_path('illustrations/gitlab_logo.svg'), + slack_logo_path: image_path('illustrations/slack_logo.svg') + } + end + extend self private diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index e986b56fde4..422380f3cc6 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -45,7 +45,7 @@ module InviteMembersHelper full_path: source.full_path } - if current_user && show_invite_members_for_task?(source) + if current_user && show_invite_members_for_task? dataset.merge!( tasks_to_be_done_options: tasks_to_be_done_options.to_json, projects: projects_for_source(source).to_json, @@ -71,8 +71,7 @@ module InviteMembersHelper {} end - # Overridden in EE - def show_invite_members_for_task?(_source) + def show_invite_members_for_task? params[:open_modal] == 'invite_members_for_task' end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 3796d8f0210..e247577aed0 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -159,7 +159,6 @@ module IssuablesHelper author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline-block") author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none") - author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1') author_output << issuable_meta_author_status(issuable.author) author_output @@ -176,11 +175,6 @@ module IssuablesHelper output.join.html_safe end - # This is a dummy method, and has an override defined in ee - def issuable_meta_author_slot(author, css_class: nil) - nil - end - def issuables_state_counter_text(issuable_type, state, display_count) titles = { opened: _("Open"), diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index fae8d86098e..341c50abf84 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -186,7 +186,7 @@ module IssuesHelper { autocomplete_award_emojis_path: autocomplete_award_emojis_path, calendar_path: url_for(safe_params.merge(calendar_url_options)), - empty_state_svg_path: image_path('illustrations/issues.svg'), + empty_state_svg_path: image_path('illustrations/empty-state/empty-issues-md.svg'), full_path: namespace.full_path, initial_sort: current_user&.user_preference&.issues_sort, is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s, @@ -241,7 +241,7 @@ module IssuesHelper calendar_path: url_for(safe_params.merge(calendar_url_options)), dashboard_labels_path: dashboard_labels_path(format: :json, include_ancestor_groups: true), dashboard_milestones_path: dashboard_milestones_path(format: :json), - empty_state_with_filter_svg_path: image_path('illustrations/issues.svg'), + empty_state_with_filter_svg_path: image_path('illustrations/empty-state/empty-issues-md.svg'), empty_state_without_filter_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'), initial_sort: current_user&.user_preference&.issues_sort, is_public_visibility_restricted: diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 29f94adcc78..42ffe338367 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -38,7 +38,7 @@ module MembersHelper def leave_confirmation_message(member_source) "Are you sure you want to leave the " \ - "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" + "\"#{member_source.human_name}\" #{member_source.model_name.to_s.humanize(capitalize: false)}?" end def filter_group_project_member_path(options = {}) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 4f30b555ba0..c7864c1d45f 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -87,8 +87,6 @@ module NavHelper end def show_super_sidebar?(user = current_user) - return false unless Feature.enabled?(:super_sidebar_nav, user) - # The new sidebar is not enabled for anonymous use # Once we enable the new sidebar by default, this # should return true diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index f2fa82aebdb..656d35e927d 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -46,7 +46,8 @@ module PreferencesHelper [ [s_('ProjectView|Files and Readme (default)'), :files], [s_('ProjectView|Activity'), :activity], - [s_('ProjectView|Readme'), :readme] + [s_('ProjectView|Readme'), :readme], + [s_('ProjectView|Wiki'), :wiki] ] end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 979b979fba7..26463003f8d 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -68,6 +68,11 @@ module ProfilesHelper def ssh_key_expiration_policy_enabled? false end + + # Overridden in EE::ProfilesHelper#prevent_delete_account? + def prevent_delete_account? + false + end end ProfilesHelper.prepend_mod diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index fc4ad10db21..b21bac9da7f 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -9,6 +9,7 @@ module Projects::ErrorTrackingHelper 'user-can-enable-error-tracking' => can?(current_user, :admin_operations, project).to_s, 'enable-error-tracking-link' => project_settings_operations_path(project), 'error-tracking-enabled' => error_tracking_enabled.to_s, + 'integrated-error-tracking-enabled' => integrated_tracking_enabled?(project).to_s, 'project-path' => project.full_path, 'list-path' => project_error_tracking_index_path(project), 'illustration-path' => image_path('illustrations/cluster_popover.svg'), @@ -24,15 +25,25 @@ module Projects::ErrorTrackingHelper 'project-path' => project.full_path, 'issue-update-path' => update_project_error_tracking_index_path(*opts), 'project-issues-path' => project_issues_path(project), - 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) + 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts), + 'integrated-error-tracking-enabled' => integrated_tracking_enabled?(project).to_s } end private + # Should show the alert if the FF was turned off after the integrated client has been configured. def show_integrated_tracking_disabled_alert?(project) return false if ::Feature.enabled?(:integrated_error_tracking, project) + integrated_client_enabled?(project) + end + + def integrated_tracking_enabled?(project) + ::Feature.enabled?(:integrated_error_tracking, project) && integrated_client_enabled?(project) + end + + def integrated_client_enabled?(project) setting ||= project.error_tracking_setting || project.build_error_tracking_setting diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 0239253d8f0..caebbd5250e 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -24,6 +24,30 @@ module Projects tests_count: pipeline.test_report_summary.total[:count] } end + + def js_pipeline_details_header_data(project, pipeline) + { + full_path: project.full_path, + graphql_resource_etag: graphql_etag_pipeline_path(pipeline), + pipeline_iid: pipeline.iid, + pipelines_path: project_pipelines_path(project), + name: pipeline.name, + total_jobs: pipeline.total_size, + yaml_errors: pipeline.yaml_errors, + failure_reason: pipeline.failure_reason, + triggered_by_path: pipeline.child? ? pipeline_path(pipeline.triggered_by_pipeline) : '', + schedule: pipeline.schedule?.to_s, + child: pipeline.child?.to_s, + latest: pipeline.latest?.to_s, + merge_train_pipeline: pipeline.merge_train_pipeline?.to_s, + invalid: pipeline.has_yaml_errors?.to_s, + failed: pipeline.failure_reason?.to_s, + auto_devops: pipeline.auto_devops_source?.to_s, + detached: pipeline.detached_merge_request_pipeline?.to_s, + stuck: pipeline.stuck?, + ref_text: pipeline.ref_text + } + end end end diff --git a/app/helpers/projects/topics_helper.rb b/app/helpers/projects/topics_helper.rb new file mode 100644 index 00000000000..dadce693d42 --- /dev/null +++ b/app/helpers/projects/topics_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + module TopicsHelper + # To ensure a route will always generate, we need to encode `topic_name`. + # Otherwise, various pages will encounter `No route matches` error. + # + # This does mean some double encoding as Rails ActionDispatch also encodes + # segments but that is OK + # + # Also, controllers that use `params[:topic_name]` will now need to perform + # decode_www_form_component. + def topic_explore_projects_cleaned_path(topic_name:) + topic_name = URI.encode_www_form_component(topic_name) if Feature.enabled?(:explore_topics_cleaned_path) + + topic_explore_projects_path(topic_name: topic_name) + end + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 1e87d2861d4..9415e7d4dc3 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -177,7 +177,11 @@ module ProjectsHelper abilities = Array(search_tab_ability_map[tab]) - abilities.any? { |ability| can?(current_user, ability, @project) } + if @project.respond_to?(:each) # support multi-project select + @project.any? { |project| abilities.any? { |ability| can?(current_user, ability, project) } } + else + abilities.any? { |ability| can?(current_user, ability, @project) } + end end def can_change_visibility_level?(project, current_user) @@ -421,8 +425,9 @@ module ProjectsHelper packagesAvailable: ::Gitlab.config.packages.enabled, packagesHelpPath: help_page_path('user/packages/index'), currentSettings: project_permissions_settings(project), - canDisableEmails: can_disable_emails?(project, current_user), + canAddCatalogResource: can_add_catalog_resource?(project), canChangeVisibilityLevel: can_change_visibility_level?(project, current_user), + canDisableEmails: can_disable_emails?(project, current_user), allowedVisibilityOptions: project_allowed_visibility_levels(project), visibilityHelpPath: help_page_path('user/public_access'), registryAvailable: Gitlab.config.registry.enabled, @@ -615,7 +620,8 @@ module ProjectsHelper commits: :read_code, merge_requests: :read_merge_request, notes: [:read_merge_request, :read_code, :read_issue, :read_snippet], - members: :read_project_member + users: :read_project_member, + wiki_blobs: :read_wiki ) end @@ -737,7 +743,6 @@ module ProjectsHelper containerRegistryEnabled: !!project.container_registry_enabled, lfsEnabled: !!project.lfs_enabled, emailsDisabled: project.emails_disabled?, - metricsDashboardAccessLevel: feature.metrics_dashboard_access_level, monitorAccessLevel: feature.monitor_access_level, showDefaultAwardEmojis: project.show_default_award_emojis?, warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?, @@ -747,7 +752,8 @@ module ProjectsHelper environmentsAccessLevel: feature.environments_access_level, featureFlagsAccessLevel: feature.feature_flags_access_level, releasesAccessLevel: feature.releases_access_level, - infrastructureAccessLevel: feature.infrastructure_access_level + infrastructureAccessLevel: feature.infrastructure_access_level, + modelExperimentsAccessLevel: feature.model_experiments_access_level } end diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb index fcd560dbe8c..4acba9b68d7 100644 --- a/app/helpers/registrations_helper.rb +++ b/app/helpers/registrations_helper.rb @@ -14,6 +14,11 @@ module RegistrationsHelper def signup_box_template 'devise/shared/signup_box' end + + # overridden in EE + def register_omniauth_params(_local_assigns) + {} + end end RegistrationsHelper.prepend_mod_with('RegistrationsHelper') diff --git a/app/helpers/resource_events/abuse_report_events_helper.rb b/app/helpers/resource_events/abuse_report_events_helper.rb new file mode 100644 index 00000000000..8adbc891184 --- /dev/null +++ b/app/helpers/resource_events/abuse_report_events_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ResourceEvents + module AbuseReportEventsHelper + def success_message_for_action(action) + case action + when 'ban_user' + s_('AbuseReportEvent|Successfully banned the user') + when 'block_user' + s_('AbuseReportEvent|Successfully blocked the user') + when 'delete_user' + s_('AbuseReportEvent|Successfully scheduled the user for deletion') + when 'close_report' + s_('AbuseReportEvent|Successfully closed the report') + when 'ban_user_and_close_report' + s_('AbuseReportEvent|Successfully banned the user and closed the report') + when 'block_user_and_close_report' + s_('AbuseReportEvent|Successfully blocked the user and closed the report') + when 'delete_user_and_close_report' + s_('AbuseReportEvent|Successfully scheduled the user for deletion and closed the report') + end + end + end +end diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb index c79e8b50a1a..d39a972f3f3 100644 --- a/app/helpers/safe_format_helper.rb +++ b/app/helpers/safe_format_helper.rb @@ -1,23 +1,67 @@ # frozen_string_literal: true module SafeFormatHelper - # Returns a HTML-safe string where +format+ and +args+ are escaped via - # `html_escape` if they are not marked as HTML-safe. + # Returns a HTML-safe String. # - # Argument +format+ must not be marked as HTML-safe via `.html_safe`. + # @param [String] format is escaped via `html_escape_once` + # @param [Array<Hash>] args are escaped via `html_escape` if they are not marked as HTML-safe # - # Example: - # safe_format('Some %{open}bold%{close} text.', open: '<strong>'.html_safe, close: '</strong>'.html_safe) - # # => 'Some <strong>bold</strong>' + # @example # safe_format('See %{user_input}', user_input: '<b>bold</b>') - # # => 'See <b>bold</b> + # # => "See <b>bold</b>" # - def safe_format(format, **args) - raise ArgumentError, 'Argument `format` must not be marked as html_safe!' if format.html_safe? + # safe_format('In < hour & more') + # # => "In < hour & more" + # + # @example With +tag_pair+ support + # safe_format('Some %{open}bold%{close} text.', tag_pair(tag.strong, :open, :close)) + # # => "Some <strong>bold</strong> text." + # safe_format('Some %{open}bold%{close} %{italicStart}text%{italicEnd}.', + # tag_pair(tag.strong, :open, :close), + # tag_pair(tag.i, :italicStart, :italicEnd)) + # # => "Some <strong>bold</strong> <i>text</i>. + def safe_format(format, *args) + args = args.inject({}, &:merge) - format( - html_escape(format), + # Use `Kernel.format` to avoid conflicts with ViewComponent's `format`. + Kernel.format( + html_escape_once(format), args.transform_values { |value| html_escape(value) } ).html_safe end + + # Returns a Hash containing a pair of +open+ and +close+ tag parts extracted + # from HTML-safe +tag+. The values are HTML-safe. + # + # Returns an empty Hash if +tag+ is not a valid paired tag (e.g. <p>foo</p>). + # an empty Hash is returned. + # + # @param [String] tag is a HTML-safe output from tag helper + # @param [Symbol,Object] open_name name of opening tag + # @param [Symbol,Object] close_name name of closing tag + # @raise [ArgumentError] if +tag+ is not HTML-safe + # + # @example + # tag_pair(tag.strong, :open, :close) + # # => { open: '<strong>', close: '</strong>' } + # tag_pair(link_to('', '/'), :open, :close) + # # => { open: '<a href="/">', close: '</a>' } + def tag_pair(html_tag, open_name, close_name) + raise ArgumentError, 'Argument `tag` must be `html_safe`!' unless html_tag.html_safe? + return {} unless html_tag.start_with?('<') + + # end of opening tag: <p>foo</p> + # ^ + open_index = html_tag.index('>') + # start of closing tag: <p>foo</p> + # ^^ + close_index = html_tag.rindex('</') + + return {} unless open_index && close_index + + { + open_name => html_tag[0, open_index + 1], + close_name => html_tag[close_index, html_tag.size] + } + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 2187126272d..8fbbd18c9ae 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -14,12 +14,12 @@ module SearchHelper :project_ids ].freeze - def search_autocomplete_opts(term, filter: nil) + def search_autocomplete_opts(term, filter: nil, scope: nil) return unless current_user results = case filter&.to_sym when :search - resource_results(term) + resource_results(term, scope: scope) when :generic [ recent_items_autocomplete(term), @@ -36,7 +36,10 @@ module SearchHelper results.flatten { |item| item[:label] } end - def resource_results(term) + def resource_results(term, scope: nil) + return [] if term.length < Gitlab::Search::Params::MIN_TERM_LENGTH + return scope_specific_results(term, scope) if scope.present? + [ groups_autocomplete(term), projects_autocomplete(term), @@ -45,6 +48,19 @@ module SearchHelper ].flatten end + def scope_specific_results(term, scope) + case scope&.to_sym + when :project + projects_autocomplete(term) + when :user + users_autocomplete(term) + when :issue + recent_issues_autocomplete(term) + else + [] + end + end + def generic_results(term) search_pattern = Regexp.new(Regexp.escape(term), "i") @@ -307,7 +323,7 @@ module SearchHelper # Autocomplete results for the current user's groups # rubocop: disable CodeReuse/ActiveRecord def groups_autocomplete(term, limit = 5) - current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group| + current_user.authorized_groups.order_id_desc.search(term, use_minimum_char_limit: false).limit(limit).map do |group| { category: "Groups", id: group.id, @@ -341,7 +357,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(term, include_namespace: true) + current_user.authorized_projects.order_id_desc.search(term, include_namespace: true, use_minimum_char_limit: false) .sorted_by_stars_desc.non_archived.limit(limit).map do |p| { category: "Projects", @@ -357,10 +373,17 @@ module SearchHelper def users_autocomplete(term, limit = 5) return [] unless current_user && Ability.allowed?(current_user, :read_users_list) - SearchService - .new(current_user, { scope: 'users', per_page: limit, search: term }) - .search_objects - .map do |user| + users = if Feature.enabled?(:autocomplete_users_use_search_service) + ::SearchService + .new(current_user, { scope: 'users', per_page: limit, search: term }) + .search_objects + else + is_current_user_admin = current_user.can_admin_all_resources? + scope = is_current_user_admin ? User.all : User.without_forbidden_states + scope.search(term, with_private_emails: is_current_user_admin, use_minimum_char_limit: false).limit(limit) + end + + users.map do |user| { category: "Users", id: user.id, @@ -448,38 +471,60 @@ module SearchHelper result end - def code_tab_condition + def show_code_search_tab? return true if project_search_tabs?(:blobs) @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab) end - def wiki_tab_condition - return true if project_search_tabs?(:wiki) + def show_wiki_search_tab? + return true if project_search_tabs?(:wiki_blobs) @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_wiki_tab) end - def commits_tab_condition + def show_commits_search_tab? return true if project_search_tabs?(:commits) @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab) end + def show_issues_search_tab? + return true if project_search_tabs?(:issues) + + @project.nil? && feature_flag_tab_enabled?(:global_search_issues_tab) + end + + def show_merge_requests_search_tab? + return true if project_search_tabs?(:merge_requests) + + @project.nil? && feature_flag_tab_enabled?(:global_search_merge_requests_tab) + end + + def show_comments_search_tab? + return true if project_search_tabs?(:notes) + + @project.nil? && search_service.show_elasticsearch_tabs? + end + + def show_snippets_search_tab? + search_service.show_snippets? && @project.nil? && feature_flag_tab_enabled?(:global_search_snippet_titles_tab) + end + # search page scope navigation def search_navigation { projects: { sort: 1, label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? }, - blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: code_tab_condition }, + blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: show_code_search_tab? }, # sort: 3 is reserved for EE items - issues: { sort: 4, label: _("Issues"), condition: project_search_tabs?(:issues) || feature_flag_tab_enabled?(:global_search_issues_tab) }, - merge_requests: { sort: 5, label: _("Merge requests"), condition: project_search_tabs?(:merge_requests) || feature_flag_tab_enabled?(:global_search_merge_requests_tab) }, - wiki_blobs: { sort: 6, label: _("Wiki"), condition: wiki_tab_condition }, - commits: { sort: 7, label: _("Commits"), condition: commits_tab_condition }, - notes: { sort: 8, label: _("Comments"), condition: project_search_tabs?(:notes) || search_service.show_elasticsearch_tabs? }, + issues: { sort: 4, label: _("Issues"), condition: show_issues_search_tab? }, + merge_requests: { sort: 5, label: _("Merge requests"), condition: show_merge_requests_search_tab? }, + wiki_blobs: { sort: 6, label: _("Wiki"), condition: show_wiki_search_tab? }, + commits: { sort: 7, label: _("Commits"), condition: show_commits_search_tab? }, + notes: { sort: 8, label: _("Comments"), condition: show_comments_search_tab? }, milestones: { sort: 9, label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? }, users: { sort: 10, label: _("Users"), condition: show_user_search_tab? }, - snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: search_service.show_snippets? && @project.nil? } + snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: show_snippets_search_tab? } } end @@ -567,7 +612,7 @@ module SearchHelper end def show_user_search_tab? - return project_search_tabs?(:members) if @project + return project_search_tabs?(:users) if @project return false unless can?(current_user, :read_users_list) return true if @group @@ -608,7 +653,14 @@ module SearchHelper def sanitized_search_params sanitized_params = params.dup - sanitized_params[:confidential] = Gitlab::Utils.to_boolean(sanitized_params[:confidential]) if sanitized_params.key?(:confidential) + + if sanitized_params.key?(:confidential) + sanitized_params[:confidential] = Gitlab::Utils.to_boolean(sanitized_params[:confidential]) + end + + if sanitized_params.key?(:include_archived) + sanitized_params[:include_archived] = Gitlab::Utils.to_boolean(sanitized_params[:include_archived]) + end sanitized_params end diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb index 13d6851f3cd..6100093b7c2 100644 --- a/app/helpers/ssh_keys_helper.rb +++ b/app/helpers/ssh_keys_helper.rb @@ -52,6 +52,6 @@ module SshKeysHelper quoted_allowed_algorithms = allowed_algorithms.map { |name| "'#{name}'" } - Gitlab::Utils.to_exclusive_sentence(quoted_allowed_algorithms) + Gitlab::Sentence.to_exclusive_sentence(quoted_allowed_algorithms) end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 0aeea323ddb..84512453b7c 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -157,17 +157,15 @@ module TreeHelper } end - def fork_modal_options(project, ref, path, blob) + def fork_modal_options(project, blob) if show_edit_button?({ blob: blob }) - fork_path = fork_and_edit_path(project, ref, path) fork_modal_id = "modal-confirm-fork-edit" elsif show_web_ide_button? - fork_path = ide_fork_and_edit_path(project, ref, path) fork_modal_id = "modal-confirm-fork-webide" end { - fork_path: fork_path, + fork_path: new_namespace_project_fork_path(project_id: project.path, namespace_id: project.namespace.full_path), fork_modal_id: fork_modal_id } end diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index af3ac495164..0f4cbd6642b 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -16,6 +16,7 @@ module Users WEB_HOOK_DISABLED = 'web_hook_disabled' ULTIMATE_FEATURE_REMOVAL_BANNER = 'ultimate_feature_removal_banner' BRANCH_RULES_INFO_CALLOUT = 'branch_rules_info_callout' + NEW_NAVIGATION_CALLOUT = 'new_navigation_callout' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -79,6 +80,18 @@ module Users !user_dismissed?(BRANCH_RULES_INFO_CALLOUT) end + def show_new_navigation_callout? + show_super_sidebar? && + !user_dismissed?(NEW_NAVIGATION_CALLOUT) && + # GitLab.com users created after the feature flag's full rollout (June 2nd 2023) don't need to see the callout. + # Remove the gitlab_com_user_created_after_new_nav_rollout? method when the callout isn't needed anymore. + !gitlab_com_user_created_after_new_nav_rollout? + end + + def gitlab_com_user_created_after_new_nav_rollout? + Gitlab.com? && current_user.created_at >= Date.new(2023, 6, 2) + end + def ultimate_feature_removal_banner_dismissed?(project) return false unless project diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 60230d58e30..c8002c437a9 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -184,14 +184,28 @@ module UsersHelper def user_profile_tabs_app_data(user) { - followees: user.followees.count, - followers: user.followers.count, + followees_count: user.followees.count, + followers_count: user.followers.count, user_calendar_path: user_calendar_path(user, :json), + user_activity_path: user_activity_path(user, :json), utc_offset: local_timezone_instance(user.timezone).now.utc_offset, - user_id: user.id + user_id: user.id, + snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg') } end + def moderation_status(user) + return unless user.present? + + if user.banned? + _('Banned') + elsif user.blocked? + _('Blocked') + else + _('Active') + end + end + private def admin_users_paths diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index c627f4633e4..f609c9318da 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -79,8 +79,7 @@ module Emails options = { from: email_sender, to: @service_desk_setting.custom_email_address_for_verification, - subject: subject, - content_type: "text/plain; charset=UTF-8" + subject: subject } # Outgoing emails from GitLab usually have this set to true. # Service Desk email ingestion ignores auto generated emails. @@ -176,6 +175,11 @@ module Emails .gsub(/%\{\s*SYSTEM_FOOTER\s*\}/, text_footer_message.to_s) .gsub(/%\{\s*UNSUBSCRIBE_URL\s*\}/, unsubscribe_sent_notification_url(@sent_notification)) .gsub(/%\{\s*ADDITIONAL_TEXT\s*\}/, service_desk_email_additional_text.to_s) + .gsub(/%\{\s*ISSUE_URL\s*\}/, full_issue_url) + end + + def full_issue_url + issue_url(@issue) end def issue_id diff --git a/app/models/abuse/event.rb b/app/models/abuse/event.rb new file mode 100644 index 00000000000..5700c1c73e6 --- /dev/null +++ b/app/models/abuse/event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Abuse + class Event < ApplicationRecord + self.table_name = 'abuse_events' + + validates :category, presence: true + validates :source, presence: true + validates :user, presence: true, on: :create + validates :metadata, json_schema: { filename: 'abuse_event_metadata' }, allow_blank: true + + belongs_to :user, inverse_of: :abuse_events + belongs_to :abuse_report, inverse_of: :abuse_events + + enum category: Enums::Abuse::Category.categories + enum source: Enums::Abuse::Source.sources + end +end diff --git a/app/models/abuse/trust_score.rb b/app/models/abuse/trust_score.rb index 9ad7c9b14b1..b7ed504a0ba 100644 --- a/app/models/abuse/trust_score.rb +++ b/app/models/abuse/trust_score.rb @@ -3,6 +3,7 @@ module Abuse class TrustScore < ApplicationRecord MAX_EVENTS = 100 + SPAMCHECK_HAM_THRESHOLD = 0.5 self.table_name = 'abuse_trust_scores' diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 55b1aff51da..1d2eee82827 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -12,13 +12,17 @@ class AbuseReport < ApplicationRecord cache_markdown_field :message, pipeline: :single_line - belongs_to :reporter, class_name: 'User' - belongs_to :user + belongs_to :reporter, class_name: 'User', inverse_of: :reported_abuse_reports + belongs_to :user, inverse_of: :abuse_reports + belongs_to :resolved_by, class_name: 'User', inverse_of: :resolved_abuse_reports + belongs_to :assignee, class_name: 'User', inverse_of: :assigned_abuse_reports has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report - validates :reporter, presence: true - validates :user, presence: true + has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report + + validates :reporter, presence: true, on: :create + validates :user, presence: true, on: :create validates :message, presence: true validates :category, presence: true validates :user_id, @@ -27,7 +31,7 @@ class AbuseReport < ApplicationRecord message: ->(object, data) do _('You have already reported this user') end - } + }, on: :create validates :reported_from_url, allow_blank: true, @@ -45,6 +49,9 @@ class AbuseReport < ApplicationRecord message: N_("exceeds the limit of %{count} links") } + validates :mitigation_steps, length: { maximum: 1000 }, allow_blank: true + validates :evidence, json_schema: { filename: 'abuse_report_evidence' }, allow_blank: true + before_validation :filter_empty_strings_from_links_to_spam validate :links_to_spam_contains_valid_urls diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb index 906855d6dfc..d5162865a79 100644 --- a/app/models/alert_management/http_integration.rb +++ b/app/models/alert_management/http_integration.rb @@ -3,8 +3,8 @@ module AlertManagement class HttpIntegration < ApplicationRecord include ::Gitlab::Routing + LEGACY_IDENTIFIER = 'legacy' - DEFAULT_NAME_SLUG = 'http-endpoint' belongs_to :project, inverse_of: :alert_management_http_integrations @@ -19,6 +19,7 @@ module AlertManagement validates :active, inclusion: { in: [true, false] } validates :token, presence: true, format: { with: /\A\h{32}\z/ } validates :name, presence: true, length: { maximum: 255 } + validates :type_identifier, presence: true validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ } validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active? validates :payload_attribute_mapping, json_schema: { filename: 'http_integration_payload_attribute_mapping' } @@ -29,15 +30,30 @@ module AlertManagement before_validation :ensure_payload_example_not_nil scope :for_endpoint_identifier, ->(endpoint_identifier) { where(endpoint_identifier: endpoint_identifier) } + scope :for_type, ->(type) { where(type_identifier: type) } + scope :for_project, ->(project_ids) { where(project: project_ids) } scope :active, -> { where(active: true) } - scope :ordered_by_id, -> { order(:id) } + scope :legacy, -> { for_endpoint_identifier(LEGACY_IDENTIFIER) } + scope :ordered_by_type_and_id, -> { order(:type_identifier, :id) } + + enum type_identifier: { + http: 0, + prometheus: 1 + } def url - return project_alerts_notify_url(project, format: :json) if legacy? + if legacy? + return project_alerts_notify_url(project, format: :json) if http? + return notify_project_prometheus_alerts_url(project, format: :json) if prometheus? + end project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json) end + def legacy? + endpoint_identifier == LEGACY_IDENTIFIER + end + private def self.generate_token @@ -45,11 +61,7 @@ module AlertManagement end def name_slug - (name && Gitlab::Utils.slugify(name)) || DEFAULT_NAME_SLUG - end - - def legacy? - endpoint_identifier == LEGACY_IDENTIFIER + (name && Gitlab::Utils.slugify(name)) || "#{type_identifier}-endpoint" end # Blank token assignment triggers token reset diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb index fa165ae9600..0f8e184933e 100644 --- a/app/models/analytics/cycle_analytics/aggregation.rb +++ b/app/models/analytics/cycle_analytics/aggregation.rb @@ -17,11 +17,13 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord end def consistency_check_cursor_for(model) + return {} if self["last_consistency_check_#{model.issuable_model.table_name}_issuable_id"].nil? + { :start_event_timestamp => self["last_consistency_check_#{model.issuable_model.table_name}_start_event_timestamp"], :end_event_timestamp => self["last_consistency_check_#{model.issuable_model.table_name}_end_event_timestamp"], model.issuable_id_column => self["last_consistency_check_#{model.issuable_model.table_name}_issuable_id"] - }.compact + } end def refresh_last_run(mode) diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb index 59c68393d74..31e06075bcb 100644 --- a/app/models/analytics/cycle_analytics/value_stream.rb +++ b/app/models/analytics/cycle_analytics/value_stream.rb @@ -21,6 +21,7 @@ module Analytics scope :preload_associated_models, -> { includes(:namespace, stages: [:namespace, :end_event_label, :start_event_label]) } + scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) } after_save :ensure_aggregation_record_presence diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index d2ca88aae0e..a71b47e88d8 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,8 +13,32 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18' ignore_column :send_user_confirmation_email, remove_with: '15.8', remove_after: '2022-12-18' ignore_column :web_ide_clientside_preview_enabled, remove_with: '15.11', remove_after: '2023-04-22' - ignore_column :clickhouse_connection_string, remove_with: '16.1', remove_after: '2023-05-22' ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22' + ignore_columns %i[ + encrypted_tofa_access_token_expires_in + encrypted_tofa_access_token_expires_in_iv + encrypted_tofa_client_library_args + encrypted_tofa_client_library_args_iv + encrypted_tofa_client_library_class + encrypted_tofa_client_library_class_iv + encrypted_tofa_client_library_create_credentials_method + encrypted_tofa_client_library_create_credentials_method_iv + encrypted_tofa_client_library_fetch_access_token_method + encrypted_tofa_client_library_fetch_access_token_method_iv + encrypted_tofa_credentials + encrypted_tofa_credentials_iv + encrypted_tofa_host + encrypted_tofa_host_iv + encrypted_tofa_request_json_keys + encrypted_tofa_request_json_keys_iv + encrypted_tofa_request_payload + encrypted_tofa_request_payload_iv + encrypted_tofa_response_json_keys + encrypted_tofa_response_json_keys_iv + encrypted_tofa_url + encrypted_tofa_url_iv + vertex_project + ], remove_with: '16.2', remove_after: '2023-06-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -31,6 +55,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord archive_builds_in_seconds: 'Archive job value' }.freeze + # matches the size set in the database constraint + DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE = 1.kilobyte + 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 @@ -86,6 +113,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord attribute :id, default: 1 attribute :repository_storages_weighted, default: -> { {} } attribute :kroki_formats, default: -> { {} } + attribute :default_branch_protection_defaults, default: -> { {} } chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds @@ -93,6 +121,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord chronic_duration_attr :group_runner_token_expiration_interval_human_readable, :group_runner_token_expiration_interval chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval + validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' } + validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } } + validates :grafana_url, system_hook_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ blocked_message: "is blocked: %{exception_message}. #{GRAFANA_URL_ERROR_MESSAGE}" @@ -187,6 +218,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :sourcegraph_url, presence: true, if: :sourcegraph_enabled + validates :diagramsnet_url, + presence: true, + addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ enforce_sanitization: true }), + if: :diagramsnet_enabled + validates :gitpod_url, presence: true, addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ enforce_sanitization: true }), @@ -379,6 +415,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 } validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes } + validates :wiki_asciidoc_allow_uri_includes, inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true @@ -390,6 +427,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :container_registry_delete_tags_service_timeout, :container_registry_cleanup_tags_service_max_list_size, + :container_registry_data_repair_detail_worker_max_concurrency, :container_registry_expiration_policies_worker_capacity, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -590,6 +628,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :search_rate_limit validates :search_rate_limit_unauthenticated validates :projects_api_rate_limit_unauthenticated + validates :gitlab_shell_operation_limit end validates :notes_create_limit_allowlist, @@ -668,6 +707,17 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :database_apdex_settings, json_schema: { filename: 'application_setting_database_apdex_settings' }, allow_nil: true + validates :namespace_aggregation_schedule_lease_duration_in_seconds, + numericality: { only_integer: true, greater_than: 0 } + + validates :instance_level_code_suggestions_enabled, + allow_nil: false, + inclusion: { in: [true, false], message: N_('must be a boolean value') } + + validates :ai_access_token, + presence: { message: N_("is required to enable Code Suggestions") }, + if: :instance_level_code_suggestions_enabled + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -713,18 +763,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord 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) attr_encrypted :anthropic_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - # TOFA API integration settngs - attr_encrypted :tofa_client_library_args, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_client_library_class, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_client_library_create_credentials_method, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_client_library_fetch_access_token_method, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_credentials, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_host, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_request_json_keys, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_request_payload, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_response_json_keys, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_url, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :tofa_access_token_expires_in, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :ai_access_token, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :vertex_ai_credentials, 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') } @@ -752,7 +792,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? before_validation :normalize_default_branch_name - before_validation :remove_old_import_sources before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -796,10 +835,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord users_count >= INSTANCE_REVIEW_MIN_USERS end - def remove_old_import_sources - self.import_sources -= %w[phabricator gitlab] if self.import_sources - end - Recursion = Class.new(RuntimeError) def self.create_from_defaults @@ -911,4 +946,5 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord end end +ApplicationSetting.prepend(ApplicationSettingMaskedAttrs) ApplicationSetting.prepend_mod_with('ApplicationSetting') diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 845d402f550..81e816a5b7c 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -37,6 +37,7 @@ module ApplicationSettingImplementation { admin_mode: false, after_sign_up_text: nil, + ai_access_token: nil, akismet_enabled: false, akismet_api_key: nil, allow_local_requests_from_system_hooks: true, @@ -104,6 +105,7 @@ module ApplicationSettingImplementation housekeeping_gc_period: 200, housekeeping_incremental_repack_period: 10, import_sources: Settings.gitlab['import_sources'], + instance_level_code_suggestions_enabled: false, invisible_captcha_enabled: false, issues_create_limit: 300, jira_connect_application_key: nil, @@ -132,6 +134,8 @@ module ApplicationSettingImplementation personal_access_token_prefix: 'glpat-', plantuml_enabled: false, plantuml_url: nil, + diagramsnet_enabled: true, + diagramsnet_url: 'https://embed.diagrams.net', polling_interval_multiplier: 1, productivity_analytics_start_date: Time.current, project_download_export_limit: 1, @@ -223,6 +227,7 @@ module ApplicationSettingImplementation user_show_add_ssh_key_message: true, valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES, wiki_page_max_content_bytes: 50.megabytes, + wiki_asciidoc_allow_uri_includes: false, package_registry_cleanup_policies_worker_capacity: 2, container_registry_delete_tags_service_timeout: 250, container_registry_expiration_policies_worker_capacity: 4, @@ -253,7 +258,9 @@ module ApplicationSettingImplementation user_defaults_to_private_profile: false, projects_api_rate_limit_unauthenticated: 400, gitlab_dedicated_instance: false, - ci_max_includes: 150 + ci_max_includes: 150, + allow_account_deletion: true, + gitlab_shell_operation_limit: 600 }.tap do |hsh| hsh.merge!(non_production_defaults) unless Rails.env.production? end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 163e741d990..9370982be47 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -100,6 +100,40 @@ class AuditEvent < ApplicationRecord super || details[:target_details] end + def self.by_group(group) + group_id = group.id + + # Bring entity_type and entity_id from projects and group into one query + scope1 = Group.find(group_id).all_projects.select("'Project' as entity_type", 'id AS entity_id') + scope2 = Project.from("(VALUES ('Group', #{group_id})) as projects(entity_type, entity_id)").select('entity_type', + 'entity_id') + array_scope = Project.from_union([scope1, scope2], remove_duplicates: false).select(:entity_type, :entity_id) + + # order by created_at (id is the tie breaker) + scope = AuditEvent.order(:created_at, :id) + + array_mapping_scope = ->(entity_type_expression, entity_id_expression) do + AuditEvent.where(AuditEvent.arel_table[:entity_id].eq(entity_id_expression)) + .where(AuditEvent.arel_table[:entity_type].eq(entity_type_expression)) + end + + finder_query = ->(created_at_expression, id_expression) do + # we need to add created_at filter as well because that's the partitioning key + AuditEvent.where( + AuditEvent.arel_table[:id].eq(id_expression) + ).where( + AuditEvent.arel_table[:created_at].eq(created_at_expression) + ) + end + + Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( + scope: scope, + array_scope: array_scope, + array_mapping_scope: array_mapping_scope, + finder_query: finder_query + ).execute + end + private def sanitize_message diff --git a/app/models/blob.rb b/app/models/blob.rb index 20d7c230aa2..bb8c9345573 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -33,6 +33,7 @@ class Blob < SimpleDelegator BlobViewer::Notebook, BlobViewer::SVG, BlobViewer::OpenApi, + BlobViewer::GeoJson, BlobViewer::Image, BlobViewer::Sketch, @@ -54,7 +55,6 @@ class Blob < SimpleDelegator BlobViewer::License, BlobViewer::Contributing, BlobViewer::Changelog, - BlobViewer::MetricsDashboardYml, BlobViewer::CargoToml, BlobViewer::Cartfile, @@ -72,6 +72,7 @@ class Blob < SimpleDelegator ].freeze attr_reader :container + attr_accessor :ref_type delegate :repository, to: :container, allow_nil: true delegate :project, to: :repository, allow_nil: true diff --git a/app/models/blob_viewer/geo_json.rb b/app/models/blob_viewer/geo_json.rb new file mode 100644 index 00000000000..01dcf7707eb --- /dev/null +++ b/app/models/blob_viewer/geo_json.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module BlobViewer + class GeoJson < Base + include Rich + include ClientSide + + self.binary = false + self.extensions = %w[geojson] + self.partial_name = 'geo_json' + end +end diff --git a/app/models/blob_viewer/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb deleted file mode 100644 index b63f3022198..00000000000 --- a/app/models/blob_viewer/metrics_dashboard_yml.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module BlobViewer - class MetricsDashboardYml < Base - include ServerSide - include Gitlab::Utils::StrongMemoize - include Auxiliary - - self.partial_name = 'metrics_dashboard_yml' - self.loading_partial_name = 'metrics_dashboard_yml_loading' - self.file_types = %i(metrics_dashboard) - self.binary = false - - def self.can_render?(blob, verify_binary: true) - super && !Feature.enabled?(:remove_monitor_metrics) - end - - def valid? - errors.blank? - end - - def errors - strong_memoize(:errors) do - prepare! - parse_blob_data - end - end - - private - - def parse_blob_data - old_metrics_dashboard_validation - end - - def old_metrics_dashboard_validation - yaml = ::Gitlab::Config::Loader::Yaml.new(blob.data).load_raw! - ::PerformanceMonitoring::PrometheusDashboard.from_json(yaml) - [] - rescue Gitlab::Config::Loader::FormatError => e - ["YAML syntax: #{e.message}"] - rescue ActiveModel::ValidationError => e - e.model.errors.messages.map { |messages| messages.join(': ') } - end - end -end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 733018160cd..bf25ea7367c 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -22,6 +22,7 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord validates :ends_at, presence: true validates :broadcast_type, presence: true validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS } + validates :show_in_cli, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :color, allow_blank: true, color: true validates :font, allow_blank: true, color: true @@ -29,6 +30,8 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord attribute :color, default: '#E75E40' attribute :font, default: '#FFFFFF' + scope :current_and_future_messages, -> { where('ends_at > :now', now: Time.current).order_id_asc } + CACHE_KEY = 'broadcast_message_current_json' BANNER_CACHE_KEY = 'broadcast_message_current_banner_json' NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json' @@ -60,6 +63,10 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord end end + def current_show_in_cli_banner_messages + current_banner_messages.select(&:show_in_cli?) + end + def current_notification_messages(current_path: nil, user_access_level: nil) fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do current_and_future_messages.notification @@ -72,13 +79,9 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord end end - def current_and_future_messages - where('ends_at > :now', now: Time.current).order_id_asc - end - def cache ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do - Gitlab::JsonCache.new + Gitlab::Cache::JsonCaches::JsonKeyed.new end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 61585de4ff7..bb1bfe8c889 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -48,6 +48,7 @@ module Ci # Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685 has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id, inverse_of: :job + has_many :job_annotations, class_name: 'Ci::JobAnnotation', foreign_key: :job_id, inverse_of: :job has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :build has_many :pages_deployments, foreign_key: :ci_build_id, inverse_of: :ci_build @@ -259,10 +260,6 @@ module Ci !build.any_unmet_prerequisites? # If false is returned, it stops the transition end - before_transition on: :enqueue do |build| - !build.waiting_for_deployment_approval? # If false is returned, it stops the transition - end - before_transition any => [:pending] do |build| build.ensure_token true @@ -428,11 +425,7 @@ module Ci end def playable? - action? && !archived? && (manual? || scheduled? || retryable?) && !waiting_for_deployment_approval? - end - - def waiting_for_deployment_approval? - manual? && deployment_job? && deployment&.blocked? + action? && !archived? && (manual? || scheduled? || retryable?) end def outdated_deployment? @@ -598,14 +591,6 @@ 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) - - 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) @@ -658,9 +643,8 @@ module Ci def apple_app_store_variables return [] unless apple_app_store_integration.try(:activated?) - return [] unless pipeline.protected_ref? - Gitlab::Ci::Variables::Collection.new(apple_app_store_integration.ci_variables) + Gitlab::Ci::Variables::Collection.new(apple_app_store_integration.ci_variables(protected_ref: pipeline.protected_ref?)) end def google_play_variables @@ -1274,7 +1258,7 @@ module Ci def id_tokens_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| id_tokens.each do |var_name, token_data| - token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['aud']) + token = Gitlab::Ci::JwtV2.for_build(self, aud: expanded_id_token_aud(token_data['aud'])) variables.append(key: var_name, value: token, public: false, masked: true) end @@ -1283,6 +1267,19 @@ module Ci end end + def expanded_id_token_aud(aud) + return unless aud + + strong_memoize_with(:expanded_id_token_aud, aud) do + # `aud` can be a string or an array of strings. + if aud.is_a?(Array) + aud.map { |x| ExpandVariables.expand(x, -> { scoped_variables.sort_and_expand_all }) } + else + ExpandVariables.expand(aud, -> { scoped_variables.sort_and_expand_all }) + end + end + end + def cache_for_online_runners(&block) Rails.cache.fetch( ['has-online-runners', id], diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb index b9e777f27a0..1cb030c67c3 100644 --- a/app/models/ci/catalog/listing.rb +++ b/app/models/ci/catalog/listing.rb @@ -14,16 +14,25 @@ module Ci @current_user = current_user end - def resources - Ci::Catalog::Resource - .joins(:project).includes(:project) - .merge(projects_in_namespace_visible_to_user) + def resources(sort: nil) + case sort.to_s + when 'name_desc' then all_resources.order_by_name_desc + when 'name_asc' then all_resources.order_by_name_asc + else + all_resources.order_by_created_at_desc + end end private attr_reader :namespace, :current_user + def all_resources + Ci::Catalog::Resource + .joins(:project).includes(:project) + .merge(projects_in_namespace_visible_to_user) + end + def projects_in_namespace_visible_to_user Project .in_namespace(namespace.self_and_descendant_ids) diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index bb4584aacae..77cfe91ddd6 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -13,16 +13,21 @@ module Ci belongs_to :project scope :for_projects, ->(project_ids) { where(project_id: project_ids) } + scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } + scope :order_by_name_desc, -> { joins(:project).merge(Project.sorted_by_name_desc) } + scope :order_by_name_asc, -> { joins(:project).merge(Project.sorted_by_name_asc) } - delegate :avatar_path, :description, :name, to: :project + delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project def versions project.releases.order_released_desc end def latest_version - versions.first + project.releases.latest end end end end + +Ci::Catalog::Resource.prepend_mod_with('Ci::Catalog::Resource') diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index f04f0d27e51..5522a01758f 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -23,6 +23,19 @@ module Ci scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } scope :for_groups, ->(group_ids) { where(group_id: group_ids) } + scope :for_environment_scope_like, -> (query) do + top_level = 'LOWER(ci_group_variables.environment_scope) LIKE LOWER(?) || \'%\'' + search_like = "%#{sanitize_sql_like(query)}%" + + where(top_level, search_like) + end + + scope :environment_scope_names, -> do + group(:environment_scope) + .order(:environment_scope) + .pluck(:environment_scope) + end + self.limit_name = 'group_ci_variables' self.limit_scope = :group diff --git a/app/models/ci/job_annotation.rb b/app/models/ci/job_annotation.rb new file mode 100644 index 00000000000..a8bef02cc42 --- /dev/null +++ b/app/models/ci/job_annotation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + class JobAnnotation < Ci::ApplicationRecord + include Ci::Partitionable + + self.table_name = :p_ci_job_annotations + self.primary_key = :id + + belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_annotations + + partitionable scope: :job, partitioned: true + + validates :data, json_schema: { filename: 'ci_job_annotation_data' } + validates :name, presence: true, + length: { maximum: 255 }, + uniqueness: { scope: [:job_id, :partition_id] } + end +end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 766155c6a99..5cd7988837e 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -372,11 +372,11 @@ module Ci file_stored_after_transaction_hooks end - # method overriden in EE + # method overridden in EE def file_stored_after_transaction_hooks end - # method overriden in EE + # method overridden in EE def file_stored_in_transaction_hooks end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index babea831d85..6f2939583e0 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -366,7 +366,6 @@ module Ci project = pipeline&.project next unless project - next unless Feature.enabled?(:pipeline_trigger_merge_status, project) pipeline.run_after_commit do next if pipeline.child? @@ -384,6 +383,10 @@ module Ci scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) } scope :ci_branch_sources, -> { where(source: Enums::Ci::Pipeline.ci_branch_sources.values) } scope :ci_and_parent_sources, -> { where(source: Enums::Ci::Pipeline.ci_and_parent_sources.values) } + scope :ci_and_security_orchestration_sources, -> do + where(source: Enums::Ci::Pipeline.ci_and_security_orchestration_sources.values) + end + scope :for_user, -> (user) { where(user: user) } scope :for_sha, -> (sha) { where(sha: sha) } scope :where_not_sha, -> (sha) { where.not(sha: sha) } @@ -675,32 +678,6 @@ module Ci canceled? && auto_canceled_by_id? end - # Cancel a pipelines cancelable jobs and optionally it's child pipelines cancelable jobs - # retries - # of times to retry if errors - # cascade_to_children - if true cancels all related child pipelines for parent child pipelines - # auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation - # execute_async - if true cancel the children asyncronously - def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true) - Gitlab::AppJsonLogger.info( - event: 'pipeline_cancel_running', - pipeline_id: id, - auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, - cascade_to_children: cascade_to_children, - execute_async: execute_async, - **Gitlab::ApplicationContext.current - ) - - update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id - - cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) - - if cascade_to_children - # cancel any bridges that could spin up new child pipelines - cancel_jobs(bridges_in_self_and_project_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) - cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async) - end - end - # rubocop: disable CodeReuse/ServiceClass def retry_failed(current_user) Ci::RetryPipelineService.new(project, current_user) @@ -1375,42 +1352,6 @@ module Ci private - def cancel_jobs(jobs, retries: 1, auto_canceled_by_pipeline_id: nil) - retry_lock(jobs, retries, name: 'ci_pipeline_cancel_running') do |statuses| - preloaded_relations = [:project, :pipeline, :deployment, :taggings] - - statuses.find_in_batches do |status_batch| - relation = CommitStatus.where(id: status_batch) - Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations) - - relation.each do |job| - job.auto_canceled_by_id = auto_canceled_by_pipeline_id if auto_canceled_by_pipeline_id - job.cancel - end - end - end - end - - # For parent child-pipelines only (not multi-project) - def cancel_children(auto_canceled_by_pipeline_id: nil, execute_async: true) - all_child_pipelines.each do |child_pipeline| - if execute_async - ::Ci::CancelPipelineWorker.perform_async( - child_pipeline.id, - auto_canceled_by_pipeline_id - ) - else - child_pipeline.cancel_running( - # cascade_to_children is false because we iterate through children - # we also cancel bridges prior to prevent more children - cascade_to_children: false, - execute_async: execute_async, - auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id - ) - end - end - end - def add_message(severity, content) messages.build(severity: severity, content: content) end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 7727e94875b..6319163b0d7 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -100,7 +100,10 @@ module Ci scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } scope :with_running_builds, -> do - where('EXISTS(?)', ::Ci::Build.running.select(1).where('ci_builds.runner_id = ci_runners.id')) + where('EXISTS(?)', + ::Ci::Build.running.select(1) + .where("#{::Ci::Build.quoted_table_name}.runner_id = #{quoted_table_name}.id") + ) end # BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb` diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb index 5e273e0fd4b..af04db0a153 100644 --- a/app/models/ci/secure_file.rb +++ b/app/models/ci/secure_file.rb @@ -29,6 +29,7 @@ module Ci scope :order_by_created_at, -> { order(created_at: :desc) } scope :project_id_in, ->(ids) { where(project_id: ids) } + scope :with_files_stored_locally, -> { where(file_store: Ci::SecureFileUploader::Store::LOCAL) } def checksum_algorithm CHECKSUM_ALGORITHM @@ -69,6 +70,10 @@ module Ci end end + def local? + file_store == ObjectStorage::Store::LOCAL + end + private def assign_checksum diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 6980ec1c2d3..372fdfda1ea 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -3,6 +3,7 @@ module Clusters class Agent < ApplicationRecord include FromUnion + include Gitlab::Utils::StrongMemoize self.table_name = 'cluster_agents' @@ -29,6 +30,8 @@ module Clusters has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent + has_many :environments, class_name: '::Environment', inverse_of: :cluster_agent, foreign_key: :cluster_agent_id + scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } scope :has_vulnerabilities, -> (value = true) { where(has_vulnerabilities: value) } @@ -65,10 +68,8 @@ module Clusters return false unless user return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project) - ::Project.from_union( - all_ci_access_authorized_projects_for(user).limit(1), - all_ci_access_authorized_namespaces_for(user).limit(1) - ).exists? + all_ci_access_authorized_projects_for(user).exists? || + all_ci_access_authorized_namespaces_for(user).exists? end def user_access_authorized_for?(user) @@ -93,47 +94,35 @@ module Clusters def all_ci_access_authorized_projects_for(user) ::Project.joins(:ci_access_project_authorizations) .joins(:project_authorizations) + .joins(:namespace) .where(agent_project_authorizations: { agent_id: id }) .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. }) + .where('namespaces.traversal_ids @> ARRAY[?]', root_namespace.id) end def all_ci_access_authorized_namespaces_for(user) - ::Project.with(root_namespace_cte.to_arel) - .with(all_ci_access_authorized_namespaces_cte.to_arel) + ::Project.with(all_ci_access_authorized_namespaces_cte.to_arel) .joins('INNER JOIN all_authorized_namespaces ON all_authorized_namespaces.id = projects.namespace_id') .joins(:project_authorizations) .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. }) end - def root_namespace_cte - Gitlab::SQL::CTE.new(:root_namespace, root_namespace.to_sql) - end - def all_ci_access_authorized_namespaces_cte Gitlab::SQL::CTE.new(:all_authorized_namespaces, all_ci_access_authorized_namespaces.to_sql) end def all_ci_access_authorized_namespaces Namespace.select("traversal_ids[array_length(traversal_ids, 1)] AS id") - .joins("INNER JOIN root_namespace ON " \ - "namespaces.traversal_ids @> ARRAY[root_namespace.root_id]") .joins("INNER JOIN agent_group_authorizations ON " \ "namespaces.traversal_ids @> ARRAY[agent_group_authorizations.group_id::integer]") .where(agent_group_authorizations: { agent_id: id }) + .where('namespaces.traversal_ids @> ARRAY[?]', root_namespace.id) end def root_namespace - Namespace.select("traversal_ids[1] AS root_id") - .where("traversal_ids @> ARRAY(?)", project_namespace) - .limit(1) - end - - def project_namespace - ::Project.select('namespace_id') - .joins(:cluster_agents) - .where(cluster_agents: { id: id }) - .limit(1) + project.root_namespace end + strong_memoize_attr :root_namespace end end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index a2903bba6d2..9cae71809fd 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -23,7 +23,7 @@ module Clusters has_many :projects, through: :cluster_projects, class_name: '::Project' has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project' has_many :deployment_clusters - has_many :deployments, inverse_of: :cluster + has_many :deployments, inverse_of: :cluster, through: :deployment_clusters has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_many :environments, -> { distinct }, through: :deployments diff --git a/app/models/commit.rb b/app/models/commit.rb index 6d17d7f495d..26412205899 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -427,7 +427,7 @@ class Commit end def cherry_pick_message(user) - %Q{#{message}\n\n#{cherry_pick_description(user)}} + %{#{message}\n\n#{cherry_pick_description(user)}} end def revert_description(user) @@ -439,7 +439,7 @@ class Commit end def revert_message(user) - %Q{Revert "#{title.strip}"\n\n#{revert_description(user)}} + %{Revert "#{title.strip}"\n\n#{revert_description(user)}} end def reverts_commit?(commit, user) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 6dfea7ef9a7..f26831c1049 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -317,6 +317,16 @@ class CommitStatus < Ci::ApplicationRecord ci_stage&.name end + # For AiAction + def to_ability_name + 'build' + end + + # For AiAction + def resource_parent + project + end + private def unrecoverable_failure? diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb index 4d464f353ee..9215e15f07d 100644 --- a/app/models/commit_user_mention.rb +++ b/app/models/commit_user_mention.rb @@ -3,7 +3,7 @@ class CommitUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' belongs_to :note end diff --git a/app/models/concerns/admin_changed_password_notifier.rb b/app/models/concerns/admin_changed_password_notifier.rb index f6c2abc7e0f..957f4f6323a 100644 --- a/app/models/concerns/admin_changed_password_notifier.rb +++ b/app/models/concerns/admin_changed_password_notifier.rb @@ -30,12 +30,10 @@ module AdminChangedPasswordNotifier extend ActiveSupport::Concern included do - after_update :send_admin_changed_your_password_notification, if: :send_admin_changed_your_password_notification? - end + # default value of this attribute is `nil`, so these emails are off by default + attr_accessor :allow_admin_changed_your_password_notification - def initialize(*args, &block) - @allow_admin_changed_your_password_notification = false # These emails are off by default - super + after_update :send_admin_changed_your_password_notification, if: :send_admin_changed_your_password_notification? end def send_only_admin_changed_your_password_notification! @@ -50,11 +48,11 @@ module AdminChangedPasswordNotifier end def allow_admin_changed_your_password_notification! - @allow_admin_changed_your_password_notification = true # rubocop:disable Gitlab/ModuleWithInstanceVariables + self.allow_admin_changed_your_password_notification = true end def send_admin_changed_your_password_notification? self.class.send_password_change_notification && saved_change_to_encrypted_password? && - @allow_admin_changed_your_password_notification # rubocop:disable Gitlab/ModuleWithInstanceVariables + allow_admin_changed_your_password_notification end 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 c01399184ad..d268c32c088 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -5,6 +5,8 @@ module Analytics extend ActiveSupport::Concern included do + include FromUnion + scope :by_stage_event_hash_id, ->(id) { where(stage_event_hash_id: id) } scope :by_project_id, ->(id) { where(project_id: id) } scope :by_group_id, ->(id) { where(group_id: id) } @@ -20,7 +22,7 @@ module Analytics # start_event_timestamp must be included in the ORDER BY clause for the duration # calculation to work: SELECT end_event_timestamp - start_event_timestamp keyset_order( - :end_event_timestamp => { order_expression: arel_order(arel_table[:end_event_timestamp], direction), distinct: false }, + :end_event_timestamp => { order_expression: arel_order(arel_table[:end_event_timestamp], direction), distinct: false, nullable: direction == :asc ? :nulls_last : :nulls_first }, issuable_id_column => { order_expression: arel_order(arel_table[issuable_id_column], direction), distinct: true }, :start_event_timestamp => { order_expression: arel_order(arel_table[:start_event_timestamp], direction), distinct: false } ) diff --git a/app/models/concerns/application_setting_masked_attrs.rb b/app/models/concerns/application_setting_masked_attrs.rb new file mode 100644 index 00000000000..14a7185e39e --- /dev/null +++ b/app/models/concerns/application_setting_masked_attrs.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Similar to MASK_PASSWORD mechanism we do for EE, see: +# https://gitlab.com/gitlab-org/gitlab/-/blob/463bb1f855d71fadef931bd50f1692ee04f211a8/ee/app/models/ee/application_setting.rb#L15 +# but for non-EE attributes. +module ApplicationSettingMaskedAttrs + MASK = '*****' + + def ai_access_token=(value) + return if value == MASK + + super + end +end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 1d0ce594f63..e830594af11 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -4,7 +4,7 @@ module Awardable extend ActiveSupport::Concern included do - has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, inverse_of: :awardable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent if self < Participable # By default we always load award_emoji user association diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index d8417773dbd..a3bcc7bcbbc 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -31,6 +31,7 @@ module Ci Ci::BuildTraceChunk Ci::BuildTraceMetadata Ci::BuildPendingState + Ci::JobAnnotation Ci::JobArtifact Ci::JobVariable Ci::Pipeline diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index 7a6076c7d2e..b10b318fb7c 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -4,7 +4,7 @@ module DiffPositionableNote included do before_validation :set_original_position, on: :create - before_validation :update_position, on: :create, if: :on_text?, unless: :importing? + before_validation :update_position, on: :create, if: :should_update_position?, unless: :importing? serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize @@ -37,10 +37,18 @@ module DiffPositionableNote end end + def should_update_position? + on_text? || on_file? + end + def on_text? !!position&.on_text? end + def on_file? + !!position&.on_file? + end + def on_image? !!position&.on_image? end diff --git a/app/models/concerns/enums/abuse/category.rb b/app/models/concerns/enums/abuse/category.rb new file mode 100644 index 00000000000..e024ed17e32 --- /dev/null +++ b/app/models/concerns/enums/abuse/category.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Enums + module Abuse + module Category + def self.categories + { + spam: 0, # spamcheck + virus: 1, # VirusTotal + fraud: 2, # Arkos, Telesign + ci_cd: 3 # PVS + } + end + end + end +end diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index 778471eac8b..d798a13741f 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -69,6 +69,10 @@ module Enums ci_sources.merge(sources.slice(:parent_pipeline)) end + def self.ci_and_security_orchestration_sources + ci_sources.merge(sources.slice(:security_orchestration_policy)) + end + # Returns the `Hash` to use for creating the `config_sources` enum for # `Ci::Pipeline`. def self.config_sources diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 468ea26c51a..9d4b8328e8d 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -4,7 +4,6 @@ module HasUserType extend ActiveSupport::Concern USER_TYPES = { - human_deprecated: nil, human: 0, support_bot: 1, alert_bot: 2, @@ -39,25 +38,20 @@ module HasUserType # `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 human_deprecated project_bot service_user service_account].freeze + NON_INTERNAL_USER_TYPES = %w[human project_bot service_user service_account].freeze INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze included do 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)) } - scope :without_ghosts, -> { humans.or(where(user_type: USER_TYPES.keys - ['ghost'])) } - 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)) } + scope :without_bots, -> { where(user_type: USER_TYPES.keys - BOT_USER_TYPES) } + scope :non_internal, -> { where(user_type: NON_INTERNAL_USER_TYPES) } + scope :without_ghosts, -> { where(user_type: USER_TYPES.keys - ['ghost']) } + scope :without_project_bot, -> { where(user_type: USER_TYPES.keys - ['project_bot']) } + scope :human_or_service_user, -> { where(user_type: %i[human service_user]) } - def human? - super || human_deprecated? || user_type.nil? - end + validates :user_type, presence: true end def bot? diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 8926e805d8d..9a513ea0e5b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -659,6 +659,7 @@ module Issuable def read_ability_for(participable_source) return super if participable_source == self + return super if participable_source.is_a?(Note) && participable_source.system? name = participable_source.try(:issuable_ability_name) || :read_issuable_participables diff --git a/app/models/concerns/issues/forbid_issue_type_column_usage.rb b/app/models/concerns/issues/forbid_issue_type_column_usage.rb new file mode 100644 index 00000000000..46a8a0278d9 --- /dev/null +++ b/app/models/concerns/issues/forbid_issue_type_column_usage.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699 +module Issues + module ForbidIssueTypeColumnUsage + extend ActiveSupport::Concern + + ForbiddenColumnUsed = Class.new(StandardError) + + included do + WorkItems::Type.base_types.each do |base_type, _value| + define_method "#{base_type}?".to_sym do + error_message = <<~ERROR + `#{model_name.element}.#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column, + its usage is forbidden. You should use the `work_item_types` table instead. + + # Before + + #{model_name.element}.#{base_type}? => true + + # After + + #{model_name.element}.work_item_type.#{base_type}? => true + + More details in https://gitlab.com/groups/gitlab-org/-/epics/10529 + ERROR + + raise ForbiddenColumnUsed, error_message + end + + define_singleton_method base_type.to_sym do + error = ForbiddenColumnUsed.new( + <<~ERROR + `#{name}.#{base_type}` uses the `issue_type` column underneath. As we want to remove the column, + its usage is forbidden. You should use the `work_item_types` table instead. + + # Before + + #{name}.#{base_type} + + # After + + #{name}.with_issue_type(:#{base_type}) + + More details in https://gitlab.com/groups/gitlab-org/-/epics/10529 + ERROR + ) + + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + error, + method_name: "#{name}.#{base_type}" + ) + + with_issue_type(base_type.to_sym) + end + end + end + end +end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 65e7f734233..5c91f2460c4 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -169,7 +169,9 @@ module Noteable def expire_note_etag_cache return unless discussions_rendered_on_frontend? return unless etag_caching_enabled? - return unless project.present? + + # TODO: We need to figure out a way to make ETag caching work for group-level work items + return if is_a?(Issue) && project.nil? Gitlab::EtagCaching::Store.new.touch(note_etag_key) end diff --git a/app/models/concerns/packages/downloadable.rb b/app/models/concerns/packages/downloadable.rb new file mode 100644 index 00000000000..011f5ddda9c --- /dev/null +++ b/app/models/concerns/packages/downloadable.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Packages + module Downloadable + extend ActiveSupport::Concern + + def touch_last_downloaded_at + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do + update_column(:last_downloaded_at, Time.zone.now) + end + end + end +end + +Packages::Downloadable.prepend_mod diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index b910c0ab5c2..76c733b1c0b 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -114,6 +114,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:infrastructure_access_level, value) end + def model_experiments_access_level=(value) + write_feature_attribute_string(:model_experiments_access_level, value) + end + # TODO: Remove this method after we drop support for project create/edit APIs to set the # container_registry_enabled attribute. They can instead set the container_registry_access_level # attribute. diff --git a/app/models/concerns/recoverable_by_any_email.rb b/app/models/concerns/recoverable_by_any_email.rb new file mode 100644 index 00000000000..c946e7e78c6 --- /dev/null +++ b/app/models/concerns/recoverable_by_any_email.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Concern that overrides the Devise methods +# to send reset password instructions to any verified user email +module RecoverableByAnyEmail + extend ActiveSupport::Concern + + class_methods do + def send_reset_password_instructions(attributes = {}) + email = attributes.delete(:email) + super unless email + + recoverable = by_email_with_errors(email) + recoverable.send_reset_password_instructions(to: email) if recoverable&.persisted? + recoverable + end + + private + + def by_email_with_errors(email) + record = find_by_any_email(email, confirmed: true) || new + record.errors.add(:email, :invalid) unless record.persisted? + record + end + end + + def send_reset_password_instructions(opts = {}) + token = set_reset_password_token + send_reset_password_instructions_notification(token, opts) + + token + end + + private + + def send_reset_password_instructions_notification(token, opts = {}) + send_devise_notification(:reset_password_instructions, token, opts) + end +end diff --git a/app/models/concerns/sanitizable.rb b/app/models/concerns/sanitizable.rb index 653d7a4875d..d05ce389ebf 100644 --- a/app/models/concerns/sanitizable.rb +++ b/app/models/concerns/sanitizable.rb @@ -48,11 +48,11 @@ module Sanitizable # This method raises an exception on failure so perform this # last if multiple errors should be returned. - Gitlab::Utils.check_path_traversal!(input.to_s) + Gitlab::PathTraversal.check_path_traversal!(input.to_s) rescue Gitlab::Utils::DoubleEncodingError record.errors.add(attr, 'cannot contain escaped components') - rescue Gitlab::Utils::PathTraversalAttackError + rescue Gitlab::PathTraversal::PathTraversalAttackError record.errors.add(attr, "cannot contain a path traversal component") end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index fba923e843a..6550c5a94a0 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -2,6 +2,7 @@ module Spammable extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize class_methods do def attr_spammable(attr, options = {}) @@ -46,14 +47,23 @@ module Spammable end def needs_recaptcha! - self.needs_recaptcha = true + if self.supports_recaptcha? + self.needs_recaptcha = true + else + self.spam! + end + end + + # Override in Spammable if recaptcha is supported + def supports_recaptcha? + false end ## # Indicates if a recaptcha should be rendered before allowing this model to be saved. # def render_recaptcha? - return false unless Gitlab::Recaptcha.enabled? + return false unless Gitlab::Recaptcha.enabled? && supports_recaptcha? return false if self.errors.count > 1 # captcha should not be rendered if are still other errors @@ -70,7 +80,7 @@ module Spammable end def invalidate_if_spam - if needs_recaptcha? && Gitlab::Recaptcha.enabled? + if needs_recaptcha? && Gitlab::Recaptcha.enabled? && supports_recaptcha? recaptcha_error! elsif needs_recaptcha? || spam? unrecoverable_spam_error! @@ -84,12 +94,24 @@ module Spammable end def unrecoverable_spam_error! - self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam and has been discarded.") \ + self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam. "\ + "Please, change the content to proceed.") \ % { spammable_entity_type: spammable_entity_type }) end def spammable_entity_type - self.class.name.underscore + case self + when Issue + _('issue') + when MergeRequest + _('merge request') + when Note + _('comment') + when Snippet + _('snippet') + else + self.class.model_name.human.downcase + end end def spam_title @@ -117,8 +139,18 @@ module Spammable end # Override in Spammable if further checks are necessary - def check_for_spam?(user:) - true + def check_for_spam?(*) + spammable_attribute_changed? + end + + def spammable_attribute_changed? + (changed & self.class.spammable_attrs.to_h.keys).any? + end + + def check_for_spam(user:, action:, extra_features: {}) + strong_memoize_with(:check_for_spam, user, action, extra_features) do + Spam::SpamActionService.new(spammable: self, user: user, action: action, extra_features: extra_features).execute + end end # Override in Spammable if differs diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index e418842a30b..b73ed937b5d 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -99,7 +99,7 @@ module Storage Gitlab::GitalyClient::NamespaceService.allow do if gitlab_shell.mv_namespace(repository_storage, full_path, new_path) - Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}") + Gitlab::AppLogger.info %(Namespace directory "#{full_path}" moved to "#{new_path}") # Remove namespace directory async with delay so # GitLab has time to remove all projects first diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index ef31bedc3a8..f9fa4bd212c 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -10,15 +10,19 @@ class DeployKey < Key has_many :projects, through: :deploy_keys_projects has_many :deploy_keys_projects_with_write_access, -> { with_write_access }, class_name: "DeployKeysProject", inverse_of: :deploy_key + has_many :deploy_keys_projects_with_readonly_access, -> { with_readonly_access }, class_name: "DeployKeysProject", inverse_of: :deploy_key has_many :projects_with_write_access, -> { includes(:route) }, class_name: 'Project', through: :deploy_keys_projects_with_write_access, source: :project + has_many :projects_with_readonly_access, -> { includes(:route) }, class_name: 'Project', through: :deploy_keys_projects_with_readonly_access, source: :project has_many :protected_branch_push_access_levels, class_name: '::ProtectedBranch::PushAccessLevel', inverse_of: :deploy_key has_many :protected_tag_create_access_levels, class_name: '::ProtectedTag::CreateAccessLevel', inverse_of: :deploy_key scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where(deploy_keys_projects: { project_id: projects }) } scope :with_write_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_write_access) } + scope :with_readonly_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_readonly_access) } scope :are_public, -> { where(public: true) } scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) } scope :including_projects_with_write_access, -> { includes(:projects_with_write_access) } + scope :including_projects_with_readonly_access, -> { includes(:projects_with_readonly_access) } accepts_nested_attributes_for :deploy_keys_projects, reject_if: :reject_deploy_keys_projects? diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb index 363ef0b1c9a..e114b7297eb 100644 --- a/app/models/deploy_keys_project.rb +++ b/app/models/deploy_keys_project.rb @@ -5,6 +5,7 @@ class DeployKeysProject < ApplicationRecord belongs_to :deploy_key, inverse_of: :deploy_keys_projects scope :in_project, ->(project) { where(project: project) } scope :with_write_access, -> { where(can_push: true) } + scope :with_readonly_access, -> { where(can_push: false) } accepts_nested_attributes_for :deploy_key diff --git a/app/models/deployment.rb b/app/models/deployment.rb index f3ee21ea4e0..1e3a80087c8 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -16,7 +16,6 @@ class Deployment < ApplicationRecord belongs_to :project, optional: false belongs_to :environment, optional: false - belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user belongs_to :deployable, polymorphic: true, optional: true, inverse_of: :deployment # rubocop:disable Cop/PolymorphicAssociations has_many :deployment_merge_requests @@ -35,6 +34,7 @@ class Deployment < ApplicationRecord delegate :name, to: :environment, prefix: true delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true + delegate :cluster, to: :deployment_cluster, allow_nil: true scope :for_iid, -> (project, iid) { where(project: project, iid: iid) } scope :for_environment, -> (environment) { where(environment_id: environment) } diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb index 33c5dc15fa4..39077fdbcb1 100644 --- a/app/models/design_management/repository.rb +++ b/app/models/design_management/repository.rb @@ -34,3 +34,5 @@ module DesignManagement end end end + +DesignManagement::Repository.prepend_mod_with('DesignManagement::Repository') diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb index 87899f65cb1..7d0cd72e9eb 100644 --- a/app/models/design_user_mention.rb +++ b/app/models/design_user_mention.rb @@ -3,7 +3,7 @@ class DesignUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' belongs_to :design, class_name: 'DesignManagement::Design' belongs_to :note diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index e2ee951522d..c4ccb9ef4f5 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -37,8 +37,8 @@ class DiffDiscussion < Discussion def reply_attributes super.merge( - original_position: Gitlab::Json.dump(original_position), - position: Gitlab::Json.dump(position) + original_position: Gitlab::Json.dump(original_position.to_h), + position: Gitlab::Json.dump(position.to_h) ) end diff --git a/app/models/diff_note_position.rb b/app/models/diff_note_position.rb index a25b0def643..5e9f0100e62 100644 --- a/app/models/diff_note_position.rb +++ b/app/models/diff_note_position.rb @@ -43,7 +43,6 @@ class DiffNotePosition < ApplicationRecord def self.position_to_attrs(position) position_attrs = position.to_h position_attrs[:diff_content_type] = position_attrs.delete(:position_type) - position_attrs.delete(:line_range) - position_attrs + position_attrs.except(:line_range, :ignore_whitespace_change) end end diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 05552e83700..31118791075 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -88,7 +88,7 @@ module DiffViewer { viewer: switcher_title, reason: render_error_reason, - options: Gitlab::Utils.to_exclusive_sentence(render_error_options) + options: Gitlab::Sentence.to_exclusive_sentence(render_error_options) } end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 83c85f30178..dc4794ed3cd 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -13,23 +13,23 @@ class Discussion attr_reader :context_noteable attr_accessor :notes - delegate :created_at, - :project, - :author, - :noteable, - :commit_id, - :confidential?, - :for_commit?, - :for_design?, - :for_merge_request?, - :noteable_ability_name, - :to_ability_name, - :editable?, - :resolved_by_id, - :system_note_visible_for?, - :resource_parent, - :save, - to: :first_note + delegate :created_at, + :project, + :author, + :noteable, + :commit_id, + :confidential?, + :for_commit?, + :for_design?, + :for_merge_request?, + :noteable_ability_name, + :to_ability_name, + :editable?, + :resolved_by_id, + :system_note_visible_for?, + :resource_parent, + :save, + to: :first_note def declarative_policy_delegate first_note diff --git a/app/models/environment.rb b/app/models/environment.rb index f1de41674c6..8480272eced 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -15,6 +15,7 @@ class Environment < ApplicationRecord belongs_to :project, optional: false belongs_to :merge_request, optional: true + belongs_to :cluster_agent, class_name: 'Clusters::Agent', optional: true, inverse_of: :environments use_fast_destroy :all_deployments nullify_if_blank :external_url @@ -35,12 +36,12 @@ class Environment < ApplicationRecord Deployment::FINISHED_STATUSES.each do |status| has_one :"last_#{status}_deployment", -> { where(status: status).ordered }, - class_name: 'Deployment', inverse_of: :environment + class_name: 'Deployment', inverse_of: :environment end Deployment::UPCOMING_STATUSES.each do |status| has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming }, - class_name: 'Deployment', inverse_of: :environment + class_name: 'Deployment', inverse_of: :environment end has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment @@ -52,22 +53,22 @@ class Environment < ApplicationRecord after_save :clear_reactive_cache! validates :name, - presence: true, - uniqueness: { scope: :project_id }, - length: { maximum: 255 }, - format: { with: Gitlab::Regex.environment_name_regex, - message: Gitlab::Regex.environment_name_regex_message } + presence: true, + uniqueness: { scope: :project_id }, + length: { maximum: 255 }, + format: { with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } validates :slug, - presence: true, - uniqueness: { scope: :project_id }, - length: { maximum: 24 }, - format: { with: Gitlab::Regex.environment_slug_regex, - message: Gitlab::Regex.environment_slug_regex_message } + presence: true, + uniqueness: { scope: :project_id }, + length: { maximum: 24 }, + format: { with: Gitlab::Regex.environment_slug_regex, + message: Gitlab::Regex.environment_slug_regex_message } validates :external_url, - length: { maximum: 255 }, - allow_nil: true + length: { maximum: 255 }, + allow_nil: true # Currently, the tier presence is validaed for newly created environments. # After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`. @@ -236,8 +237,7 @@ class Environment < ApplicationRecord def self.nested group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)') - .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS name', - 'COUNT(*) AS size', 'MAX(id) AS last_id') + .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS name', 'COUNT(*) AS size', 'MAX(id) AS last_id') .order('name ASC') end diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb index b02074849a1..f795585dfc5 100644 --- a/app/models/generic_commit_status.rb +++ b/app/models/generic_commit_status.rb @@ -3,9 +3,7 @@ class GenericCommitStatus < CommitStatus EXTERNAL_STAGE_IDX = 1_000_000 - validates :target_url, addressable_url: true, - length: { maximum: 255 }, - allow_nil: true + validates :target_url, addressable_url: true, length: { maximum: 255 }, allow_nil: true validate :name_uniqueness_across_types, unless: :importing? # GitHub compatible API diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb index 71abfd3f6da..37e69102521 100644 --- a/app/models/grafana_integration.rb +++ b/app/models/grafana_integration.rb @@ -11,8 +11,8 @@ class GrafanaIntegration < ApplicationRecord before_validation :check_token_changes validates :grafana_url, - length: { maximum: 1024 }, - addressable_url: { enforce_sanitization: true, ascii_only: true } + length: { maximum: 1024 }, + addressable_url: { enforce_sanitization: true, ascii_only: true } validates :encrypted_token, :project, presence: true diff --git a/app/models/group.rb b/app/models/group.rb index ab8e0101684..85971c48567 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -38,7 +38,7 @@ class Group < Namespace has_many :users, through: :group_members has_many :owners, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, - through: :group_members, + through: :all_group_members, source: :user has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent @@ -92,7 +92,7 @@ class Group < Namespace has_many :badges, class_name: 'GroupBadge' # AR defaults to nullify when trying to delete via has_many associations unless we set dependent: :delete_all - has_many :organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :crm_organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :contacts, class_name: 'CustomerRelations::Contact', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :cluster_groups, class_name: 'Clusters::Group' @@ -152,17 +152,19 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } validates :name, - html_safety: true, - format: { with: Gitlab::Regex.group_name_regex, - message: Gitlab::Regex.group_name_regex_message }, - if: :name_changed? + html_safety: true, + format: { + with: Gitlab::Regex.group_name_regex, + message: Gitlab::Regex.group_name_regex_message + }, + if: :name_changed? validates :group_feature, presence: true add_authentication_token_field :runners_token, - encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption) ? :optional : :required }, - format_with_prefix: :runners_token_prefix, - require_prefix_for_validation: true + encrypted: :required, + format_with_prefix: :runners_token_prefix, + require_prefix_for_validation: true after_create :post_create_hook after_create -> { create_or_load_association(:group_feature) } @@ -187,6 +189,8 @@ class Group < Namespace Group.from_union([by_id(ids), by_id(ids_by_full_path), where('LOWER(path) IN (?)', paths.map(&:downcase))]) end + scope :excluding_groups, ->(groups) { where.not(id: groups) } + scope :for_authorized_group_members, -> (user_ids) do joins(:group_members) .where(members: { user_id: user_ids }) @@ -469,7 +473,7 @@ class Group < Namespace def has_owner?(user) return false unless user - members_with_parents.owners.exists?(user_id: user) + members_with_parents.all_owners.exists?(user_id: user) end def blocked_owners @@ -490,35 +494,23 @@ class Group < Namespace # Excludes non-direct owners for top-level group # Excludes project_bots def last_owner?(user) - has_owner?(user) && member_owners_excluding_project_bots.size == 1 - end + return false unless user - def member_last_owner?(member) - return member.last_owner unless member.last_owner.nil? + all_owners = member_owners_excluding_project_bots - last_owner?(member.user) + all_owners.size == 1 && all_owners.first.user_id == user.id end # Excludes non-direct owners for top-level group # Excludes project_bots def member_owners_excluding_project_bots - if root? - members - else - members_with_parents - end.owners.merge(User.without_project_bot) - end + members_from_hiearchy = if root? + members.non_minimal_access.without_invites_and_requests + else + members_with_parents(only_active_users: false) + end - def single_blocked_owner? - blocked_owners.size == 1 - end - - def member_last_blocked_owner?(member) - return member.last_blocked_owner unless member.last_blocked_owner.nil? - - return false if member_owners_excluding_project_bots.any? - - single_blocked_owner? && blocked_owners.exists?(user_id: member.user) + members_from_hiearchy.all_owners.left_outer_joins(:user).merge(User.without_project_bot) end def ldap_synced? @@ -606,7 +598,7 @@ class Group < Namespace members_from_self_and_ancestor_group_shares]).authorizable end - def members_with_parents + def members_with_parents(only_active_users: true) # Avoids an unnecessary SELECT when the group has no parents source_ids = if has_parent? @@ -615,11 +607,16 @@ class Group < Namespace id end - group_hierarchy_members = GroupMember.active_without_invites_and_requests - .non_minimal_access + group_hierarchy_members = GroupMember.non_minimal_access .where(source_id: source_ids) .select(*GroupMember.cached_column_list) + group_hierarchy_members = if only_active_users + group_hierarchy_members.active_without_invites_and_requests + else + group_hierarchy_members.without_invites_and_requests + end + GroupMember.from_union([group_hierarchy_members, members_from_self_and_ancestor_group_shares]) end @@ -972,9 +969,11 @@ class Group < Namespace end def max_member_access(user_ids) - Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(User), - resource_ids: user_ids, - default_value: Gitlab::Access::NO_ACCESS) do |user_ids| + Gitlab::SafeRequestLoader.execute( + resource_key: max_member_access_for_resource_key(User), + resource_ids: user_ids, + default_value: Gitlab::Access::NO_ACCESS + ) do |user_ids| members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level) end end @@ -1035,8 +1034,7 @@ class Group < Namespace # the respective group_group_links.group_access. member_columns = GroupMember.attribute_names.map do |column_name| if column_name == 'access_level' - smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], - 'access_level') + smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], 'access_level') else group_member_table[column_name] end diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index fdb8fb9ed75..dba52aa51cd 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -10,8 +10,7 @@ class GroupGroupLink < ApplicationRecord validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id], message: N_('The group has already been shared with this group') } validates :shared_with_group, presence: true - validates :group_access, inclusion: { in: Gitlab::Access.all_values }, - presence: true + validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) } diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 5ccbc926a71..6dc1c9f290a 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -9,23 +9,23 @@ class WebHook < ApplicationRecord SECRET_MASK = '************' attr_encrypted :token, - mode: :per_attribute_iv, - algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32 attr_encrypted :url, - mode: :per_attribute_iv, - algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32 attr_encrypted :url_variables, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm', - marshal: true, - marshaler: ::Gitlab::Json, - encode: false, - encode_iv: false + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + marshal: true, + marshaler: ::Gitlab::Json, + encode: false, + encode_iv: false has_many :web_hook_logs diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb index e5b27009115..36658513275 100644 --- a/app/models/import_failure.rb +++ b/app/models/import_failure.rb @@ -3,9 +3,11 @@ class ImportFailure < ApplicationRecord belongs_to :project belongs_to :group + belongs_to :user - validates :project, presence: true, unless: :group - validates :group, presence: true, unless: :project + validates :project, :group, absence: true, if: :user + validates :project, :user, absence: true, if: :group + validates :group, :user, absence: true, if: :project validates :external_identifiers, json_schema: { filename: "import_failure_external_identifiers" } scope :with_external_identifiers, -> { where.not(external_identifiers: {}) } diff --git a/app/models/integration.rb b/app/models/integration.rb index 860739fe5aa..f2f242136ab 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -18,17 +18,17 @@ class Integration < ApplicationRecord self.inheritance_column = :type_new INTEGRATION_NAMES = %w[ - asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord + asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker datadog discord drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email - pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity + pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram unify_circuit webex_teams youtrack zentao ].freeze # TODO Shimo is temporary disabled on group and instance-levels. # See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677 PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ - apple_app_store google_play jenkins shimo + apple_app_store gitlab_slack_application google_play jenkins shimo ].freeze # Fake integrations to help with local development. @@ -55,13 +55,13 @@ class Integration < ApplicationRecord SNOWPLOW_EVENT_LABEL = 'redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly' attr_encrypted :properties, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm', - marshal: true, - marshaler: ::Gitlab::Json, - encode: false, - encode_iv: false + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + marshal: true, + marshaler: ::Gitlab::Json, + encode: false, + encode_iv: false # Handle assignment of props with symbol keys. # To do this correctly, we need to call the method generated by attr_encrypted. @@ -81,6 +81,7 @@ class Integration < ApplicationRecord attribute :commit_events, default: true attribute :confidential_issues_events, default: true attribute :confidential_note_events, default: true + attribute :deployment_events, default: false attribute :issues_events, default: true attribute :job_events, default: true attribute :merge_requests_events, default: true @@ -282,7 +283,6 @@ class Integration < ApplicationRecord # Returns a list of available integration names. # Example: ["asana", ...] - # @deprecated def self.available_integration_names(include_project_specific: true, include_dev: true) names = integration_names names += project_specific_integration_names if include_project_specific @@ -302,7 +302,9 @@ class Integration < ApplicationRecord end def self.project_specific_integration_names - PROJECT_SPECIFIC_INTEGRATION_NAMES + names = PROJECT_SPECIFIC_INTEGRATION_NAMES.dup + names.delete('gitlab_slack_application') unless Gitlab::CurrentSettings.slack_app_enabled || Gitlab.dev_or_test_env? + names end # Returns a list of available integration types. diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index 5e502cce927..a4036a82cec 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -15,23 +15,28 @@ module Integrations validates :app_store_key_id, presence: true, format: { with: KEY_ID_REGEX } validates :app_store_private_key, presence: true, certificate_key: true validates :app_store_private_key_file_name, presence: true + validates :app_store_protected_refs, inclusion: [true, false] end field :app_store_issuer_id, - section: SECTION_TYPE_CONNECTION, - required: true, - title: -> { s_('AppleAppStore|The Apple App Store Connect Issuer ID.') } + section: SECTION_TYPE_CONNECTION, + required: true, + title: -> { s_('AppleAppStore|The Apple App Store Connect Issuer ID.') } field :app_store_key_id, - section: SECTION_TYPE_CONNECTION, - required: true, - title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') } - - field :app_store_private_key_file_name, - section: SECTION_TYPE_CONNECTION + section: SECTION_TYPE_CONNECTION, + required: true, + title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') } + field :app_store_private_key_file_name, section: SECTION_TYPE_CONNECTION field :app_store_private_key, api_only: true + field :app_store_protected_refs, + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('AppleAppStore|Protected branches and tags only') }, + checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') } + def title 'Apple App Store Connect' end @@ -87,8 +92,9 @@ module Integrations end end - def ci_variables + def ci_variables(protected_ref:) return [] unless activated? + return [] if app_store_protected_refs && !protected_ref [ { key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', value: app_store_issuer_id, masked: true, public: false }, @@ -100,6 +106,11 @@ module Integrations ] end + def initialize_properties + super + self.app_store_protected_refs = true if app_store_protected_refs.nil? + end + private def client diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 963ba918089..4477f3d207f 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -35,9 +35,9 @@ module Integrations boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :webhook, - presence: true, - public_url: true, - if: -> (integration) { integration.activated? && integration.requires_webhook? } + presence: true, + public_url: true, + if: -> (integration) { integration.activated? && integration.requires_webhook? } validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true, if: :activated? validate :validate_channel_limit, if: :activated? diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb index 60a3105d1c0..b17e28bb6c6 100644 --- a/app/models/integrations/chat_message/push_message.rb +++ b/app/models/integrations/chat_message/push_message.rb @@ -82,12 +82,12 @@ module Integrations if ref_type == 'tag' "#{project_url}/-/tags/#{ref}" else - "#{project_url}/commits/#{ref}" + "#{project_url}/-/commits/#{ref}" end end def compare_url - "#{project_url}/compare/#{before}...#{after}" + "#{project_url}/-/compare/#{before}...#{after}" end def ref_link diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb new file mode 100644 index 00000000000..7cc05d41e14 --- /dev/null +++ b/app/models/integrations/clickup.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + class Clickup < BaseIssueTracker + include HasIssueTrackerFields + + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? + + def reference_pattern(*) + @reference_pattern ||= /((#|CU-)(?<issue>[a-z0-9]+)|(?<issue>[A-Z0-9_]{2,10}-\d+))\b/ + end + + def title + 'ClickUp' + end + + def description + s_("IssueTracker|Use Clickup as this project's issue tracker.") + end + + def help + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), + Rails.application.routes.url_helpers.help_page_url('user/project/integrations/clickup'), + target: '_blank', + rel: 'noopener noreferrer' + format(s_( + "IssueTracker|Use ClickUp as this project's issue tracker. %{docs_link}" + ).html_safe, docs_link: docs_link.html_safe) + end + + def self.to_param + 'clickup' + end + + def fields + super.select { _1.name.in?(%w[project_url issues_url]) } + end + end +end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index 3b3c7d8f2cd..c7306209174 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -40,7 +40,7 @@ module Integrations ERB::Util.html_escape( s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') ) % { - linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, + linkOpen: %{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, linkClose: '</a>'.html_safe } end, diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index c903e8d9eb8..ad82f1b916f 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -58,9 +58,9 @@ module Integrations when Integrations::ChatMessage::NoteMessage message.target when Integrations::ChatMessage::IssueMessage - "issue #{Issue.reference_prefix}#{message.issue_iid}" + "issue #{message.project_name}#{Issue.reference_prefix}#{message.issue_iid}" when Integrations::ChatMessage::MergeMessage - "merge request #{MergeRequest.reference_prefix}#{message.merge_request_iid}" + "merge request #{message.project_name}#{MergeRequest.reference_prefix}#{message.merge_request_iid}" when Integrations::ChatMessage::PushMessage "push #{message.project_name}_#{message.ref}" when Integrations::ChatMessage::PipelineMessage diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 2520d3bfc9c..4e0c2dde13b 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -14,6 +14,14 @@ module Integrations ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze + API_ENDPOINTS = { + find_issue: "/rest/api/2/issue/%s", + server_info: "/rest/api/2/serverInfo", + transition_issue: "/rest/api/2/issue/%s/transitions", + issue_comments: "/rest/api/2/issue/%s/comment", + link_remote_issue: "/rest/api/2/issue/%s/remotelink" + }.freeze + SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger' SECTION_TYPE_JIRA_ISSUES = 'jira_issues' @@ -32,11 +40,11 @@ module Integrations validate :validate_jira_cloud_auth_type_is_basic, if: :activated? validates :jira_issue_transition_id, - format: { - with: Gitlab::Regex.jira_transition_id_regex, - message: ->(*_) { s_("JiraService|IDs must be a list of numbers that can be split with , or ;") } - }, - allow_blank: true + format: { + with: Gitlab::Regex.jira_transition_id_regex, + message: ->(*_) { s_("JiraService|IDs must be a list of numbers that can be split with , or ;") } + }, + allow_blank: true # Jira Cloud version is deprecating authentication via username and password. # We should use username/password for Jira Server and email/api_token for Jira Cloud, @@ -52,57 +60,57 @@ module Integrations self.field_storage = :data_fields field :url, - section: SECTION_TYPE_CONNECTION, - required: true, - title: -> { s_('JiraService|Web URL') }, - help: -> { s_('JiraService|Base URL of the Jira instance') }, - placeholder: 'https://jira.example.com', - exposes_secrets: true + section: SECTION_TYPE_CONNECTION, + required: true, + title: -> { s_('JiraService|Web URL') }, + help: -> { s_('JiraService|Base URL of the Jira instance') }, + placeholder: 'https://jira.example.com', + exposes_secrets: true field :api_url, - section: SECTION_TYPE_CONNECTION, - title: -> { s_('JiraService|Jira API URL') }, - help: -> { s_('JiraService|If different from the Web URL') }, - exposes_secrets: true + section: SECTION_TYPE_CONNECTION, + title: -> { s_('JiraService|Jira API URL') }, + help: -> { s_('JiraService|If different from the Web URL') }, + exposes_secrets: true field :jira_auth_type, - type: 'select', - required: true, - section: SECTION_TYPE_CONNECTION, - title: -> { s_('JiraService|Authentication type') }, - choices: -> { - [ - [s_('JiraService|Basic'), AUTH_TYPE_BASIC], - [s_('JiraService|Jira personal access token (Jira Data Center and Jira Server only)'), AUTH_TYPE_PAT] - ] - } + type: 'select', + required: true, + section: SECTION_TYPE_CONNECTION, + title: -> { s_('JiraService|Authentication type') }, + choices: -> { + [ + [s_('JiraService|Basic'), AUTH_TYPE_BASIC], + [s_('JiraService|Jira personal access token (Jira Data Center and Jira Server only)'), AUTH_TYPE_PAT] + ] + } field :username, - section: SECTION_TYPE_CONNECTION, - required: false, - title: -> { s_('JiraService|Email or username') }, - help: -> { s_('JiraService|Only required for Basic authentication. Email for Jira Cloud or username for Jira Data Center and Jira Server') } + section: SECTION_TYPE_CONNECTION, + required: false, + title: -> { s_('JiraService|Email or username') }, + help: -> { s_('JiraService|Email for Jira Cloud or username for Jira Data Center and Jira Server') } field :password, - section: SECTION_TYPE_CONNECTION, - required: true, - title: -> { s_('JiraService|Password or API token') }, - non_empty_password_title: -> { s_('JiraService|New API token, password, or Jira personal access token') }, - non_empty_password_help: -> { s_('JiraService|Leave blank to use your current configuration') }, - help: -> { s_('JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server') }, - is_secret: true + section: SECTION_TYPE_CONNECTION, + required: true, + title: -> { s_('JiraService|API token or password') }, + non_empty_password_title: -> { s_('JiraService|New API token or password') }, + non_empty_password_help: -> { s_('JiraService|Leave blank to use your current configuration') }, + help: -> { s_('JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server') }, + 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.') } + 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.') } + 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 @@ -277,7 +285,9 @@ module Integrations expands << 'transitions' if transitions options = { expand: expands.join(',') } if expands.any? - jira_request { client.Issue.find(issue_key, options || {}) } + path = API_ENDPOINTS[:find_issue] % issue_key + + jira_request(path) { client.Issue.find(issue_key, options || {}) } end def close_issue(entity, external_issue, current_user) @@ -374,9 +384,9 @@ module Integrations private def jira_issue_match_regex - match_regex = (jira_issue_regex.presence || Gitlab::Regex.jira_issue_key_regex) + return /\b#{jira_issue_prefix}(?<issue>#{Gitlab::Regex.jira_issue_key_regex})/ if jira_issue_regex.blank? - /\b#{jira_issue_prefix}(?<issue>#{match_regex})/ + Gitlab::UntrustedRegexp.new("\\b#{jira_issue_prefix}(?P<issue>#{jira_issue_regex})") end def parse_project_from_issue_key(issue_key) @@ -389,7 +399,7 @@ module Integrations def server_info strong_memoize(:server_info) do - client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil + client_url.present? ? jira_request(API_ENDPOINTS[:server_info]) { client.ServerInfo.all.attrs } : nil end end @@ -419,7 +429,8 @@ module Integrations true rescue StandardError => e - log_exception(e, message: 'Issue transition failed', client_url: client_url) + path = API_ENDPOINTS[:transition_issue] % issue.id + log_exception(e, message: 'Issue transition failed', client_url: client_url, client_path: path, client_status: '400') false end @@ -518,7 +529,8 @@ module Integrations end def comment_exists?(issue, message) - comments = jira_request { issue.comments } + path = API_ENDPOINTS[:issue_comments] % issue.id + comments = jira_request(path) { issue.comments } comments.present? && comments.any? { |comment| comment.body.include?(message) } end @@ -526,14 +538,16 @@ module Integrations def send_message(issue, message, remote_link_props) return unless client_url.present? - jira_request do + path = API_ENDPOINTS[:link_remote_issue] % issue.id + + jira_request(path) do remote_link = find_remote_link(issue, remote_link_props[:object][:url]) create_issue_comment(issue, message) unless remote_link remote_link ||= issue.remotelink.build remote_link.save!(remote_link_props) - log_info("Successfully posted", client_url: client_url) + log_info("Successfully posted", client_url: client_url, client_path: path) "SUCCESS: Successfully posted to #{client_url}." end end @@ -545,7 +559,8 @@ module Integrations end def find_remote_link(issue, url) - links = jira_request { issue.remotelink.all } + path = API_ENDPOINTS[:link_remote_issue] % issue.id + links = jira_request(path) { issue.remotelink.all } return unless links links.find { |link| link.object["url"] == url } @@ -612,11 +627,11 @@ module Integrations end # Handle errors when doing Jira API calls - def jira_request + def jira_request(path) yield rescue StandardError => e @error = e - log_exception(e, message: 'Error sending message', client_url: client_url) + log_exception(e, message: 'Error sending message', client_url: client_url, client_path: path, client_status: e.try(:code)) nil end diff --git a/app/models/integrations/telegram.rb b/app/models/integrations/telegram.rb new file mode 100644 index 00000000000..9af12c712c6 --- /dev/null +++ b/app/models/integrations/telegram.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Integrations + class Telegram < BaseChatNotification + TELEGRAM_HOSTNAME = "https://api.telegram.org/bot%{token}/sendMessage" + + field :token, + section: SECTION_TYPE_CONNECTION, + help: -> { s_('TelegramIntegration|Unique authentication token.') }, + non_empty_password_title: -> { s_('TelegramIntegration|New token') }, + non_empty_password_help: -> { s_('TelegramIntegration|Leave blank to use your current token.') }, + placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', + exposes_secrets: true, + is_secret: true, + required: true + + field :room, + title: 'Channel identifier', + section: SECTION_TYPE_CONFIGURATION, + help: "Unique identifier for the target chat or the username of the target channel (format: @channelusername)", + placeholder: '@channelusername', + required: true + + with_options if: :activated? do + validates :token, :room, presence: true + end + + before_validation :set_webhook + + def title + 'Telegram' + end + + def description + s_("TelegramIntegration|Send notifications about project events to Telegram.") + end + + def self.to_param + 'telegram' + end + + def help + docs_link = ActionController::Base.helpers.link_to( + _('Learn more.'), + Rails.application.routes.url_helpers.help_page_url('user/project/integrations/telegram'), + target: '_blank', + rel: 'noopener noreferrer' + ) + format(s_("TelegramIntegration|Send notifications about project events to Telegram. %{docs_link}"), + docs_link: docs_link.html_safe + ) + end + + def fields + self.class.fields + build_event_channels + end + + def self.supported_events + super - ['deployment'] + end + + def sections + [ + { + type: SECTION_TYPE_CONNECTION, + title: s_('Integrations|Connection details'), + description: help + }, + { + type: SECTION_TYPE_TRIGGER, + title: s_('Integrations|Trigger'), + description: s_('Integrations|An event will be triggered when one of the following items happen.') + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: s_('Integrations|Notification settings'), + description: s_('Integrations|Configure the scope of notifications.') + } + ] + end + + private + + def set_webhook + self.webhook = format(TELEGRAM_HOSTNAME, token: token) if token.present? + end + + def notify(message, _opts) + body = { + text: message.summary, + chat_id: room, + parse_mode: 'markdown' + } + + header = { 'Content-Type' => 'application/json' } + response = Gitlab::HTTP.post(webhook, headers: header, body: Gitlab::Json.dump(body)) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index f214bc0f1af..890af8a27a0 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -39,7 +39,6 @@ class Issue < ApplicationRecord DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze IssueTypeOutOfSyncError = Class.new(StandardError) - ForbiddenColumnUsed = Class.new(StandardError) SORTING_PREFERENCE_FIELD = :issues_sort MAX_BRANCH_TEMPLATE = 255 @@ -138,28 +137,8 @@ class Issue < ApplicationRecord validate :issue_type_attribute_present enum issue_type: WorkItems::Type.base_types - # TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699 - WorkItems::Type.base_types.each do |base_type, _value| - define_method "#{base_type}?".to_sym do - error_message = <<~ERROR - `#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column, - its usage is forbidden. You should use the `work_item_types` table instead. - - # Before - - issue.requirement? => true - - # After - - issue.work_item_type.requirement? => true - - More details in https://gitlab.com/groups/gitlab-org/-/epics/10529 - ERROR - - raise ForbiddenColumnUsed, error_message - end - end + include ::Issues::ForbidIssueTypeColumnUsage alias_method :issuing_parent, :project alias_attribute :issuing_parent_id, :project_id @@ -219,8 +198,28 @@ class Issue < ApplicationRecord project: [:project_namespace, :project_feature, :route, { group: :route }, { namespace: :route }], duplicated_to: { project: [:project_feature] }) } - scope :with_issue_type, ->(types) { where(issue_type: types) } - scope :without_issue_type, ->(types) { where.not(issue_type: types) } + scope :with_issue_type, ->(types) { + types = Array(types) + + if Feature.enabled?(:issue_type_uses_work_item_types_table) + # Using != 1 since we also want the guard clause to handle empty arrays + return joins(:work_item_type).where(work_item_types: { base_type: types }) if types.size != 1 + + where( + '"issues"."work_item_type_id" = (?)', + WorkItems::Type.by_type(types.first).select(:id).limit(1) + ) + else + where(issue_type: types) + end + } + scope :without_issue_type, ->(types) { + if Feature.enabled?(:issue_type_uses_work_item_types_table) + joins(:work_item_type).where.not(work_item_types: { base_type: types }) + else + where.not(issue_type: types) + end + } scope :public_only, -> { where(confidential: false) } @@ -601,6 +600,10 @@ class Issue < ApplicationRecord spammable_attribute_changed? end + def supports_recaptcha? + true + end + def as_json(options = {}) super(options).tap do |json| if options.key?(:labels) @@ -757,6 +760,12 @@ class Issue < ApplicationRecord end end + def unsubscribe_email_participant(email) + return if email.blank? + + issue_email_participants.find_by_email(email)&.destroy + end + private def check_issue_type_in_sync! @@ -826,11 +835,9 @@ class Issue < ApplicationRecord end def spammable_attribute_changed? - title_changed? || - description_changed? || - # NOTE: We need to check them for spam when issues are made non-confidential, because spam - # may have been added while they were confidential and thus not being checked for spam. - confidential_changed?(from: true, to: false) + # NOTE: We need to check them for spam when issues are made non-confidential, because spam + # may have been added while they were confidential and thus not being checked for spam. + super || confidential_changed?(from: true, to: false) end def ensure_metrics! diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb index 1bd34aa0083..af55a5dec91 100644 --- a/app/models/issue_link.rb +++ b/app/models/issue_link.rb @@ -9,6 +9,9 @@ class IssueLink < ApplicationRecord scope :for_source_issue, ->(issue) { where(source_id: issue.id) } scope :for_target_issue, ->(issue) { where(target_id: issue.id) } + scope :for_issues, ->(source, target) do + where(source: source, target: target).or(where(source: target, target: source)) + end class << self def issuable_type diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb index bb13b83d3ba..ad0df0dca78 100644 --- a/app/models/issue_user_mention.rb +++ b/app/models/issue_user_mention.rb @@ -5,5 +5,5 @@ class IssueUserMention < UserMention belongs_to :note include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' end diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index f07f979a06d..9122f46d92c 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -4,9 +4,9 @@ class JiraConnectInstallation < ApplicationRecord include Gitlab::Routing attr_encrypted :shared_secret, - mode: :per_attribute_iv, - algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32 has_many :subscriptions, class_name: 'JiraConnectSubscription' diff --git a/app/models/key.rb b/app/models/key.rb index 2ea71bfcd6d..fdc54c9f56e 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -182,7 +182,7 @@ class Key < ApplicationRecord def forbidden_key_type_message allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase) - "type is forbidden. Must be #{Gitlab::Utils.to_exclusive_sentence(allowed_types)}" + "type is forbidden. Must be #{Gitlab::Sentence.to_exclusive_sentence(allowed_types)}" end def expiration diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 2619a7cca99..b15f32cb356 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -24,8 +24,10 @@ class LfsObject < ApplicationRecord end def self.not_linked_to_project(project) - where('NOT EXISTS (?)', - project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) + where( + 'NOT EXISTS (?)', + project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id') + ) end def project_allowed_access?(project) @@ -44,8 +46,10 @@ class LfsObject < ApplicationRecord def self.unreferenced_in_batches each_batch(of: BATCH_SIZE, order: :desc) do |lfs_objects| - relation = lfs_objects.where('NOT EXISTS (?)', - LfsObjectsProject.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) + relation = lfs_objects.where( + 'NOT EXISTS (?)', + LfsObjectsProject.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id') + ) yield relation if relation.any? end diff --git a/app/models/member.rb b/app/models/member.rb index 529666a069c..0700b1a8448 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -168,6 +168,7 @@ class Member < ApplicationRecord scope :non_guests, -> { where('members.access_level > ?', GUEST) } scope :non_minimal_access, -> { where('members.access_level > ?', MINIMAL_ACCESS) } scope :owners, -> { active.where(access_level: OWNER) } + scope :all_owners, -> { where(access_level: OWNER) } scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } scope :with_user, -> (user) { where(user: user) } scope :by_access_level, -> (access_level) { active.where(access_level: access_level) } diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index aabc902fe03..237054587bc 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -25,7 +25,7 @@ class GroupMember < Member after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? - attr_accessor :last_owner, :last_blocked_owner + attr_accessor :last_owner # For those who get to see a modal with a role dropdown, here are the options presented def self.permissible_access_level_roles(_, _) @@ -52,8 +52,11 @@ class GroupMember < Member def last_owner_of_the_group? return false unless access_level == Gitlab::Access::OWNER + return last_owner unless last_owner.nil? - group.member_last_owner?(self) || group.member_last_blocked_owner?(self) + group.member_owners_excluding_project_bots.where.not( + group: group, user_id: user_id + ).empty? end private diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb index 48c9bcb9a70..45cd8d8b000 100644 --- a/app/models/members/last_group_owner_assigner.rb +++ b/app/models/members/last_group_owner_assigner.rb @@ -8,7 +8,6 @@ class LastGroupOwnerAssigner end def execute - @last_blocked_owner = no_owners_in_hierarchy? && group.single_blocked_owner? @group_single_owner = owners.size == 1 members.each { |member| set_last_owner(member) } @@ -16,25 +15,16 @@ class LastGroupOwnerAssigner private - attr_reader :group, :members, :last_blocked_owner, :group_single_owner - - def no_owners_in_hierarchy? - owners.empty? - end + attr_reader :group, :members, :group_single_owner def set_last_owner(member) - member.last_owner = member.id.in?(owner_ids) && group_single_owner - member.last_blocked_owner = member.id.in?(blocked_owner_ids) && last_blocked_owner + member.last_owner = group_single_owner && member.id.in?(owner_ids) end def owner_ids @owner_ids ||= owners.where(id: member_ids).ids end - def blocked_owner_ids - @blocked_owner_ids ||= group.blocked_owners.where(id: member_ids).ids - end - def member_ids @members_ids ||= members.pluck(:id) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7b1d4b97d3b..116108ceaf9 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -23,6 +23,7 @@ class MergeRequest < ApplicationRecord include Approvable include IdInOrdered include Todoable + include Spammable extend ::Gitlab::Utils::Override @@ -95,9 +96,9 @@ class MergeRequest < ApplicationRecord dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue - has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' + has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline', inverse_of: :merge_request has_many :suggestions, through: :notes - has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note' + has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note', inverse_of: :noteable has_many :merge_request_assignees has_many :assignees, class_name: "User", through: :merge_request_assignees @@ -154,6 +155,9 @@ class MergeRequest < ApplicationRecord # Flag to skip triggering mergeRequestMergeStatusUpdated GraphQL subscription. attr_accessor :skip_merge_status_trigger + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true + participant :reviewers # Keep states definition to be evaluated before the state_machine block to @@ -307,6 +311,13 @@ class MergeRequest < ApplicationRecord scope :open_and_closed, -> { with_states(:opened, :closed) } scope :drafts, -> { where(draft: true) } scope :from_source_branches, ->(branches) { where(source_branch: branches) } + scope :by_sorted_source_branches, ->(branches) do + from_source_branches(branches) + .order(source_branch: :asc, id: :desc) + end + scope :including_target_project, -> do + includes(:target_project) + end scope :by_commit_sha, ->(sha) do where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil) end @@ -420,7 +431,7 @@ class MergeRequest < ApplicationRecord includes(:metrics) end - scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex.source) } + scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex(expression_escape: '\m').source) } scope :review_requested, -> do where(reviewers_subquery.exists) @@ -2044,6 +2055,10 @@ class MergeRequest < ApplicationRecord NewMergeRequestWorker.perform_async(id, author_id) end + def check_for_spam?(*) + spammable_attribute_changed? && project.public? + end + private attr_accessor :skip_fetch_ref diff --git a/app/models/merge_request/diff_llm_summary.rb b/app/models/merge_request/diff_llm_summary.rb index 5e7d80712e2..e13fe5e1f50 100644 --- a/app/models/merge_request/diff_llm_summary.rb +++ b/app/models/merge_request/diff_llm_summary.rb @@ -5,6 +5,7 @@ class MergeRequest::DiffLlmSummary < ApplicationRecord belongs_to :merge_request_diff belongs_to :user, optional: true + validates :merge_request_diff_id, uniqueness: true validates :provider, presence: true validates :content, presence: true, length: { maximum: 2056 } diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 0e699d7a81d..33930836c48 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -32,7 +32,7 @@ class MergeRequestDiff < ApplicationRecord -> { order(:merge_request_diff_id, :relative_order) }, inverse_of: :merge_request_diff - has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) } + has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }, inverse_of: :merge_request_diff validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true validates :merge_request_id, uniqueness: { scope: :diff_type }, if: :merge_head? @@ -592,8 +592,8 @@ class MergeRequestDiff < ApplicationRecord end def remove_cached_external_diff - Gitlab::Utils.check_path_traversal!(external_diff_cache_dir) - Gitlab::Utils.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir]) + Gitlab::PathTraversal.check_path_traversal!(external_diff_cache_dir) + Gitlab::PathTraversal.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir]) return unless Dir.exist?(external_diff_cache_dir) diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb index d946fd14628..3157f1ca2aa 100644 --- a/app/models/merge_request_user_mention.rb +++ b/app/models/merge_request_user_mention.rb @@ -3,7 +3,7 @@ class MergeRequestUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' belongs_to :merge_request belongs_to :note diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 7c6fa24cd4d..7b3bb04da5b 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -229,11 +229,15 @@ class Namespace < ApplicationRecord # query - The search query as a String. # # Returns an ActiveRecord::Relation. - def search(query, include_parents: false) + def search(query, include_parents: false, use_minimum_char_limit: true) if include_parents - without_project_namespaces.where(id: Route.for_routable_type(Namespace.name).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id)) + without_project_namespaces + .where(id: Route.for_routable_type(Namespace.name) + .fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]], + use_minimum_char_limit: use_minimum_char_limit) + .select(:source_id)) else - without_project_namespaces.fuzzy_search(query, [:path, :name]) + without_project_namespaces.fuzzy_search(query, [:path, :name], use_minimum_char_limit: use_minimum_char_limit) end end @@ -400,6 +404,12 @@ class Namespace < ApplicationRecord Project.where(namespace: namespace) end + # Includes projects from this namespace and projects from all subgroups + # that belongs to this namespace, except the ones that are soft deleted + def all_projects_except_soft_deleted + all_projects.not_aimed_for_deletion + end + def has_parent? parent_id.present? || parent.present? end diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb index e08c08f9ced..6c977505f17 100644 --- a/app/models/namespace/aggregation_schedule.rb +++ b/app/models/namespace/aggregation_schedule.rb @@ -14,7 +14,7 @@ class Namespace::AggregationSchedule < ApplicationRecord def default_lease_timeout if Feature.enabled?(:reduce_aggregation_schedule_lease, namespace.root_ancestor) - 2.minutes.to_i + ::Gitlab::CurrentSettings.namespace_aggregation_schedule_lease_duration_in_seconds else 30.minutes.to_i end diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 0443e1d9231..8af0cf2767c 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -21,7 +21,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord scope :for_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) } - delegate :all_projects, to: :namespace + delegate :all_projects_except_soft_deleted, to: :namespace enum notification_level: { storage_remaining: 100, @@ -60,8 +60,6 @@ class Namespace::RootStorageStatistics < ApplicationRecord end def attributes_for_forks_statistics - return {} unless ::Feature.enabled?(:root_storage_statistics_calculate_forks, namespace) - visibility_levels_to_storage_size_columns = { Gitlab::VisibilityLevel::PRIVATE => :private_forks_storage_size, Gitlab::VisibilityLevel::INTERNAL => :internal_forks_storage_size, @@ -78,7 +76,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord end def for_forks_statistics - all_projects + all_projects_except_soft_deleted .joins([:statistics, :fork_network]) .where('fork_networks.root_project_id != projects.id') .group('projects.visibility_level') @@ -94,7 +92,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord end def from_project_statistics - all_projects + all_projects_except_soft_deleted .joins('INNER JOIN project_statistics ps ON ps.project_id = projects.id') .select( 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index e7f6db38047..5b114bb42aa 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -12,8 +12,12 @@ class NamespaceSetting < ApplicationRecord enum jobs_to_be_done: { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5 }, _suffix: true enum enabled_git_access_protocol: { all: 0, ssh: 1, http: 2 }, _suffix: true + attribute :default_branch_protection_defaults, default: -> { {} } + validates :enabled_git_access_protocol, inclusion: { in: enabled_git_access_protocols.keys } validates :code_suggestions, allow_nil: false, inclusion: { in: [true, false] } + validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' } + validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } } validate :allow_mfa_for_group validate :allow_resource_access_token_creation_for_group @@ -22,6 +26,8 @@ class NamespaceSetting < ApplicationRecord before_validation :normalize_default_branch_name + after_create :set_code_suggestions_default + chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval chronic_duration_attr :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval @@ -41,6 +47,9 @@ class NamespaceSetting < ApplicationRecord project_runner_token_expiration_interval ].freeze + # matches the size set in the database constraint + DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE = 1.kilobyte + self.primary_key = :namespace_id def self.allowed_namespace_settings_params @@ -87,6 +96,14 @@ class NamespaceSetting < ApplicationRecord self.default_branch_name = default_branch_name.presence end + def set_code_suggestions_default + # users should have code suggestions disabled by default + return if namespace&.user_namespace? + + # groups should have code suggestions enabled by default + update_column(:code_suggestions, true) + end + def allow_mfa_for_group if namespace&.subgroup? && allow_mfa_for_subgroups == false errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.')) diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb index cf2612b7f33..bf23fc21124 100644 --- a/app/models/namespaces/project_namespace.rb +++ b/app/models/namespaces/project_namespace.rb @@ -11,7 +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 + delegate :execute_hooks, :execute_integrations, :group, to: :project, allow_nil: true + delegate :external_references_supported?, :default_issues_tracker?, to: :project def self.sti_name 'Project' diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 792964a6c7f..c50d3dd1de6 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -25,8 +25,6 @@ module Namespaces end def self_and_ancestors(include_self: true, upto: nil, hierarchy_order: nil) - return super unless use_traversal_ids_for_ancestor_scopes? - self_and_ancestors_from_inner_join( include_self: include_self, upto: upto, hierarchy_order: @@ -35,8 +33,6 @@ module Namespaces end def self_and_ancestor_ids(include_self: true) - return super unless use_traversal_ids_for_ancestor_scopes? - self_and_ancestors(include_self: include_self).as_ids end @@ -87,11 +83,6 @@ module Namespaces use_traversal_ids? end - def use_traversal_ids_for_ancestor_scopes? - Feature.enabled?(:use_traversal_ids_for_ancestor_scopes) && - use_traversal_ids? - end - def use_traversal_ids_for_descendants_scopes? Feature.enabled?(:use_traversal_ids_for_descendants_scopes) && use_traversal_ids? diff --git a/app/models/note.rb b/app/models/note.rb index ac2b54629ae..09ff7ad3979 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -24,6 +24,7 @@ class Note < ApplicationRecord include Sortable include EachBatch include IgnorableColumns + include Spammable ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' @@ -68,6 +69,8 @@ class Note < ApplicationRecord attribute :system, default: false + attr_spammable :note, spam_description: true + attr_mentionable :note, pipeline: :note participant :author @@ -141,6 +144,7 @@ class Note < ApplicationRecord scope :with_discussion_ids, ->(discussion_ids) { where(discussion_id: discussion_ids) } scope :with_suggestions, -> { joins(:suggestions) } scope :inc_author, -> { includes(:author) } + scope :authored_by, ->(user) { where(author: user) } scope :inc_note_diff_file, -> { includes(:note_diff_file) } scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) } scope :inc_relations_for_view, ->(noteable = nil) do @@ -429,6 +433,10 @@ class Note < ApplicationRecord project&.team&.contributor?(self.author_id) end + def human_max_access + project&.team&.human_max_access(self.author_id) + end + def noteable_author?(noteable) noteable.author == self.author end @@ -688,6 +696,7 @@ class Note < ApplicationRecord def show_outdated_changes? return false unless for_merge_request? return false unless system? + return false if change_position&.on_file? return false unless change_position&.line_range change_position.line_range["end"] || change_position.line_range["start"] @@ -773,6 +782,16 @@ class Note < ApplicationRecord readable_by?(user) end + # Override method defined in Spammable + # Wildcard argument because user: argument is not used + def check_for_spam?(*) + return false if system? || !spammable_attribute_changed? || confidential? + return false if noteable.try(:confidential?) == true || noteable.try(:public?) == false + return false if noteable.try(:group)&.public? == false || project&.public? == false + + true + end + private def trigger_note_subscription? diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index e4936de7b40..b0f6af0d853 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -4,7 +4,7 @@ class NoteDiffFile < ApplicationRecord include DiffFile include IgnorableColumns - ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' scope :referencing_sha, -> (oids, project_id:) do joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids }) diff --git a/app/models/organization.rb b/app/models/organization.rb deleted file mode 100644 index cfbbbf1183e..00000000000 --- a/app/models/organization.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class Organization < ApplicationRecord - DEFAULT_ORGANIZATION_ID = 1 - - scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) } - - before_destroy :check_if_default_organization - - validates :name, - presence: true, - length: { maximum: 255 }, - uniqueness: { case_sensitive: false } - - def default? - id == DEFAULT_ORGANIZATION_ID - end - - private - - def check_if_default_organization - return unless default? - - raise ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization') - end -end diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb new file mode 100644 index 00000000000..ce89f57a73b --- /dev/null +++ b/app/models/organizations/organization.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Organizations + class Organization < ApplicationRecord + DEFAULT_ORGANIZATION_ID = 1 + + scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) } + + before_destroy :check_if_default_organization + + validates :name, + presence: true, + length: { maximum: 255 } + + validates :path, + presence: true, + 'organizations/path': true, + length: { minimum: 2, maximum: 255 } + + def self.default_organization + find_by(id: DEFAULT_ORGANIZATION_ID) + end + + def default? + id == DEFAULT_ORGANIZATION_ID + end + + def to_param + path + end + + private + + def check_if_default_organization + return unless default? + + raise ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization') + end + end +end diff --git a/app/models/packages/cleanup/policy.rb b/app/models/packages/cleanup/policy.rb index 35f58f3680d..e8729704192 100644 --- a/app/models/packages/cleanup/policy.rb +++ b/app/models/packages/cleanup/policy.rb @@ -12,11 +12,10 @@ module Packages belongs_to :project validates :project, presence: true - validates :keep_n_duplicated_package_files, - inclusion: { - in: KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES, - message: 'is invalid' - } + validates :keep_n_duplicated_package_files, inclusion: { + in: KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES, + message: 'is invalid' + } # used by Schedulable def self.active diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb index 58af34879af..fc46e0b3b10 100644 --- a/app/models/packages/conan/metadatum.rb +++ b/app/models/packages/conan/metadatum.rb @@ -8,9 +8,9 @@ class Packages::Conan::Metadatum < ApplicationRecord validates :package, presence: true validates :package_username, - :package_channel, - presence: true, - format: { with: Gitlab::Regex.conan_recipe_user_channel_regex } + :package_channel, + presence: true, + format: { with: Gitlab::Regex.conan_recipe_user_channel_regex } validate :conan_package_type validate :username_channel_none_values diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb index eb66f4acfa9..7ea0dfe8765 100644 --- a/app/models/packages/debian/file_entry.rb +++ b/app/models/packages/debian/file_entry.rb @@ -9,13 +9,13 @@ module Packages FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze attr_accessor :filename, - :size, - :md5sum, - :section, - :priority, - :sha1sum, - :sha256sum, - :package_file + :size, + :md5sum, + :section, + :priority, + :sha1sum, + :sha256sum, + :package_file validates :filename, :size, :md5sum, :section, :priority, :sha1sum, :sha256sum, :package_file, presence: true validates :filename, format: { with: FILENAME_REGEX } diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb index a029437c82d..958658e68c1 100644 --- a/app/models/packages/go/module.rb +++ b/app/models/packages/go/module.rb @@ -14,8 +14,9 @@ module Packages end def versions - strong_memoize(:versions) { Packages::Go::VersionFinder.new(self).execute } + Packages::Go::VersionFinder.new(self).execute end + strong_memoize_attr :versions def version_by(ref: nil, commit: nil) raise ArgumentError, 'no filter specified' unless ref || commit diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb index 5869a03e081..17b97151f29 100644 --- a/app/models/packages/go/module_version.rb +++ b/app/models/packages/go/module_version.rb @@ -46,16 +46,15 @@ module Packages end def gomod - strong_memoize(:gomod) do - if strong_memoized?(:blobs) - blob_at(@mod.path + '/go.mod') - elsif @mod.path.empty? - @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data - else - @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data - end + if strong_memoized?(:blobs) + blob_at(@mod.path + '/go.mod') + elsif @mod.path.empty? + @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data + else + @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data end end + strong_memoize_attr :gomod def archive suffix_len = @mod.path == '' ? 0 : @mod.path.length + 1 @@ -69,18 +68,16 @@ module Packages end def files - strong_memoize(:files) do - ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } } - end + ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } } end + strong_memoize_attr :files def excluded - strong_memoize(:excluded) do - ls_tree + ls_tree .filter { |f| f.end_with?('/go.mod') && f != @mod.path + '/go.mod' } .map { |f| f[0..-7] } - end end + strong_memoize_attr :excluded def valid? # assume the module version is valid if a corresponding Package exists @@ -100,21 +97,20 @@ module Packages end def blobs - strong_memoize(:blobs) { @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] }) } + @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] }) end + strong_memoize_attr :blobs def ls_tree - strong_memoize(:ls_tree) do - path = - if @mod.path.empty? - '.' - else - @mod.path - end - - @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path) - end + path = if @mod.path.empty? + '.' + else + @mod.path + end + + @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path) end + strong_memoize_attr :ls_tree end end end diff --git a/app/models/packages/npm/metadata_cache.rb b/app/models/packages/npm/metadata_cache.rb index 7a7c66d7a45..02efeda69cb 100644 --- a/app/models/packages/npm/metadata_cache.rb +++ b/app/models/packages/npm/metadata_cache.rb @@ -4,6 +4,7 @@ module Packages module Npm class MetadataCache < ApplicationRecord include FileStoreMounter + include Packages::Downloadable belongs_to :project, inverse_of: :npm_metadata_caches diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb index 1db8c0eddbf..fae7728cccb 100644 --- a/app/models/packages/nuget/metadatum.rb +++ b/app/models/packages/nuget/metadatum.rb @@ -1,24 +1,23 @@ # frozen_string_literal: true class Packages::Nuget::Metadatum < ApplicationRecord + MAX_AUTHORS_LENGTH = 255 + MAX_DESCRIPTION_LENGTH = 4000 + MAX_URL_LENGTH = 255 + belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_metadatum validates :package, presence: true - validates :license_url, public_url: { allow_blank: true } - validates :project_url, public_url: { allow_blank: true } - validates :icon_url, public_url: { allow_blank: true } + validates :license_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH } + validates :project_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH } + validates :icon_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH } + validates :authors, presence: true, length: { maximum: MAX_AUTHORS_LENGTH } + validates :description, presence: true, length: { maximum: MAX_DESCRIPTION_LENGTH } - validate :ensure_at_least_one_field_supplied validate :ensure_nuget_package_type private - def ensure_at_least_one_field_supplied - return if license_url? || project_url? || icon_url? - - errors.add(:base, _('Nuget metadatum must have at least license_url, project_url or icon_url set')) - end - def ensure_nuget_package_type return if package&.nuget? diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index c58ad92d7a6..58305b45457 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -6,6 +6,7 @@ class Packages::Package < ApplicationRecord include UsageStatistics include Gitlab::Utils::StrongMemoize include Packages::Installable + include Packages::Downloadable DISPLAYABLE_STATUSES = [:default, :error].freeze INSTALLABLE_STATUSES = [:default, :hidden].freeze @@ -23,7 +24,8 @@ class Packages::Package < ApplicationRecord rubygems: 10, helm: 11, terraform_module: 12, - rpm: 13 + rpm: 13, + ml_model: 14 } enum status: { default: 0, hidden: 1, processing: 2, error: 3, pending_destruction: 4 } @@ -68,11 +70,11 @@ class Packages::Package < ApplicationRecord validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? || debian? } validates :name, - uniqueness: { - scope: %i[project_id version package_type], - conditions: -> { not_pending_destruction } - }, - unless: -> { pending_destruction? || conan? } + uniqueness: { + scope: %i[project_id version package_type], + conditions: -> { not_pending_destruction } + }, + unless: -> { pending_destruction? || conan? } validate :valid_conan_package_recipe, if: :conan? validate :valid_composer_global_name, if: :composer? @@ -225,6 +227,10 @@ class Packages::Package < ApplicationRecord find_by!(name: name, version: version) end + def self.debian_incoming_package! + find_by!(name: Packages::Debian::INCOMING_PACKAGE_NAME, version: nil, package_type: :debian, status: :default) + end + def self.existing_debian_packages_with(name:, version:) debian.with_name(name) .with_version(version) @@ -297,20 +303,14 @@ class Packages::Package < ApplicationRecord end # Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937 - # TODO: rename the method https://gitlab.com/gitlab-org/gitlab/-/issues/410352 - def original_build_info - strong_memoize(:original_build_info) do - if Feature.enabled?(:packages_display_last_pipeline, project) - build_infos.last - else - build_infos.first - end - end + def last_build_info + build_infos.last end + strong_memoize_attr :last_build_info # Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937 def pipeline - original_build_info&.pipeline + last_build_info&.pipeline end def tag_names @@ -330,10 +330,9 @@ class Packages::Package < ApplicationRecord end def package_settings - strong_memoize(:package_settings) do - project.namespace.package_settings - end + project.namespace.package_settings end + strong_memoize_attr :package_settings def sync_maven_metadata(user) return unless maven? && version? && user @@ -361,12 +360,6 @@ class Packages::Package < ApplicationRecord name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase end - def touch_last_downloaded_at - ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do - update_column(:last_downloaded_at, Time.zone.now) - end - end - def publish_creation_event ::Gitlab::EventStore.publish( ::Packages::PackageCreatedEvent.new(data: { @@ -439,5 +432,3 @@ class Packages::Package < ApplicationRecord end end end - -Packages::Package.prepend_mod diff --git a/app/models/packages/rpm/metadatum.rb b/app/models/packages/rpm/metadatum.rb index 07361995a12..7ee9e2d0064 100644 --- a/app/models/packages/rpm/metadatum.rb +++ b/app/models/packages/rpm/metadatum.rb @@ -8,34 +8,13 @@ module Packages belongs_to :package, -> { where(package_type: :rpm) }, inverse_of: :rpm_metadatum validates :package, presence: true - - validates :epoch, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :release, - presence: true, - length: { maximum: 128 } - - validates :summary, - presence: true, - length: { maximum: 1000 } - - validates :description, - presence: true, - length: { maximum: 5000 } - - validates :arch, - presence: true, - length: { maximum: 255 } - - validates :license, - allow_nil: true, - length: { maximum: 1000 } - - validates :url, - allow_nil: true, - length: { maximum: 1000 } + validates :epoch, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :release, presence: true, length: { maximum: 128 } + validates :summary, presence: true, length: { maximum: 1000 } + validates :description, presence: true, length: { maximum: 5000 } + validates :arch, presence: true, length: { maximum: 255 } + validates :license, allow_nil: true, length: { maximum: 1000 } + validates :url, allow_nil: true, length: { maximum: 1000 } validate :rpm_package_type diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 10ac10295fc..88d7f0f972a 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -23,10 +23,10 @@ class PagesDomain < ApplicationRecord validates :domain, uniqueness: { case_sensitive: false } validates :certificate, :key, presence: true, if: :usage_serverless? validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' }, - if: :certificate_should_be_present? + if: :certificate_should_be_present? validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? } validates :key, presence: { message: 'must be present if HTTPS-only is enabled' }, - if: :certificate_should_be_present? + if: :certificate_should_be_present? validates :key, certificate_key: true, named_ecdsa_key: true, if: ->(domain) { domain.key.present? } validates :verification_code, presence: true, allow_blank: false diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb index 8427176fa72..80688e8d247 100644 --- a/app/models/pages_domain_acme_order.rb +++ b/app/models/pages_domain_acme_order.rb @@ -13,10 +13,10 @@ class PagesDomainAcmeOrder < ApplicationRecord validates :private_key, presence: true attr_encrypted :private_key, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm', - encode: true + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + encode: true def self.find_by_domain_and_token(domain_name, challenge_token) joins(:pages_domain).find_by(pages_domains: { domain: domain_name }, challenge_token: challenge_token) diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 75afff6a2fa..2749404b7b5 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -24,11 +24,6 @@ class PersonalAccessToken < ApplicationRecord after_initialize :set_default_scopes, if: :persisted? before_save :ensure_token - # During the implementation of Admin Mode for API, tokens of - # administrators should automatically get the `admin_mode` scope as well - # See https://gitlab.com/gitlab-org/gitlab/-/issues/42692 - before_create :add_admin_mode_scope, if: -> { Feature.disabled?(:admin_mode_for_api) && user_admin? } - scope :active, -> { not_revoked.not_expired } scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) } @@ -49,6 +44,7 @@ class PersonalAccessToken < ApplicationRecord validates :scopes, presence: true validate :validate_scopes + validates :expires_at, presence: true, on: :create validate :expires_at_before_instance_max_expiry_date, on: :create def revoke! @@ -59,19 +55,6 @@ class PersonalAccessToken < ApplicationRecord !revoked? && !expired? end - # fall back to default value until background migration has updated all - # existing PATs and we can add a validation - # https://gitlab.com/gitlab-org/gitlab/-/issues/369123 - def expires_at=(value) - datetime = if Feature.enabled?(:default_pat_expiration) - value.presence || MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now - else - value - end - - super(datetime) - end - override :simple_sorts def self.simple_sorts super.merge( @@ -89,17 +72,10 @@ class PersonalAccessToken < ApplicationRecord fuzzy_search(query, [:name]) end - def project_access_token? - user&.project_bot? - end - protected def validate_scopes - valid_scopes = Gitlab::Auth.all_available_scopes - valid_scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE] if Feature.disabled?(:admin_mode_for_api) - - unless revoked || scopes.all? { |scope| valid_scopes.include?(scope.to_sym) } + unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) } errors.add :scopes, "can only contain available scopes" end end @@ -116,16 +92,11 @@ class PersonalAccessToken < ApplicationRecord user.admin? # rubocop: disable Cop/UserAdmin end - def add_admin_mode_scope - self.scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE.to_s] - end - def prefix_from_application_current_settings self.class.token_prefix end def expires_at_before_instance_max_expiry_date - return unless Feature.enabled?(:default_pat_expiration) return unless expires_at if expires_at > MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb index bf69f425189..6795e7a3049 100644 --- a/app/models/plan_limits.rb +++ b/app/models/plan_limits.rb @@ -4,11 +4,18 @@ class PlanLimits < ApplicationRecord include IgnorableColumns ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22' ignore_column :web_hook_calls_high, remove_with: '15.10', remove_after: '2022-02-22' + ignore_column :ci_active_pipelines, remove_with: '16.3', remove_after: '2022-07-22' + + attribute :limits_history, :ind_jsonb, default: -> { {} } + validates :limits_history, json_schema: { filename: 'plan_limits_history' } LimitUndefinedError = Class.new(StandardError) belongs_to :plan + validates :notification_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :enforcement_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + def exceeded?(limit_name, subject, alternate_limit: 0) limit = limit_for(limit_name, alternate_limit: alternate_limit) return false unless limit @@ -37,4 +44,39 @@ class PlanLimits < ApplicationRecord limits = [limit, alternate_limit] limits.map(&:to_i).select(&:positive?).min end + + # Overridden in EE + def dashboard_storage_limit_enabled? + false + end + + def log_limits_changes(user, new_limits) + new_limits.each do |attribute, value| + limits_history[attribute] ||= [] + limits_history[attribute] << { + user_id: user&.id, + username: user&.username, + timestamp: Time.current.utc.to_i, + value: value + } + end + + update(limits_history: limits_history) + end + + def limit_attribute_changes(attribute) + limit_history = limits_history[attribute] + return [] unless limit_history + + limit_history.map do |entry| + { + timestamp: entry[:timestamp], + value: entry[:value], + username: entry[:username], + user_id: entry[:user_id] + } + end + end end + +PlanLimits.prepend_mod_with('PlanLimits') diff --git a/app/models/preloaders/projects/notes_preloader.rb b/app/models/preloaders/projects/notes_preloader.rb new file mode 100644 index 00000000000..d3a05951926 --- /dev/null +++ b/app/models/preloaders/projects/notes_preloader.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Preloaders + module Projects + class NotesPreloader + include RendersNotes + + def initialize(project, current_user) + @project = project + @current_user = current_user + end + + def call(notes) + prepare_notes_for_rendering(notes) + end + + private + + attr_reader :current_user + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 224193fba08..452a5c8973c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -111,9 +111,9 @@ class Project < ApplicationRecord attribute :ci_config_path, default: -> { Gitlab::CurrentSettings.default_ci_config_path } add_authentication_token_field :runners_token, - encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption) ? :optional : :required }, - format_with_prefix: :runners_token_prefix, - require_prefix_for_validation: true + encrypted: :required, + format_with_prefix: :runners_token_prefix, + require_prefix_for_validation: true # Storage specific hooks after_initialize :use_hashed_storage @@ -185,6 +185,7 @@ class Project < ApplicationRecord has_one :bugzilla_integration, class_name: 'Integrations::Bugzilla' has_one :buildkite_integration, class_name: 'Integrations::Buildkite' has_one :campfire_integration, class_name: 'Integrations::Campfire' + has_one :clickup_integration, class_name: 'Integrations::Clickup' has_one :confluence_integration, class_name: 'Integrations::Confluence' has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker' has_one :datadog_integration, class_name: 'Integrations::Datadog' @@ -194,6 +195,7 @@ class Project < ApplicationRecord has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush' has_one :ewm_integration, class_name: 'Integrations::Ewm' has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki' + has_one :gitlab_slack_application_integration, class_name: 'Integrations::GitlabSlackApplication' has_one :google_play_integration, class_name: 'Integrations::GooglePlay' has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat' has_one :harbor_integration, class_name: 'Integrations::Harbor' @@ -217,6 +219,7 @@ class Project < ApplicationRecord has_one :slack_slash_commands_integration, class_name: 'Integrations::SlackSlashCommands' has_one :squash_tm_integration, class_name: 'Integrations::SquashTm' has_one :teamcity_integration, class_name: 'Integrations::Teamcity' + has_one :telegram_integration, class_name: 'Integrations::Telegram' has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit' has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams' has_one :youtrack_integration, class_name: 'Integrations::Youtrack' @@ -224,10 +227,7 @@ class Project < ApplicationRecord 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, - class_name: 'ForkNetwork' + has_one :root_of_fork_network, foreign_key: 'root_project_id', inverse_of: :root_project, class_name: 'ForkNetwork' has_one :fork_network_member has_one :fork_network, through: :fork_network_member has_one :forked_from_project, through: :fork_network_member @@ -247,24 +247,19 @@ class Project < ApplicationRecord has_many :fork_network_projects, through: :fork_network, source: :projects # Packages - has_many :packages, - class_name: 'Packages::Package' - has_many :package_files, - through: :packages, class_name: 'Packages::PackageFile' + has_many :packages, class_name: 'Packages::Package' + has_many :package_files, through: :packages, class_name: 'Packages::PackageFile' # repository_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :rpm_repository_files, - inverse_of: :project, - class_name: 'Packages::Rpm::RepositoryFile', - dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + inverse_of: :project, + class_name: 'Packages::Rpm::RepositoryFile', + dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, - class_name: 'Packages::Debian::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 + 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 has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -388,10 +383,7 @@ class Project < ApplicationRecord # The relation :ci_pipelines includes all those that directly contribute to the # latest status of a ref. This does not include dangling pipelines such as those # from webide, child pipelines, etc. - has_many :ci_pipelines, - -> { ci_sources }, - class_name: 'Ci::Pipeline', - inverse_of: :project + has_many :ci_pipelines, -> { ci_sources }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :stages, class_name: 'Ci::Stage', inverse_of: :project has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project has_many :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :project @@ -473,8 +465,8 @@ class Project < ApplicationRecord accepts_nested_attributes_for :container_expiration_policy, update_only: true accepts_nested_attributes_for :remote_mirrors, - allow_destroy: true, - reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + allow_destroy: true, + reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } accepts_nested_attributes_for :incident_management_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true @@ -483,60 +475,63 @@ class Project < ApplicationRecord accepts_nested_attributes_for :prometheus_integration, update_only: true accepts_nested_attributes_for :alerting_setting, update_only: true - delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, - :wiki_access_level, :snippets_access_level, :builds_access_level, - :repository_access_level, :package_registry_access_level, :pages_access_level, - :metrics_dashboard_access_level, :analytics_access_level, - :operations_access_level, :security_and_compliance_access_level, - :container_registry_access_level, :environments_access_level, :feature_flags_access_level, - :monitor_access_level, :releases_access_level, :infrastructure_access_level, - to: :project_feature, allow_nil: true - - delegate :show_default_award_emojis, :show_default_award_emojis=, - :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=, - :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, - to: :project_setting, allow_nil: true - - delegate :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 - delegate :squash_option, :squash_option=, to: :project_setting - delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting - delegate :previous_default_branch, :previous_default_branch=, to: :project_setting + delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, :monitor_access_level, :releases_access_level, :infrastructure_access_level, :model_experiments_access_level, to: :project_feature, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true - delegate :members, to: :team, prefix: true - 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 + delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage delegate :last_pipeline, to: :commit, allow_nil: true - delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true - delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true - delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci, allow_nil: true - delegate :forward_deployment_enabled, :forward_deployment_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true - delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci_outbound, allow_nil: true - delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true - delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true - delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=, to: :ci_cd_settings, prefix: :ci, allow_nil: true - delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true - delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true - delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true - delegate :actual_limits, :actual_plan_name, :actual_plan, to: :namespace, allow_nil: true - delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, - :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?, - to: :project_setting - delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true - delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true - delegate :issue_branch_template, :issue_branch_template=, to: :project_setting, allow_nil: true - delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage + with_options to: :team do + delegate :members, prefix: true + delegate :add_member, :add_members, :member? + delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role + end - delegate :maven_package_requests_forwarding, - :pypi_package_requests_forwarding, - :npm_package_requests_forwarding, - to: :namespace + with_options to: :metrics_setting, allow_nil: true, prefix: true do + delegate :external_dashboard_url + delegate :dashboard_timezone + end + + with_options to: :namespace do + delegate :actual_limits, :actual_plan_name, :actual_plan, :root_ancestor, allow_nil: true + delegate :maven_package_requests_forwarding, :pypi_package_requests_forwarding, :npm_package_requests_forwarding + end + + with_options to: :ci_cd_settings, allow_nil: true do + delegate :group_runners_enabled, :group_runners_enabled= + delegate :keep_latest_artifact, :keep_latest_artifact= + delegate :restrict_user_defined_variables, :restrict_user_defined_variables= + delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable= + delegate :job_token_scope_enabled, :job_token_scope_enabled=, prefix: :ci_outbound + + with_options prefix: :ci do + delegate :default_git_depth, :default_git_depth= + delegate :forward_deployment_enabled, :forward_deployment_enabled= + delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled= + delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project= + delegate :separated_caches, :separated_caches= + end + end + + with_options to: :project_setting do + delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline= + delegate :has_confluence? + delegate :has_shimo? + delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email? + delegate :runner_registration_enabled, :runner_registration_enabled=, :runner_registration_enabled? + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly? + delegate :mr_default_target_self, :mr_default_target_self= + delegate :previous_default_branch, :previous_default_branch= + delegate :squash_option, :squash_option= + + with_options allow_nil: true do + delegate :merge_commit_template, :merge_commit_template= + delegate :squash_commit_template, :squash_commit_template= + delegate :issue_branch_template, :issue_branch_template= + delegate :show_default_award_emojis, :show_default_award_emojis= + delegate :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads= + delegate :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters= + end + end # Validations validates :creator, presence: true, on: :create @@ -602,6 +597,42 @@ class Project < ApplicationRecord .or(arel_table[:storage_version].eq(nil))) end + scope :sorted_by_name_desc, -> { + keyset_order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: Project.arel_table[:name].desc, + distinct: false, + nullable: :nulls_last + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Project.arel_table[:id].desc + ) + ]) + + reorder(keyset_order) + } + + scope :sorted_by_name_asc, -> { + keyset_order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: Project.arel_table[:name].asc, + distinct: false, + nullable: :nulls_last + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Project.arel_table[:id].asc + ) + ]) + + reorder(keyset_order) + } + scope :sorted_by_updated_asc, -> { reorder(self.arel_table['updated_at'].asc) } scope :sorted_by_updated_desc, -> { reorder(self.arel_table['updated_at'].desc) } scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } @@ -751,11 +782,12 @@ class Project < ApplicationRecord chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600, error_message: N_('Maximum job timeout has a value which could not be accepted') - validates :build_timeout, allow_nil: true, - numericality: { greater_than_or_equal_to: 10.minutes, - less_than: MAX_BUILD_TIMEOUT, - only_integer: true, - message: N_('needs to be between 10 minutes and 1 month') } + validates :build_timeout, allow_nil: true, numericality: { + greater_than_or_equal_to: 10.minutes, + less_than: MAX_BUILD_TIMEOUT, + only_integer: true, + message: N_('needs to be between 10 minutes and 1 month') + } # Used by Projects::CleanupService to hold a map of rewritten object IDs mount_uploader :bfg_object_map, AttachmentUploader @@ -768,6 +800,20 @@ class Project < ApplicationRecord preload(:project_feature, :route, :creator, group: :parent, namespace: [:route, :owner]) end + def self.with_slack_application_disabled + # Using Arel to avoid exposing what the column backing the type: attribute is + # rubocop: disable GitlabSecurity/PublicSend + with_active_slack = Integration.active.by_name(:gitlab_slack_application) + join_contraint = arel_table[:id].eq(Integration.arel_table[:project_id]) + constraint = with_active_slack.where_clause.send(:predicates).reduce(join_contraint) { |a, b| a.and(b) } + join = arel_table.join(Integration.arel_table, Arel::Nodes::OuterJoin).on(constraint).join_sources + # rubocop: enable GitlabSecurity/PublicSend + + joins(join).where(integrations: { id: nil }) + rescue Integration::UnknownType + all + end + def self.eager_load_namespace_and_owner includes(namespace: :owner) end @@ -782,9 +828,11 @@ class Project < ApplicationRecord if user.is_a?(DeployToken) where(id: user.accessible_projects) else - where('EXISTS (?) OR projects.visibility_level IN (?)', - user.authorizations_for_projects(min_access_level: min_access_level), - Gitlab::VisibilityLevel.levels_for_user(user)) + where( + 'EXISTS (?) OR projects.visibility_level IN (?)', + user.authorizations_for_projects(min_access_level: min_access_level), + Gitlab::VisibilityLevel.levels_for_user(user) + ) end end @@ -863,11 +911,12 @@ class Project < ApplicationRecord # search. # # query - The search query as a String. - def search(query, include_namespace: false) + def search(query, include_namespace: false, use_minimum_char_limit: true) if include_namespace - joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description]) + joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description], + use_minimum_char_limit: use_minimum_char_limit) else - fuzzy_search(query, [:path, :name, :description]) + fuzzy_search(query, [:path, :name, :description], use_minimum_char_limit: use_minimum_char_limit) end end @@ -1205,8 +1254,8 @@ class Project < ApplicationRecord @repository ||= Gitlab::GlRepository::PROJECT.repository_for(self) end - def design_management_repository - super || create_design_management_repository + def find_or_create_design_management_repository + design_management_repository || create_design_management_repository end def design_repository @@ -1676,7 +1725,13 @@ class Project < ApplicationRecord end def disabled_integrations - %w[shimo zentao] + return [] if Rails.env.development? + + names = %w[shimo zentao] + + # The Slack Slash Commands integration is only available for customers who cannot use the GitLab for Slack app. + # The GitLab for Slack app integration is only available when enabled through settings. + names << (Gitlab::CurrentSettings.slack_app_enabled ? 'slack_slash_commands' : 'gitlab_slack_application') end def find_or_initialize_integration(name) @@ -2871,7 +2926,13 @@ class Project < ApplicationRecord def default_branch_protected? branch_protection = Gitlab::Access::BranchProtection.new(self.namespace.default_branch_protection) - branch_protection.fully_protected? || branch_protection.developer_can_merge? + branch_protection.fully_protected? || branch_protection.developer_can_merge? || branch_protection.developer_can_initial_push? + end + + def initial_push_to_default_branch_allowed_for_developer? + branch_protection = Gitlab::Access::BranchProtection.new(self.namespace.default_branch_protection) + + !branch_protection.any? || branch_protection.developer_can_push? || branch_protection.developer_can_initial_push? end def environments_for_scope(scope) diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 772a82fa173..92ba02ec777 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -26,6 +26,7 @@ class ProjectFeature < ApplicationRecord feature_flags releases infrastructure + model_experiments ].freeze EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze @@ -79,6 +80,7 @@ class ProjectFeature < ApplicationRecord attribute :infrastructure_access_level, default: ENABLED attribute :feature_flags_access_level, default: ENABLED attribute :environments_access_level, default: ENABLED + attribute :model_experiments_access_level, default: ENABLED attribute :package_registry_access_level, default: -> do if ::Gitlab.config.packages.enabled @@ -132,12 +134,14 @@ class ProjectFeature < ApplicationRecord min_access_level = required_minimum_access_level(feature) column = quoted_access_level_column(feature) - where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))", - { - public_visible: visible, - private_visible: PRIVATE, - authorizations: user.authorizations_for_projects(min_access_level: min_access_level, related_project_column: 'project_features.project_id') - }) + where( + "#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))", + { + public_visible: visible, + private_visible: PRIVATE, + authorizations: user.authorizations_for_projects(min_access_level: min_access_level, related_project_column: 'project_features.project_id') + } + ) else # This has to be added to include features whose value is nil in the db visible << nil diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 3b514d5c5ff..7e0722ab68c 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -7,12 +7,12 @@ class ProjectImportData < ApplicationRecord belongs_to :project, inverse_of: :import_data attr_encrypted :credentials, - key: Settings.attr_encrypted_db_key_base, - marshal: true, - encode: true, - mode: :per_attribute_iv_and_salt, - insecure_mode: true, - algorithm: 'aes-256-cbc' + key: Settings.attr_encrypted_db_key_base, + marshal: true, + encode: true, + mode: :per_attribute_iv_and_salt, + insecure_mode: true, + algorithm: 'aes-256-cbc' # NOTE # We are serializing a project as `data` in an "unsafe" way here diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 1256ef0f2fc..7ca74d4e970 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -31,6 +31,13 @@ class ProjectSetting < ApplicationRecord encode: false, encode_iv: false + attr_encrypted :product_analytics_configurator_connection_string, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false + enum squash_option: { never: 0, always: 1, diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 732dadc03d9..14f6a90e5ed 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -132,11 +132,6 @@ class ProjectStatistics < ApplicationRecord end def self.bulk_increment_statistic(project, key, increments) - unless Feature.enabled?(:project_statistics_bulk_increment, type: :development) - total_amount = Gitlab::Counters::Increment.new(amount: increments.sum(&:amount)) - return increment_statistic(project, key, total_amount) - end - return if project.pending_delete? project.statistics.try do |project_statistics| diff --git a/app/models/project_team.rb b/app/models/project_team.rb index ca1064997af..fbdc88e7b76 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -183,9 +183,11 @@ class ProjectTeam # # Returns a Hash mapping user ID -> maximum access level. def max_member_access_for_user_ids(user_ids) - Gitlab::SafeRequestLoader.execute(resource_key: project.max_member_access_for_resource_key(User), - resource_ids: user_ids, - default_value: Gitlab::Access::NO_ACCESS) do |user_ids| + Gitlab::SafeRequestLoader.execute( + resource_key: project.max_member_access_for_resource_key(User), + resource_ids: user_ids, + default_value: Gitlab::Access::NO_ACCESS + ) do |user_ids| project.project_authorizations .where(user: user_ids) .group(:user_id) @@ -206,9 +208,11 @@ class ProjectTeam end def contribution_check_for_user_ids(user_ids) - Gitlab::SafeRequestLoader.execute(resource_key: "contribution_check_for_users:#{project.id}", - resource_ids: user_ids, - default_value: false) do |user_ids| + Gitlab::SafeRequestLoader.execute( + resource_key: "contribution_check_for_users:#{project.id}", + resource_ids: user_ids, + default_value: false + ) do |user_ids| project.merge_requests .merged .where(author_id: user_ids, target_branch: project.default_branch.to_s) diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index 3155eede2bd..ed1795b43e0 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -9,6 +9,7 @@ module Projects validates :name, presence: true, length: { maximum: 255 } validates :name, uniqueness: { case_sensitive: false }, if: :name_changed? + validate :validate_name_format, if: :name_changed? validates :title, presence: true, length: { maximum: 255 }, on: :create validates :description, length: { maximum: 1024 } @@ -62,6 +63,18 @@ module Projects where(id: topics_to_decrement).where('non_private_projects_count > 0').update_counters(non_private_projects_count: -1) unless topics_to_decrement.empty? end end + + private + + def validate_name_format + return if name.blank? + + # /\R/ - A linebreak: \n, \v, \f, \r \u0085 (NEXT LINE), + # \u2028 (LINE SEPARATOR), \u2029 (PARAGRAPH SEPARATOR) or \r\n. + return unless name =~ /\R/ + + errors.add(:name, 'has characters that are not allowed') + end end end diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index 59440947d71..52fc0a9d1bb 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -25,7 +25,7 @@ class PrometheusAlert < ApplicationRecord validates :environment, :project, :prometheus_metric, :threshold, :operator, presence: true validates :runbook_url, length: { maximum: 255 }, allow_blank: true, - addressable_url: { enforce_sanitization: true, ascii_only: true } + addressable_url: { enforce_sanitization: true, ascii_only: true } validate :require_valid_environment_project! validate :require_valid_metric_project! diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 09a0cfc91dc..aebce59a040 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -26,10 +26,16 @@ class ProtectedBranch < ApplicationRecord end def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil) - # Maintainers, owners and admins are allowed to create the default branch + if project.empty_repo? + member_access = project.team.max_member_access(user.id) - if project.empty_repo? && project.default_branch_protected? + # Admins are always allowed to create the default branch return true if user.admin? || user.can?(:admin_project, project) + + # Developers can push if it is allowed by default branch protection settings + if member_access == Gitlab::Access::DEVELOPER && project.initial_push_to_default_branch_allowed_for_developer? + return true + end end super diff --git a/app/models/release.rb b/app/models/release.rb index 0f00732b62e..7f74872cf67 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -35,8 +35,10 @@ class Release < ApplicationRecord scope :sorted, -> { order(released_at: :desc) } scope :preloaded, -> { - includes(:author, :evidences, :milestones, :links, :sorted_links, - project: [:project_feature, :route, { namespace: :route }]) + includes( + :author, :evidences, :milestones, :links, :sorted_links, + project: [:project_feature, :route, { namespace: :route }] + ) } scope :with_milestones, -> { joins(:milestone_releases) } scope :with_group_milestones, -> { joins(:milestones).where.not(milestones: { group_id: nil }) } @@ -54,6 +56,38 @@ class Release < ApplicationRecord MAX_NUMBER_TO_DISPLAY = 3 + class << self + # In the future, we should support `order_by=semver`; + # see https://gitlab.com/gitlab-org/gitlab/-/issues/352945 + def latest(order_by: 'released_at') + sort_by_attribute("#{order_by}_desc").first + end + + # This query uses LATERAL JOIN to find the latest release for each project. To avoid + # joining the `releases` table, we build an in-memory table using the project ids. + # Example: + # SELECT ... + # FROM (VALUES (PROJECT_ID_1),(PROJECT_ID_2)) project_ids (id) + # INNER JOIN LATERAL (...) + def latest_for_projects(projects, order_by: 'released_at') + return Release.none if projects.empty? + + projects_table = Project.arel_table + releases_table = Release.arel_table + + join_query = Release + .where(projects_table[:id].eq(releases_table[:project_id])) + .sort_by_attribute("#{order_by}_desc") + .limit(1) + + project_ids_list = projects.map { |project| "(#{project.id})" }.join(',') + + Release + .from("(VALUES #{project_ids_list}) projects (id)") + .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{Release.table_name} ON TRUE") + end + end + def to_param tag end diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index 7cead8a42cd..5a6f708f689 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -8,6 +8,12 @@ class ReleaseHighlight ULTIMATE_PACKAGE = 'Ultimate' def self.paginated(page: 1) + result = self.paginated_query(page: page) + result = self.paginated_query(page: result.next_page) while next_page?(result) + result + end + + def self.paginated_query(page:) key = self.cache_key("items:page-#{page}") Rails.cache.fetch(key, expires_in: CACHE_DURATION) do @@ -44,7 +50,7 @@ class ReleaseHighlight rescue Psych::Exception => e Gitlab::ErrorTracking.track_exception(e, file_path: file_path) - nil + [] end def self.whats_new_path @@ -121,6 +127,14 @@ class ReleaseHighlight item['available_in']&.include?(current_package) end + + def self.next_page?(result) + return false unless result + + # if all items for the current page doesn't belong to the current tier + # or failed to parse current YAML, loading next page + result.items == [] && result.next_page.present? + end end ReleaseHighlight.prepend_mod diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb index 44760541290..3ad7efcfcec 100644 --- a/app/models/releases/source.rb +++ b/app/models/releases/source.rb @@ -9,9 +9,7 @@ module Releases class << self def all(project, tag_name) Gitlab::Workhorse::ARCHIVE_FORMATS.map do |format| - Releases::Source.new(project: project, - tag_name: tag_name, - format: format) + Releases::Source.new(project: project, tag_name: tag_name, format: format) end end end @@ -19,9 +17,7 @@ module Releases def url Gitlab::Routing .url_helpers - .project_archive_url(project, - id: File.join(tag_name, archive_prefix), - format: format) + .project_archive_url(project, id: File.join(tag_name, archive_prefix), format: format) end def hook_attrs diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index b830cf313af..8b2f3bdcedf 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -11,12 +11,12 @@ class RemoteMirror < ApplicationRecord UNPROTECTED_BACKOFF_DELAY = 5.minutes attr_encrypted :credentials, - key: Settings.attr_encrypted_db_key_base, - marshal: true, - encode: true, - mode: :per_attribute_iv_and_salt, - insecure_mode: true, - algorithm: 'aes-256-cbc' + key: Settings.attr_encrypted_db_key_base, + marshal: true, + encode: true, + mode: :per_attribute_iv_and_salt, + insecure_mode: true, + algorithm: 'aes-256-cbc' belongs_to :project, inverse_of: :remote_mirrors @@ -31,10 +31,8 @@ class RemoteMirror < ApplicationRecord scope :stuck, -> do started - .where('(last_update_started_at < ? AND last_update_at IS NOT NULL)', - MAX_INCREMENTAL_RUNTIME.ago) - .or(where('(last_update_started_at < ? AND last_update_at IS NULL)', - MAX_FIRST_RUNTIME.ago)) + .where('(last_update_started_at < ? AND last_update_at IS NOT NULL)', MAX_INCREMENTAL_RUNTIME.ago) + .or(where('(last_update_started_at < ? AND last_update_at IS NULL)', MAX_FIRST_RUNTIME.ago)) end state_machine :update_status, initial: :none do diff --git a/app/models/repository.rb b/app/models/repository.rb index e942157993b..b21df6baf0e 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -691,7 +691,7 @@ class Repository @head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths) end - def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil) + def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil) if sha == :head return if empty? || root_ref.nil? @@ -699,10 +699,11 @@ class Repository return head_tree(skip_flat_paths: skip_flat_paths) else sha = head_commit.sha + ref_type = nil end end - Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params) + Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params, ref_type: ref_type) end def blob_at_branch(branch_name, path) @@ -880,10 +881,12 @@ class Repository end def merge(user, source_sha, merge_request, message) - merge_to_branch(user, - source_sha: source_sha, - target_branch: merge_request.target_branch, - message: message) do |commit_id| + merge_to_branch( + user, + source_sha: source_sha, + target_branch: merge_request.target_branch, + message: message + ) do |commit_id| merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id) nil # Return value does not matter. end @@ -1136,10 +1139,13 @@ class Repository end def squash(user, merge_request, message) - raw.squash(user, start_sha: merge_request.diff_start_sha, - end_sha: merge_request.diff_head_sha, - author: merge_request.author, - message: message) + raw.squash( + user, + start_sha: merge_request.diff_start_sha, + end_sha: merge_request.diff_head_sha, + author: merge_request.author, + message: message + ) end def submodule_links @@ -1271,11 +1277,13 @@ class Repository end def initialize_raw_repository - Gitlab::Git::Repository.new(shard, - disk_path + '.git', - repo_type.identifier_for_container(container), - container.full_path, - container: container) + Gitlab::Git::Repository.new( + shard, + disk_path + '.git', + repo_type.identifier_for_container(container), + container.full_path, + container: container + ) end end diff --git a/app/models/resource_events/abuse_report_event.rb b/app/models/resource_events/abuse_report_event.rb index 2cddfc393e3..59f88a63998 100644 --- a/app/models/resource_events/abuse_report_event.rb +++ b/app/models/resource_events/abuse_report_event.rb @@ -2,6 +2,8 @@ module ResourceEvents class AbuseReportEvent < ApplicationRecord + include AbuseReportEventsHelper + belongs_to :abuse_report, optional: false belongs_to :user @@ -28,5 +30,9 @@ module ResourceEvents other: 8, unconfirmed: 9 } + + def success_message + success_message_for_action(action) + end end end diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index dddd4d0fe84..1cc77501d8d 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -34,8 +34,9 @@ class ResourceTimeboxEvent < ResourceEvent case self when ResourceMilestoneEvent - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user, - project: issue.project) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action( + author: user, project: issue.project + ) else # no-op end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 580e4cd277c..c2fd8b20942 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -3,7 +3,7 @@ class SentNotification < ApplicationRecord include IgnorableColumns - serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize + ignore_column %i[line_code note_type position], remove_with: '16.3', remove_after: '2023-07-22' belongs_to :project belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations @@ -46,7 +46,11 @@ class SentNotification < ApplicationRecord commit_id: commit_id ) - create(attrs) + # Non-sticky write is used as `.record` is only used in ActionMailer + # where there are no queries to SentNotification. + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do + create(attrs) + end end def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {}) @@ -80,25 +84,6 @@ class SentNotification < ApplicationRecord end end - def position=(new_position) - if new_position.is_a?(String) - new_position = begin - Gitlab::Json.parse(new_position) - rescue StandardError - nil - end - end - - if new_position.is_a?(Hash) - new_position = new_position.with_indifferent_access - new_position = Gitlab::Diff::Position.new(new_position) - else - new_position = nil - end - - super(new_position) - end - def to_param self.reply_key end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 3c40f4beedc..d4f8c1b3b0b 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -242,7 +242,7 @@ class Snippet < ApplicationRecord end def hook_attrs - attributes + attributes.merge('url' => Gitlab::UrlBuilder.build(self)) end def file_name @@ -261,13 +261,12 @@ class Snippet < ApplicationRecord notes.includes(:author) end - def check_for_spam?(user:) - visibility_level_changed?(to: Snippet::PUBLIC) || - (public? && (title_changed? || description_changed?)) + def check_for_spam?(*) + visibility_level_changed?(to: Snippet::PUBLIC) || (public? && spammable_attribute_changed?) end - def spammable_entity_type - 'snippet' + def supports_recaptcha? + true end def to_ability_name diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb index 138feb6ab29..8ef2c579a5a 100644 --- a/app/models/snippet_user_mention.rb +++ b/app/models/snippet_user_mention.rb @@ -3,7 +3,7 @@ class SnippetUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' belongs_to :snippet belongs_to :note diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 267be5fe5c2..58a154b8986 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -5,7 +5,7 @@ class Suggestion < ApplicationRecord include Suggestible include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' belongs_to :note, inverse_of: :suggestions validates :note, presence: true, unless: :importing? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 0e0534d45ae..4e71a13a3a1 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -4,7 +4,7 @@ class SystemNoteMetadata < ApplicationRecord include Importable include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' # These notes's action text might contain a reference that is external. # We should always force a deep validation upon references that are found diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 93c128c989c..ecd3e27a9c4 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -28,7 +28,7 @@ module Terraform validates :project_id, :name, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, - format: { with: HEX_REGEXP, message: 'only allows hex characters' } + format: { with: HEX_REGEXP, message: 'only allows hex characters' } attribute :uuid, default: -> { SecureRandom.hex(UUID_LENGTH / 2) } diff --git a/app/models/time_tracking/timelog_category.rb b/app/models/time_tracking/timelog_category.rb index 246e78f31cb..67565039acd 100644 --- a/app/models/time_tracking/timelog_category.rb +++ b/app/models/time_tracking/timelog_category.rb @@ -18,9 +18,9 @@ module TimeTracking validates :description, length: { maximum: 1024 } validates :color, color: true, allow_blank: false, length: { maximum: 7 } validates :billing_rate, - if: :billable?, - presence: true, - numericality: { greater_than: 0 } + if: :billable?, + presence: true, + numericality: { greater_than: 0 } DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc') diff --git a/app/models/timelog.rb b/app/models/timelog.rb index dc976816ad9..eb72456b435 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -3,8 +3,9 @@ class Timelog < ApplicationRecord include Importable include IgnorableColumns + include Sortable - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' before_save :set_project @@ -45,11 +46,13 @@ class Timelog < ApplicationRecord issue || merge_request end - def self.sort_by_field(field, direction) - if direction == :asc - order_scope_asc(field) - else - order_scope_desc(field) + def self.sort_by_field(field) + case field.to_s + when 'spent_at_asc' then order_scope_asc(:spent_at) + when 'spent_at_desc' then order_scope_desc(:spent_at) + when 'time_spent_asc' then order_scope_asc(:time_spent) + when 'time_spent_desc' then order_scope_desc(:time_spent) + else order_by(field) end end diff --git a/app/models/todo.rb b/app/models/todo.rb index e1b5076e3d8..724f97c4812 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -6,7 +6,7 @@ class Todo < ApplicationRecord include EachBatch include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' # Time to wait for todos being removed when not visible for user anymore. # Prevents TODOs being removed by mistake, for example, removing access from a user diff --git a/app/models/tree.rb b/app/models/tree.rb index c6adf5c263c..8622eb793c1 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -3,17 +3,25 @@ class Tree include Gitlab::Utils::StrongMemoize - attr_accessor :repository, :sha, :path, :entries, :cursor + attr_accessor :repository, :sha, :path, :entries, :cursor, :ref_type - def initialize(repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil) + def initialize( + repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil, + ref_type: nil) path = '/' if path.blank? @repository = repository @sha = sha @path = path - + @ref_type = ExtractsRef.ref_type(ref_type) git_repo = @repository.raw_repository - @entries, @cursor = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive, skip_flat_paths, pagination_params) + + ref = ExtractsRef.qualify_ref(@sha, ref_type) + + @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, pagination_params) + @entries.each do |entry| + entry.ref_type = self.ref_type + end end def readme_path diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb index d2b8eab9f0d..3c31909fb07 100644 --- a/app/models/uploads/fog.rb +++ b/app/models/uploads/fog.rb @@ -2,11 +2,8 @@ module Uploads class Fog < Base - include ::Gitlab::Utils::StrongMemoize - - def available? - object_store.enabled - end + include ::ObjectStorage::FogHelpers + extend ::Gitlab::Utils::Override def keys(relation) return [] unless available? @@ -20,39 +17,9 @@ module Uploads private - def delete_object(key) - return unless available? - - connection.delete_object(bucket_name, object_key(key)) - - # So far, only GoogleCloudStorage raises an exception when the file is not found. - # Other providers support idempotent requests and does not raise an error - # when the file is missing. - rescue ::Google::Apis::ClientError => e - Gitlab::ErrorTracking.log_exception(e) - end - - def object_store - Gitlab.config.uploads.object_store - end - - def bucket_name - object_store.remote_directory - end - - def object_key(key) - # We allow administrators to create "sub buckets" by setting a prefix. - # This makes it possible to deploy GitLab with only one object storage - # bucket. This mirrors the implementation in app/uploaders/object_storage.rb. - File.join([object_store.bucket_prefix, key].compact) - end - - def connection - return unless available? - - strong_memoize(:connection) do - ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys) - end + override :storage_location_identifier + def storage_location_identifier + :uploads end end end diff --git a/app/models/user.rb b/app/models/user.rb index 50da6f9e491..96cdbb192bc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -31,6 +31,7 @@ class User < ApplicationRecord include RestrictedSignup include StripAttribute include EachBatch + include SafelyChangeColumnDefault DEFAULT_NOTIFICATION_LEVEL = :participating @@ -56,8 +57,14 @@ class User < ApplicationRecord FORBIDDEN_SEARCH_STATES = %w(blocked banned ldap_blocked).freeze - add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } - add_authentication_token_field :feed_token + INCOMING_MAIL_TOKEN_PREFIX = 'glimt-' + FEED_TOKEN_PREFIX = 'glft-' + + columns_changing_default :notified_of_own_activity + + # lib/tasks/tokens.rake needs to be updated when changing mail and feed tokens + add_authentication_token_field :incoming_email_token, token_generator: -> { self.generate_incoming_mail_token } + add_authentication_token_field :feed_token, format_with_prefix: :prefix_for_feed_token add_authentication_token_field :static_object_token, encrypted: :optional attribute :admin, default: false @@ -91,6 +98,7 @@ class User < ApplicationRecord # Must be included after `devise` include EncryptedUserPassword + include RecoverableByAnyEmail include AdminChangedPasswordNotifier @@ -215,8 +223,11 @@ class User < ApplicationRecord has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - 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 :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent + has_many :reported_abuse_reports, dependent: :nullify, foreign_key: :reporter_id, class_name: "AbuseReport", inverse_of: :reporter # rubocop:disable Cop/ActiveRecordDependent + has_many :assigned_abuse_reports, foreign_key: :assignee_id, class_name: "AbuseReport", inverse_of: :assignee + has_many :resolved_abuse_reports, foreign_key: :resolved_by_id, class_name: "AbuseReport", inverse_of: :resolved_by + has_many :abuse_events, foreign_key: :user_id, class_name: 'Abuse::Event', inverse_of: :user 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' @@ -343,7 +354,7 @@ class User < ApplicationRecord enum dashboard: { projects: 0, stars: 1, your_activity: 10, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 } # User's Project preference - enum project_view: { readme: 0, activity: 1, files: 2 } + enum project_view: { readme: 0, activity: 1, files: 2, wiki: 3 } # User's role enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true @@ -360,6 +371,7 @@ class User < ApplicationRecord :sourcegraph_enabled, :sourcegraph_enabled=, :gitpod_enabled, :gitpod_enabled=, :setup_for_company, :setup_for_company=, + :project_shortcut_buttons, :project_shortcut_buttons=, :render_whitespace_in_code, :render_whitespace_in_code=, :markdown_surround_selection, :markdown_surround_selection=, :markdown_automatic_lists, :markdown_automatic_lists=, @@ -960,6 +972,10 @@ class User < ApplicationRecord def get_ids_by_ids_or_usernames(ids, usernames) by_ids_or_usernames(ids, usernames).pluck(:id) end + + def generate_incoming_mail_token + "#{INCOMING_MAIL_TOKEN_PREFIX}#{SecureRandom.hex.to_i(16).to_s(36)}" + end end # @@ -1664,16 +1680,19 @@ class User < ApplicationRecord DELETION_DELAY_IN_DAYS = 7.days def delete_async(deleted_by:, params: {}) - is_deleting_own_record = deleted_by.id == id + if should_delay_delete?(deleted_by) + new_note = format(_("User deleted own account on %{timestamp}"), timestamp: Time.zone.now) + self.note = "#{new_note}\n#{note}".strip - if is_deleting_own_record && ::Feature.enabled?(:delay_delete_own_user) - block + block_or_ban DeleteUserWorker.perform_in(DELETION_DELAY_IN_DAYS, deleted_by.id, id, params.to_h) - else - block if params[:hard_delete] - DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h) + return end + + block if params[:hard_delete] + + DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h) end # rubocop: disable CodeReuse/ServiceClass @@ -2155,6 +2174,14 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end + def dismissed_callout_before?(feature_name, dismissed_before) + callout = callouts_by_feature_name[feature_name] + + return false unless callout + + callout.dismissed_before?(dismissed_before) + end + def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil) source_feature_name = "#{feature_name}_#{group.id}" callout = group_callouts_by_feature_name[source_feature_name] @@ -2252,10 +2279,26 @@ class User < ApplicationRecord namespace_commit_emails.find_by(namespace: project.root_namespace) end + def spammer? + spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD + end + def spam_score abuse_trust_scores.spamcheck.average(:score) || 0.0 end + def telesign_score + abuse_trust_scores.telesign.order(created_at: :desc).first&.score || 0.0 + end + + def arkose_global_score + abuse_trust_scores.arkose_global_score.order(created_at: :desc).first&.score || 0.0 + end + + def arkose_custom_score + abuse_trust_scores.arkose_custom_score.order(created_at: :desc).first&.score || 0.0 + end + def trust_scores_for_source(source) abuse_trust_scores.where(source: source) end @@ -2267,6 +2310,12 @@ class User < ApplicationRecord } end + def namespace_commit_email_for_namespace(namespace) + return if namespace.nil? + + namespace_commit_emails.find_by(namespace: namespace) + end + protected # override, from Devise::Validatable @@ -2305,6 +2354,45 @@ class User < ApplicationRecord private + def block_or_ban + if spammer? && account_age_in_days < 7 + ban_and_report + else + block + end + end + + def ban_and_report + msg = 'Potential spammer account deletion' + attrs = { user_id: id, reporter: User.security_bot, category: 'spam' } + abuse_report = AbuseReport.find_by(attrs) + + if abuse_report.nil? + abuse_report = AbuseReport.create!(attrs.merge(message: msg)) + else + abuse_report.update(message: "#{abuse_report.message}\n\n#{msg}") + end + + UserCustomAttribute.set_banned_by_abuse_report(abuse_report) + + ban + end + + def has_possible_spam_contributions? + events + .for_action('commented') + .or(events.for_action('created').where(target_type: %w[Issue MergeRequest])) + .any? + end + + def should_delay_delete?(deleted_by) + is_deleting_own_record = deleted_by.id == id + + is_deleting_own_record && + ::Feature.enabled?(:delay_delete_own_user) && + has_possible_spam_contributions? + end + def pbkdf2? return false unless otp_backup_codes&.any? @@ -2357,9 +2445,10 @@ class User < ApplicationRecord def authorized_groups_without_shared_membership Group.from_union( [ - groups.select(*Namespace.cached_column_list), - authorized_projects.joins(:namespace).select(*Namespace.cached_column_list) - ]) + groups, + Group.id_in(authorized_projects.select(:namespace_id)) + ] + ) end def authorized_groups_with_shared_membership @@ -2515,6 +2604,10 @@ class User < ApplicationRecord Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) end + + def prefix_for_feed_token + FEED_TOKEN_PREFIX + end end User.prepend_mod_with('User') diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 9a186cb9038..63a5ee9770f 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -35,6 +35,14 @@ class UserCustomAttribute < ApplicationRecord .select(:value) end + def set_banned_by_abuse_report(abuse_report) + return unless abuse_report + + custom_attribute = { user_id: abuse_report.user.id, key: AUTO_BANNED_BY_ABUSE_REPORT_ID, value: abuse_report.id } + + upsert_custom_attributes([custom_attribute]) + end + private def blocked_users diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 293a20fcc5a..5c9a73571c0 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -5,6 +5,7 @@ class UserDetail < ApplicationRecord extend ::Gitlab::Utils::Override ignore_column :requires_credit_card_verification, remove_with: '16.1', remove_after: '2023-06-22' + ignore_column :provisioned_by_group_at, remove_with: '16.3', remove_after: '2023-07-22' REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 90449411f8a..4d517408154 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -2,12 +2,15 @@ class UserPreference < ApplicationRecord include IgnorableColumns + include SafelyChangeColumnDefault # We could use enums, but Rails 4 doesn't support multiple # enum options with same name for multiple fields, also it creates # extra methods that aren't really needed here. NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze + columns_changing_default :tab_width, :time_display_relative, :render_whitespace_in_code + belongs_to :user scope :with_user, -> { joins(:user) } @@ -35,6 +38,7 @@ class UserPreference < ApplicationRecord attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT } attribute :time_display_relative, default: true attribute :render_whitespace_in_code, default: false + attribute :project_shortcut_buttons, default: true enum visibility_pipeline_id_type: { id: 0, iid: 1 } diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 896cccfa0e5..38e518b6d3e 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -65,7 +65,14 @@ module Users artifacts_management_page_feedback_banner: 62, # 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233 branch_rules_info_callout: 65, - create_runner_workflow_banner: 66 + create_runner_workflow_banner: 66, + repository_storage_limit_banner_info_threshold: 67, # EE-only + repository_storage_limit_banner_warning_threshold: 68, # EE-only + repository_storage_limit_banner_alert_threshold: 69, # EE-only + repository_storage_limit_banner_error_threshold: 70, # EE-only + new_navigation_callout: 71, + code_suggestions_third_party_callout: 72, # EE-only + namespace_over_storage_users_combined_alert: 73 # EE-only } validates :feature_name, diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb index 280a819e4d5..483d0d785a5 100644 --- a/app/models/users/calloutable.rb +++ b/app/models/users/calloutable.rb @@ -13,5 +13,9 @@ module Users def dismissed_after?(dismissed_after) dismissed_at > dismissed_after end + + def dismissed_before?(dismissed_before) + dismissed_at < dismissed_before + end end end diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 1cc9f1f50ad..c5946197b6f 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -25,7 +25,12 @@ module Users preview_usage_quota_free_plan_alert: 15, # EE-only enforcement_at_limit_alert: 16, # EE-only web_hook_disabled: 17, # EE-only - unlimited_members_during_trial_alert: 18 # EE-only + unlimited_members_during_trial_alert: 18, # EE-only + repository_storage_limit_banner_info_threshold: 19, # EE-only + repository_storage_limit_banner_warning_threshold: 20, # EE-only + repository_storage_limit_banner_alert_threshold: 21, # EE-only + repository_storage_limit_banner_error_threshold: 22, # EE-only + namespace_over_storage_users_combined_alert: 23 # EE-only } validates :group, presence: true diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 700e4e0e0ec..650e8942132 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -9,12 +9,6 @@ class Vulnerability < ApplicationRecord scope :with_projects, -> { includes(:project) } - # Policy class inferring logic is causing performance - # issues therefore we need to explicitly set it. - def self.declarative_policy_class - :VulnerabilityPolicy - end - def self.link_reference_pattern nil end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 24d1078516e..9f28ffbf7b6 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -4,7 +4,7 @@ class WorkItem < Issue include Gitlab::Utils::StrongMemoize COMMON_QUICK_ACTIONS_COMMANDS = [ - :title, :reopen, :close, :cc, :tableflip, :shrug, :type + :title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to ].freeze self.table_name = 'issues' @@ -168,8 +168,9 @@ class WorkItem < Issue 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 + _('cannot be changed to %{new_type} when linked to a parent %{parent_type}.'), + new_type: work_item_type.name.downcase, + parent_type: parent_link.work_item_parent.work_item_type.name.downcase ) ) end diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb index b54b84f1e1b..a8b1b3f9a59 100644 --- a/app/models/work_items/widgets/base.rb +++ b/app/models/work_items/widgets/base.rb @@ -16,9 +16,13 @@ module WorkItems end def self.callback_class - Issuable::Callbacks.const_get(name.demodulize, false) + WorkItems::Callbacks.const_get(name.demodulize, false) rescue NameError - nil + begin + Issuable::Callbacks.const_get(name.demodulize, false) + rescue NameError + nil + end end def type diff --git a/app/policies/audit_events/definition_policy.rb b/app/policies/audit_events/definition_policy.rb new file mode 100644 index 00000000000..4109c59fb77 --- /dev/null +++ b/app/policies/audit_events/definition_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module AuditEvents + class DefinitionPolicy < ::BasePolicy + condition(:read_audit_events_definitions_enabled) do + true + end + + rule { read_audit_events_definitions_enabled }.enable :audit_event_definitions + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 285721de387..94a67f5b5c8 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -109,6 +109,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy @subject.runner_registration_enabled? end + condition(:raise_admin_package_to_owner_enabled) do + Feature.enabled?(:raise_group_admin_package_permission_to_owner, @subject) + end + rule { can?(:read_group) & design_management_enabled }.policy do enable :read_design_activity end @@ -159,6 +163,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :award_achievement end + rule { can?(:owner_access) & achievements_enabled }.policy do + enable :destroy_user_achievement + end + rule { ~public_group & ~has_access }.prevent :read_counts rule { ~can_read_group_member }.policy do @@ -198,11 +206,11 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_package enable :read_crm_organization enable :read_crm_contact + enable :read_confidential_issues end rule { maintainer }.policy do enable :destroy_package - enable :admin_package enable :create_projects enable :import_projects enable :admin_pipeline @@ -304,7 +312,11 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy rule { dependency_proxy_access_allowed & dependency_proxy_available } .enable :read_dependency_proxy - rule { maintainer & dependency_proxy_available }.policy do + rule { maintainer & dependency_proxy_available & ~raise_admin_package_to_owner_enabled }.policy do + enable :admin_dependency_proxy + end + + rule { owner & dependency_proxy_available & raise_admin_package_to_owner_enabled }.policy do enable :admin_dependency_proxy end @@ -370,6 +382,9 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy # Should be matched with ProjectPolicy#read_internal_note rule { admin | reporter }.enable :read_internal_note + rule { maintainer & ~raise_admin_package_to_owner_enabled }.enable :admin_package + rule { owner & raise_admin_package_to_owner_enabled }.enable :admin_package + def access_level(for_any_session: false) return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS unless user_is_user? diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb new file mode 100644 index 00000000000..cac8d07811d --- /dev/null +++ b/app/policies/organizations/organization_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationPolicy < BasePolicy + rule { admin }.policy do + enable :admin_organization + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 47d8d0eef3e..c70dc288710 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -163,6 +163,14 @@ class ProjectPolicy < BasePolicy condition(:service_desk_enabled) { @subject.service_desk_enabled? } with_scope :subject + condition(:model_experiments_enabled) do + Feature.enabled?(:ml_experiment_tracking, @subject) && @subject.feature_available?(:model_experiments, @user) + end + + with_scope :subject + condition(:model_registry_enabled) { Feature.enabled?(:model_registry, @subject) } + + with_scope :subject condition(:resource_access_token_feature_available) do resource_access_token_feature_available? end @@ -220,6 +228,7 @@ class ProjectPolicy < BasePolicy feature_flags releases infrastructure + model_experiments ] features.each do |f| @@ -892,6 +901,14 @@ class ProjectPolicy < BasePolicy enable :add_catalog_resource end + rule { model_registry_enabled }.policy do + enable :read_model_registry + end + + rule { model_experiments_enabled }.policy do + enable :read_model_experiments + end + private def user_is_user? diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 1078eda38e7..2fd198b8cf4 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -31,6 +31,7 @@ class UserPolicy < BasePolicy enable :read_user_groups enable :read_saved_replies enable :read_user_email_address + enable :admin_user_email_address end rule { default }.enable :read_user_profile diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index f25436c54be..cd473152b41 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -56,23 +56,23 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def web_url - url_helpers.project_blob_url(project, ref_qualified_path) + url_helpers.project_blob_url(*path_params) end def web_path - url_helpers.project_blob_path(project, ref_qualified_path) + url_helpers.project_blob_path(*path_params) end def edit_blob_path - url_helpers.project_edit_blob_path(project, ref_qualified_path) + url_helpers.project_edit_blob_path(*path_params) end def raw_path - url_helpers.project_raw_path(project, ref_qualified_path) + url_helpers.project_raw_path(*path_params) end def replace_path - url_helpers.project_update_blob_path(project, ref_qualified_path) + url_helpers.project_update_blob_path(*path_params) end def pipeline_editor_path @@ -164,6 +164,18 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated private + def path_params + if ref_type.present? + [project, ref_qualified_path, { ref_type: ref_type }] + else + [project, ref_qualified_path] + end + end + + def ref_type + blob.ref_type + end + def url_helpers Gitlab::Routing.url_helpers end @@ -179,7 +191,12 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def ref_qualified_path - File.join(blob.commit_id, blob.path) + # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`. + # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`. + + commit_id = ExtractsRef.unqualify_ref(blob.commit_id, ref_type) + + File.join(commit_id, blob.path) end def load_all_blob_data diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 8c9ff49b0e7..3aba5a2c7ed 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -65,7 +65,7 @@ module Ci '%.2f' % pipeline.coverage end - def ref_text + def ref_text_legacy if pipeline.detached_merge_request_pipeline? _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}") .html_safe % { @@ -87,6 +87,28 @@ module Ci end end + def ref_text + if pipeline.detached_merge_request_pipeline? + _("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}") + .html_safe % { + link_to_merge_request: link_to_merge_request, + link_to_merge_request_source_branch: link_to_merge_request_source_branch + } + elsif pipeline.merged_result_pipeline? + _("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}") + .html_safe % { + link_to_merge_request: link_to_merge_request, + link_to_merge_request_source_branch: link_to_merge_request_source_branch, + link_to_merge_request_target_branch: link_to_merge_request_target_branch + } + elsif pipeline.ref && pipeline.ref_exists? + _("For %{link_to_pipeline_ref}") + .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref } + elsif pipeline.ref + _("For %{ref}").html_safe % { ref: plain_ref_name } + end + end + def all_related_merge_request_text(limit: nil) if all_related_merge_requests.none? _("No related merge requests found.") @@ -106,7 +128,7 @@ module Ci def link_to_pipeline_ref ApplicationController.helpers.link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), - class: "ref-name") + class: "ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2") end def link_to_merge_request diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 12f4b0496e4..8d2baa6ee99 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -197,7 +197,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def source_branch_link if source_branch_exists? - link_to(source_branch, source_branch_commits_path, class: 'ref-name') + link_to(source_branch, source_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') else content_tag(:span, source_branch, class: 'ref-name') end @@ -205,7 +205,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def target_branch_link if target_branch_exists? - link_to(target_branch, target_branch_commits_path, class: 'ref-name') + link_to(target_branch, target_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') else content_tag(:span, target_branch, class: 'ref-name') end diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb index 58ec2aee471..7f0bd9d6c11 100644 --- a/app/presenters/ml/candidate_details_presenter.rb +++ b/app/presenters/ml/candidate_details_presenter.rb @@ -53,7 +53,9 @@ module Ml { user: { path: user_path(user), - username: user.username + username: user.username, + name: user.name, + avatar: user.avatar_url } } end @@ -64,6 +66,7 @@ module Ml { merge_request: { path: project_merge_request_path(mr.project, mr), + iid: mr.iid, title: mr.title } } diff --git a/app/presenters/packages/conan/package_presenter.rb b/app/presenters/packages/conan/package_presenter.rb index 0c7a81038dd..2fab074c69c 100644 --- a/app/presenters/packages/conan/package_presenter.rb +++ b/app/presenters/packages/conan/package_presenter.rb @@ -80,10 +80,9 @@ module Packages def package_files return unless @package - strong_memoize(:package_files) do - @package.installable_package_files.preload_conan_file_metadata - end + @package.installable_package_files.preload_conan_file_metadata end + strong_memoize_attr :package_files def matching_reference?(package_file) package_file.conan_file_metadatum.conan_package_reference == conan_package_reference diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb index 9f1dee17cea..f87f447fb23 100644 --- a/app/presenters/packages/nuget/packages_metadata_presenter.rb +++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb @@ -59,11 +59,10 @@ module Packages end def sorted_versions - strong_memoize(:sorted_versions) do - versions = @packages.map(&:version).compact - VersionSorter.sort(versions) - end + versions = @packages.filter_map(&:version) + sort_versions(versions) end + strong_memoize_attr :sorted_versions end end end diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb index 82ed80d8372..16c32a9d0d0 100644 --- a/app/presenters/packages/nuget/presenter_helpers.rb +++ b/app/presenters/packages/nuget/presenter_helpers.rb @@ -4,8 +4,8 @@ module Packages module Nuget module PresenterHelpers include ::API::Helpers::RelatedResourcesHelpers + include Packages::Nuget::VersionHelpers - BLANK_STRING = '' PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup' PACKAGE_DEPENDENCY = 'PackageDependency' @@ -45,14 +45,13 @@ module Packages def catalog_entry_for(package) { json_url: json_url_for(package), - authors: BLANK_STRING, dependency_groups: dependency_groups_for(package), package_name: package.name, package_version: package.version, archive_url: archive_url_for(package), - summary: BLANK_STRING, tags: tags_for(package), - metadatum: metadatum_for(package) + metadatum: metadatum_for(package), + published: package.created_at.iso8601 } end @@ -98,8 +97,8 @@ module Packages metadatum = package.nuget_metadatum return {} unless metadatum - metadatum.slice(:project_url, :license_url, :icon_url) - .compact + metadatum.slice(:authors, :description, :project_url, :license_url, :icon_url) + .compact end def base_path_for(package) diff --git a/app/presenters/packages/nuget/search_results_presenter.rb b/app/presenters/packages/nuget/search_results_presenter.rb index dc391c380f3..020e8656a36 100644 --- a/app/presenters/packages/nuget/search_results_presenter.rb +++ b/app/presenters/packages/nuget/search_results_presenter.rb @@ -14,26 +14,23 @@ module Packages end def data - strong_memoize(:data) do - @search.results.group_by(&:name).map do |package_name, packages| - latest_version = latest_version(packages) - latest_package = packages.find { |pkg| pkg.version == latest_version } - - { - type: 'Package', - authors: '', - name: package_name, - version: latest_version, - versions: build_package_versions(packages), - summary: '', - total_downloads: 0, - verified: true, - tags: tags_for(latest_package), - metadatum: metadatum_for(latest_package) - } - end + @search.results.group_by(&:name).map do |package_name, packages| + latest_version = latest_version(packages) + latest_package = packages.find { |pkg| pkg.version == latest_version } + + { + type: 'Package', + name: package_name, + version: latest_version, + versions: build_package_versions(packages), + total_downloads: 0, + verified: true, + tags: tags_for(latest_package), + metadatum: metadatum_for(latest_package) + } end end + strong_memoize_attr :data private @@ -48,8 +45,8 @@ module Packages end def latest_version(packages) - versions = packages.map(&:version).compact - VersionSorter.sort(versions).last # rubocop: disable Style/RedundantSort + versions = packages.filter_map(&:version) + sort_versions(versions).last end end end diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb index 033a1845c1c..b262735508c 100644 --- a/app/presenters/packages/nuget/service_index_presenter.rb +++ b/app/presenters/packages/nuget/service_index_presenter.rb @@ -35,12 +35,13 @@ module Packages end def resources - available_services.map { |service| build_service(service) } - .flatten + available_services.flat_map { |service| build_service(service) } end private + attr_reader :project_or_group + def available_services case scope when :group @@ -77,13 +78,13 @@ module Packages end def scope - return :project if @project_or_group.is_a?(::Project) - return :group if @project_or_group.is_a?(::Group) + return :project if project_or_group.is_a?(::Project) + return :group if project_or_group.is_a?(::Group) end def download_service_url params = { - id: @project_or_group.id, + id: project_or_group.id, package_name: nil, package_version: nil, package_filename: nil @@ -97,7 +98,7 @@ module Packages def metadata_service_url params = { - id: @project_or_group.id, + id: project_or_group.id, package_name: nil, package_version: nil } @@ -119,18 +120,18 @@ module Packages def search_service_url case scope when :group - api_v4_groups___packages_nuget_query_path(id: @project_or_group.id) + api_v4_groups___packages_nuget_query_path(id: project_or_group.id) when :project - api_v4_projects_packages_nuget_query_path(id: @project_or_group.id) + api_v4_projects_packages_nuget_query_path(id: project_or_group.id) end end def publish_service_url - api_v4_projects_packages_nuget_path(id: @project_or_group.id) + api_v4_projects_packages_nuget_path(id: project_or_group.id) end def symbol_service_url - api_v4_projects_packages_nuget_symbolpackage_path(id: @project_or_group.id) + api_v4_projects_packages_nuget_symbolpackage_path(id: project_or_group.id) end end end diff --git a/app/presenters/packages/nuget/version_helpers.rb b/app/presenters/packages/nuget/version_helpers.rb new file mode 100644 index 00000000000..8c9c82791b3 --- /dev/null +++ b/app/presenters/packages/nuget/version_helpers.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module VersionHelpers + private + + def sort_versions(versions) + versions.sort { |a, b| compare_versions(a, b) } + end + + # NuGet version sorting algorithm as per https://semver.org/spec/v2.0.0.html#spec-item-11 + def compare_versions(version_a, version_b) + return 0 if version_a == version_b + return 1 if version_b.nil? + return -1 if version_a.nil? + + a_without_build_meta, a_build_meta = version_a.split('+', 2) + b_without_build_meta, b_build_meta = version_b.split('+', 2) + + a_core, a_pre = a_without_build_meta.split(/-/, 2) + b_core, b_pre = b_without_build_meta.split(/-/, 2) + + a_core_parts = a_core.split('.') + b_core_parts = b_core.split('.') + + compare_core_parts(a_core_parts, b_core_parts) || + compare_pre_release_parts(a_pre, b_pre) || + pick_non_nil(a_pre, b_pre) || + compare_build_meta_parts(a_build_meta, b_build_meta) + end + + def compare_core_parts(a_core_parts, b_core_parts) + while a_core_parts.any? || b_core_parts.any? + a_part = a_core_parts.shift&.to_i || 0 + b_part = b_core_parts.shift&.to_i || 0 + return a_part <=> b_part if a_part != b_part + end + end + + def compare_pre_release_parts(a_pre, b_pre) + return unless a_pre && b_pre + + a_pre_parts = a_pre.split('.').map(&:downcase) + b_pre_parts = b_pre.split('.').map(&:downcase) + + while a_pre_parts.any? || b_pre_parts.any? + a_pre_part = a_pre_parts.shift + b_pre_part = b_pre_parts.shift + + # Empty parts are considered lower + return -1 if a_pre_part.nil? + return 1 if b_pre_part.nil? + + a_num = a_pre_part.to_i + b_num = b_pre_part.to_i + next if a_num == b_num && a_pre_part.to_s == b_pre_part.to_s # Both are same numeric/alphanumeric parts + + return select_numeric_before_alphanumeric(a_num, a_pre_part, b_num, b_pre_part) || + compare_numeric_parts(a_pre_part, a_num, b_pre_part, b_num) || + a_pre_part <=> b_pre_part + end + end + + def compare_build_meta_parts(a_build_meta, b_build_meta) + (a_build_meta || '').casecmp(b_build_meta || '') + end + + def select_numeric_before_alphanumeric(a_num, a_pre_part, b_num, b_pre_part) + return -1 if a_num != b_num && numeric?(a_pre_part) && !numeric?(b_pre_part) + return 1 if a_num != b_num && !numeric?(a_pre_part) && numeric?(b_pre_part) + end + + def numeric?(pre_part) + !!Integer(pre_part, exception: false) + end + + def compare_numeric_parts(a_pre_part, a_num, b_pre_part, b_num) + a_num <=> b_num if a_num != b_num && numeric?(a_pre_part) && numeric?(b_pre_part) + end + + def pick_non_nil(var_a, var_b) + return -1 if var_a && !var_b + return 1 if !var_a && var_b + end + end + end +end diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb index 0b313d81360..3f4a9f13c36 100644 --- a/app/presenters/tree_entry_presenter.rb +++ b/app/presenters/tree_entry_presenter.rb @@ -4,10 +4,23 @@ class TreeEntryPresenter < Gitlab::View::Presenter::Delegated presents nil, as: :tree def web_url - Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path)) + Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, ref_qualified_path, + ref_type: tree.ref_type) end def web_path - Gitlab::Routing.url_helpers.project_tree_path(tree.repository.project, File.join(tree.commit_id, tree.path)) + Gitlab::Routing.url_helpers.project_tree_path(tree.repository.project, ref_qualified_path, + ref_type: tree.ref_type) + end + + private + + def ref_qualified_path + # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`. + # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`. + + commit_id = ExtractsRef.unqualify_ref(tree.commit_id, ref_type) + + File.join(commit_id, tree.path) end end diff --git a/app/presenters/work_item_presenter.rb b/app/presenters/work_item_presenter.rb new file mode 100644 index 00000000000..995f2d02156 --- /dev/null +++ b/app/presenters/work_item_presenter.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class WorkItemPresenter < IssuePresenter # rubocop:todo Gitlab/NamespacedClass +end diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index c0394eb38c5..f0e84fc44d2 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -71,6 +71,7 @@ module Admin end expose :report do + expose :status expose :message expose :created_at, as: :reported_at expose :category @@ -78,27 +79,9 @@ module Admin expose :reported_content, as: :content expose :reported_from_url, as: :url expose :screenshot_path, as: :screenshot - end - - expose :actions, if: ->(report) { report.user } do - 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| + expose :update_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 :reported_user do |report| - UserEntity.represent(report.user, only: [:name, :created_at]) - end - expose :redirect_path do |_| - admin_abuse_reports_path - end end end end diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 21ffdce155f..d7820dff6ef 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -83,7 +83,7 @@ class EnvironmentSerializer < BaseSerializer def deployment_associations { user: [], - cluster: [], + deployment_cluster: { cluster: [] }, project: { route: [], namespace: :route diff --git a/app/serializers/integrations/harbor_serializers/repository_entity.rb b/app/serializers/integrations/harbor_serializers/repository_entity.rb index f03465fe8e2..a6366ebfb36 100644 --- a/app/serializers/integrations/harbor_serializers/repository_entity.rb +++ b/app/serializers/integrations/harbor_serializers/repository_entity.rb @@ -47,8 +47,8 @@ module Integrations private def validate_path(path) - Gitlab::Utils.check_path_traversal!(path) - rescue ::Gitlab::Utils::PathTraversalAttackError + Gitlab::PathTraversal.check_path_traversal!(path) + rescue ::Gitlab::PathTraversal::PathTraversalAttackError Gitlab::AppLogger.error("Path traversal attack detected #{path}") '' end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 6058c89d347..26dc748ad51 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -109,8 +109,6 @@ class NoteEntity < API::Entities::Note end def external_author - return unless Feature.enabled?(:external_note_author_service_desk) - return unless object.note_metadata&.external_author if can?(current_user, :read_external_emails, object.project) diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb index fe90265c888..b769a80ef58 100644 --- a/app/serializers/profile/event_entity.rb +++ b/app/serializers/profile/event_entity.rb @@ -12,10 +12,12 @@ module Profile expose(:action, if: ->(event) { include_private_event?(event) }) { |event| event_action(event) } expose :ref, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do - expose(:type) { |event| event.ref_type } # rubocop:disable Style/SymbolProc - expose(:count) { |event| event.ref_count } # rubocop:disable Style/SymbolProc - expose(:name) { |event| event.ref_name } # rubocop:disable Style/SymbolProc + expose(:ref_type, as: :type) + expose(:ref_count, as: :count) + expose(:ref_name, as: :name) expose(:path) { |event| ref_path(event) } + expose(:new_ref?, as: :is_new) + expose(:rm_ref?, as: :is_removed) end expose :commit, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do @@ -34,27 +36,35 @@ module Profile end end - expose :author, if: ->(event) { include_private_event?(event) } do - expose(:id) { |event| event.author.id } - expose(:name) { |event| event.author.name } - expose(:path) { |event| event.author.username } - end + expose :author, if: ->(event) { include_private_event?(event) }, using: ::API::Entities::UserBasic - expose :target, if: ->(event) { event.visible_to_user?(current_user) } do - expose :target_type + expose :noteable, if: ->(event) { event.visible_to_user?(current_user) && event.note? } do + expose(:type) { |event| event.target.noteable_type } + expose(:reference_link_text) { |event| event.target.noteable.reference_link_text } + expose(:web_url) { |event| Gitlab::UrlBuilder.build(event.target.noteable) } + expose(:first_line_in_markdown) do |event| + first_line_in_markdown(event.target, :note, 150, project: event.project) + end + end - expose(:title) { |event| event.target_title } # rubocop:disable Style/SymbolProc - expose :target_url, if: ->(event) { event.target } do |event| - Gitlab::UrlBuilder.build(event.target, only_path: true) + expose :target, if: ->(event) { event.target && event.visible_to_user?(current_user) } do + expose(:id) { |event| event.target.id } + expose(:target_type, as: :type) + expose(:target_title, as: :title) + expose(:issue_type, if: ->(event) { event.work_item? }) do |event| + event.target.issue_type end - expose :reference_link_text, if: ->(event) { event.target&.respond_to?(:reference_link_text) } do |event| + + expose :reference_link_text, if: ->(event) { event.target.respond_to?(:reference_link_text) } do |event| event.target.reference_link_text end - expose :first_line_in_markdown, if: ->(event) { event.note? && event.target && event.project } do |event| - first_line_in_markdown(event.target, :note, 150, project: event.project) - end - expose :attachment, if: ->(event) { event.note? && event.target&.attachment } do - expose(:url) { |event| event.target.attachment.url } + + expose :web_url do |event| + if event.wiki_page? + event_wiki_page_target_url(event) + else + Gitlab::UrlBuilder.build(event.target) + end end end @@ -62,6 +72,8 @@ module Profile expose(:type) { |event| resource_parent_type(event) } expose(:full_name) { |event| event.resource_parent&.full_name } expose(:full_path) { |event| event.resource_parent&.full_path } + expose(:web_url) { |event| event.resource_parent&.web_url } + expose(:avatar_url) { |event| event.resource_parent&.avatar_url } end private diff --git a/app/services/achievements/destroy_user_achievement_service.rb b/app/services/achievements/destroy_user_achievement_service.rb new file mode 100644 index 00000000000..3beaed646e3 --- /dev/null +++ b/app/services/achievements/destroy_user_achievement_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Achievements + class DestroyUserAchievementService + attr_reader :current_user, :user_achievement + + def initialize(current_user, user_achievement) + @current_user = current_user + @user_achievement = user_achievement + end + + def execute + return error_no_permissions unless allowed? + + user_achievement.delete + ServiceResponse.success(payload: user_achievement) + end + + private + + def allowed? + current_user&.can?(:destroy_user_achievement, user_achievement) + end + + def error_no_permissions + error('You have insufficient permissions to delete this user achievement') + end + + def error(message) + ServiceResponse.error(message: Array(message)) + end + end +end diff --git a/app/services/admin/abuse_report_update_service.rb b/app/services/admin/abuse_report_update_service.rb index 5b2ad27ede4..12cf8bf14a8 100644 --- a/app/services/admin/abuse_report_update_service.rb +++ b/app/services/admin/abuse_report_update_service.rb @@ -17,8 +17,8 @@ module Admin result = perform_action if result[:status] == :success - close_report_and_record_event - ServiceResponse.success + event = close_report_and_record_event + ServiceResponse.success(message: event.success_message) else ServiceResponse.error(message: result[:message]) end @@ -58,6 +58,8 @@ module Admin end def close_report + return error('Report already closed') if abuse_report.closed? + abuse_report.closed! success end diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb new file mode 100644 index 00000000000..a71d1f14112 --- /dev/null +++ b/app/services/admin/plan_limits/update_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Admin + module PlanLimits + class UpdateService < ::BaseService + def initialize(params = {}, current_user:, plan:) + @current_user = current_user + @params = params + @plan = plan + end + + def execute + return error(_('Access denied'), :forbidden) unless can_update? + + if plan.actual_limits.update(parsed_params) + success + else + error(plan.actual_limits.errors.full_messages, :bad_request) + end + end + + private + + attr_accessor :current_user, :params, :plan + + def can_update? + current_user.can_admin_all_resources? + end + + # Overridden in EE + def parsed_params + params + end + end + end +end + +Admin::PlanLimits::UpdateService.prepend_mod_with('Admin::PlanLimits::UpdateService') diff --git a/app/services/alert_management/http_integrations/base_service.rb b/app/services/alert_management/http_integrations/base_service.rb new file mode 100644 index 00000000000..980f18631c0 --- /dev/null +++ b/app/services/alert_management/http_integrations/base_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module AlertManagement + module HttpIntegrations + class BaseService < BaseProjectService + # @param project [Project] + # @param current_user [User] + # @param params [Hash] + def initialize(project, current_user, params) + @response = nil + + super(project: project, current_user: current_user, params: params.with_indifferent_access) + end + + private + + def allowed? + current_user&.can?(:admin_operations, project) + end + + def too_many_integrations?(integration) + AlertManagement::HttpIntegration + .for_project(integration.project_id) + .for_type(integration.type_identifier) + .id_not_in(integration.id) + .any? + end + + def permitted_params + params.slice(*permitted_params_keys) + end + + # overriden in EE + def permitted_params_keys + %i[name active type_identifier] + end + + def error(message) + ServiceResponse.error(message: message) + end + + def success(integration) + ServiceResponse.success(payload: { integration: integration.reset }) + end + + def error_multiple_integrations + error(_('Multiple integrations of a single type are not supported for this project')) + end + + def error_on_save(integration) + error(integration.errors.full_messages.to_sentence) + end + end + end +end + +::AlertManagement::HttpIntegrations::BaseService.prepend_mod diff --git a/app/services/alert_management/http_integrations/create_service.rb b/app/services/alert_management/http_integrations/create_service.rb index 1abe0548c45..17e39577c29 100644 --- a/app/services/alert_management/http_integrations/create_service.rb +++ b/app/services/alert_management/http_integrations/create_service.rb @@ -2,68 +2,34 @@ module AlertManagement module HttpIntegrations - class CreateService - # @param project [Project] - # @param current_user [User] - # @param params [Hash] - def initialize(project, current_user, params) - @project = project - @current_user = current_user - @params = params.with_indifferent_access - end - + class CreateService < BaseService def execute return error_no_permissions unless allowed? - return error_multiple_integrations unless creation_allowed? - - integration = project.alert_management_http_integrations.create(permitted_params) - return error_in_create(integration) unless integration.valid? - - success(integration) - end - private + ::AlertManagement::HttpIntegration.transaction do + integration = project.alert_management_http_integrations.build(permitted_params) - attr_reader :project, :current_user, :params + if integration.save + @response = success(integration) - def allowed? - current_user&.can?(:admin_operations, project) - end + if too_many_integrations?(integration) + @response = error_multiple_integrations - def creation_allowed? - project.alert_management_http_integrations.empty? - end - - def permitted_params - params.slice(*permitted_params_keys) - end + raise ActiveRecord::Rollback + end + else + @response = error_on_save(integration) + end + end - # overriden in EE - def permitted_params_keys - %i[name active] + @response end - def error(message) - ServiceResponse.error(message: message) - end - - def success(integration) - ServiceResponse.success(payload: { integration: integration }) - end + private def error_no_permissions error(_('You have insufficient permissions to create an HTTP integration for this project')) end - - def error_multiple_integrations - error(_('Multiple HTTP integrations are not supported for this project')) - end - - def error_in_create(integration) - error(integration.errors.full_messages.to_sentence) - end end end end - -::AlertManagement::HttpIntegrations::CreateService.prepend_mod_with('AlertManagement::HttpIntegrations::CreateService') diff --git a/app/services/alert_management/http_integrations/destroy_service.rb b/app/services/alert_management/http_integrations/destroy_service.rb index aeb3f6cb807..1bd73ca46e4 100644 --- a/app/services/alert_management/http_integrations/destroy_service.rb +++ b/app/services/alert_management/http_integrations/destroy_service.rb @@ -12,6 +12,7 @@ module AlertManagement def execute return error_no_permissions unless allowed? + return error_legacy_prometheus unless destroy_allowed? if integration.destroy success @@ -28,6 +29,12 @@ module AlertManagement current_user&.can?(:admin_operations, integration) end + # Prevents downtime while migrating from Integrations::Prometheus. + # Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/409734 + def destroy_allowed? + !(integration.legacy? && integration.prometheus?) + end + def error(message) ServiceResponse.error(message: message) end @@ -39,6 +46,10 @@ module AlertManagement def error_no_permissions error(_('You have insufficient permissions to remove this HTTP integration')) end + + def error_legacy_prometheus + error(_('Legacy Prometheus integrations cannot currently be removed')) + end end end end diff --git a/app/services/alert_management/http_integrations/update_service.rb b/app/services/alert_management/http_integrations/update_service.rb index 8662f966a2e..f7a079576e4 100644 --- a/app/services/alert_management/http_integrations/update_service.rb +++ b/app/services/alert_management/http_integrations/update_service.rb @@ -2,51 +2,48 @@ module AlertManagement module HttpIntegrations - class UpdateService + class UpdateService < BaseService # @param integration [AlertManagement::HttpIntegration] # @param current_user [User] # @param params [Hash] def initialize(integration, current_user, params) @integration = integration - @current_user = current_user - @params = params.with_indifferent_access + + super(integration.project, current_user, params) end def execute return error_no_permissions unless allowed? - params[:token] = nil if params.delete(:regenerate_token) + integration.transaction do + if integration.update(permitted_params.merge(token_params)) + @response = success(integration) + + if type_update? && too_many_integrations?(integration) + @response = error_multiple_integrations - if integration.update(permitted_params) - success - else - error(integration.errors.full_messages.to_sentence) + raise ActiveRecord::Rollback + end + else + @response = error_on_save(integration) + end end + + @response end private - attr_reader :integration, :current_user, :params + attr_reader :integration - def allowed? - current_user&.can?(:admin_operations, integration) - end + def token_params + return {} unless params[:regenerate_token] - def permitted_params - params.slice(*permitted_params_keys) + { token: nil } end - # overriden in EE - def permitted_params_keys - %i[name active token] - end - - def error(message) - ServiceResponse.error(message: message) - end - - def success - ServiceResponse.success(payload: { integration: integration.reset }) + def type_update? + params[:type_identifier].present? end def error_no_permissions @@ -55,5 +52,3 @@ module AlertManagement end end end - -::AlertManagement::HttpIntegrations::UpdateService.prepend_mod_with('AlertManagement::HttpIntegrations::UpdateService') diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index e0594247975..556f04e8786 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -6,9 +6,10 @@ module AlertManagement include ::AlertManagement::AlertProcessing include ::AlertManagement::Responses - def initialize(project, payload) + def initialize(project, payload, integration: nil) @project = project @payload = payload + @integration = integration end def execute @@ -24,7 +25,7 @@ module AlertManagement private - attr_reader :project, :payload + attr_reader :project, :payload, :integration override :incoming_payload def incoming_payload @@ -32,6 +33,7 @@ module AlertManagement Gitlab::AlertManagement::Payload.parse( project, payload, + integration: integration, monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] ) end diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb index d18f2935d92..2bbb8f925a4 100644 --- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -11,9 +11,14 @@ module AutoMerge end def process(merge_request) + logger.info("Processing Automerge") return unless merge_request.actual_head_pipeline_success? + + logger.info("Pipeline Success") return unless merge_request.mergeable? + logger.info("Merge request mergeable") + merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) end @@ -40,5 +45,9 @@ module AutoMerge def notify(merge_request) notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled? end + + def logger + @logger ||= Gitlab::AppLogger + end end end diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb index 77e297b6b11..04b5d826416 100644 --- a/app/services/boards/issues/create_service.rb +++ b/app/services/boards/issues/create_service.rb @@ -32,7 +32,7 @@ module Boards def create_issue(params) # NOTE: We are intentionally not doing a spam/CAPTCHA check for issues created via boards. # See https://gitlab.com/gitlab-org/gitlab/-/issues/29400#note_598479184 for more context. - ::Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: nil).execute + ::Issues::CreateService.new(container: project, current_user: current_user, params: params, perform_spam_check: false).execute end end end diff --git a/app/services/bulk_imports/archive_extraction_service.rb b/app/services/bulk_imports/archive_extraction_service.rb index fec8fd0e1f5..4485b19035b 100644 --- a/app/services/bulk_imports/archive_extraction_service.rb +++ b/app/services/bulk_imports/archive_extraction_service.rb @@ -41,11 +41,11 @@ module BulkImports attr_reader :tmpdir, :filename, :filepath def validate_filepath - Gitlab::Utils.check_path_traversal!(filepath) + Gitlab::PathTraversal.check_path_traversal!(filepath) end def validate_tmpdir - Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) + Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) end def validate_symlink diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index 4c9c59ac504..636c636255f 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -118,9 +118,10 @@ module BulkImports end client.get("/#{entity_type}/#{source_entity_identifier}/export_relations/status") + rescue BulkImports::NetworkError => e # 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 + return if e.cause.is_a?(Gitlab::HTTP::BlockedUrlError) + raise ::BulkImports::Error.setting_not_enabled end diff --git a/app/services/bulk_imports/file_decompression_service.rb b/app/services/bulk_imports/file_decompression_service.rb index 41616fc1c75..94573f6bb13 100644 --- a/app/services/bulk_imports/file_decompression_service.rb +++ b/app/services/bulk_imports/file_decompression_service.rb @@ -41,11 +41,11 @@ module BulkImports attr_reader :tmpdir, :filename, :filepath, :decompressed_filename, :decompressed_filepath def validate_filepath - Gitlab::Utils.check_path_traversal!(filepath) + Gitlab::PathTraversal.check_path_traversal!(filepath) end def validate_tmpdir - Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) + Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) end def validate_decompressed_file_size diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index ee499c782b4..ef7e0ae8258 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -99,7 +99,7 @@ module BulkImports end def validate_tmpdir - Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) + Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) end def filepath diff --git a/app/services/ci/cancel_pipeline_service.rb b/app/services/ci/cancel_pipeline_service.rb new file mode 100644 index 00000000000..b5c8c00273e --- /dev/null +++ b/app/services/ci/cancel_pipeline_service.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Ci + # Cancel a pipelines cancelable jobs and optionally it's child pipelines cancelable jobs + class CancelPipelineService + include Gitlab::OptimisticLocking + include Gitlab::Allowable + + ## + # @cascade_to_children - if true cancels all related child pipelines for parent child pipelines + # @auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation + # @execute_async - if true cancel the children asyncronously + def initialize( + pipeline:, + current_user:, + cascade_to_children: true, + auto_canceled_by_pipeline_id: nil, + execute_async: true) + @pipeline = pipeline + @current_user = current_user + @cascade_to_children = cascade_to_children + @auto_canceled_by_pipeline_id = auto_canceled_by_pipeline_id + @execute_async = execute_async + end + + def execute + unless can?(current_user, :update_pipeline, pipeline) + return ServiceResponse.error( + message: 'Insufficient permissions to cancel the pipeline', + reason: :insufficient_permissions) + end + + force_execute + end + + # This method should be used only when we want to always cancel the pipeline without + # checking whether the current_user has permissions to do so, or when we don't have + # a current_user available in the context. + def force_execute + return ServiceResponse.error(message: 'No pipeline provided', reason: :no_pipeline) unless pipeline + + unless pipeline.cancelable? + return ServiceResponse.error(message: 'Pipeline is not cancelable', reason: :pipeline_not_cancelable) + end + + log_pipeline_being_canceled + + pipeline.update_column(:auto_canceled_by_id, @auto_canceled_by_pipeline_id) if @auto_canceled_by_pipeline_id + cancel_jobs(pipeline.cancelable_statuses) + + return ServiceResponse.success unless cascade_to_children? + + # cancel any bridges that could spin up new child pipelines + cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable) + cancel_children + + ServiceResponse.success + end + + private + + attr_reader :pipeline, :current_user + + def log_pipeline_being_canceled + Gitlab::AppJsonLogger.info( + event: 'pipeline_cancel_running', + pipeline_id: pipeline.id, + auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline_id, + cascade_to_children: cascade_to_children?, + execute_async: execute_async?, + **Gitlab::ApplicationContext.current + ) + end + + def cascade_to_children? + @cascade_to_children + end + + def execute_async? + @execute_async + end + + def cancel_jobs(jobs) + retries = 3 + retry_lock(jobs, retries, name: 'ci_pipeline_cancel_running') do |jobs_to_cancel| + preloaded_relations = [:project, :pipeline, :deployment, :taggings] + + jobs_to_cancel.find_in_batches do |batch| + relation = CommitStatus.id_in(batch) + Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations) + + relation.each do |job| + job.auto_canceled_by_id = @auto_canceled_by_pipeline_id if @auto_canceled_by_pipeline_id + job.cancel + end + end + end + end + + # For parent child-pipelines only (not multi-project) + def cancel_children + pipeline.all_child_pipelines.each do |child_pipeline| + if execute_async? + ::Ci::CancelPipelineWorker.perform_async( + child_pipeline.id, + @auto_canceled_by_pipeline_id + ) + else + # cascade_to_children is false because we iterate through children + # we also cancel bridges prior to prevent more children + self.class.new( + pipeline: child_pipeline.reset, + current_user: nil, + cascade_to_children: false, + execute_async: execute_async?, + auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline_id + ).force_execute + end + end + end + end +end diff --git a/app/services/ci/delete_unit_tests_service.rb b/app/services/ci/delete_unit_tests_service.rb index 230661a107d..a2fb44ff3fc 100644 --- a/app/services/ci/delete_unit_tests_service.rb +++ b/app/services/ci/delete_unit_tests_service.rb @@ -25,9 +25,7 @@ module Ci klass.transaction do ids = klass.deletable.lock('FOR UPDATE SKIP LOCKED').limit(BATCH_SIZE).pluck(:id) - break if ids.empty? - - deleted = klass.where(id: ids).delete_all + deleted = klass.where(id: ids).delete_all if ids.any? end deleted > 0 diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index 1c563396162..bdec13f98a7 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -7,7 +7,13 @@ module Ci Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true) - pipeline.cancel_running(cascade_to_children: true, execute_async: false) if pipeline.cancelable? + # ensure cancellation happens sync so we accumulate compute credits successfully + # before deleting the pipeline. + ::Ci::CancelPipelineService.new( + pipeline: pipeline, + current_user: current_user, + cascade_to_children: true, + execute_async: false).force_execute # The pipeline, the builds, job and pipeline artifacts all get destroyed here. # Ci::Pipeline#destroy triggers fast destroy on job_artifacts and diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index f7e04c59463..3ac0e83232f 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -26,7 +26,8 @@ module Ci headers = JobArtifactUploader.workhorse_authorize( has_length: false, maximum_size: max_size(artifact_type), - use_final_store_path: Feature.enabled?(:ci_artifacts_upload_to_final_location, project) + use_final_store_path: true, + final_store_path_root_id: project.id ) if lsif?(artifact_type) diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb index d6a2defd5b9..eddd4d79484 100644 --- a/app/services/ci/job_token_scope/remove_project_service.rb +++ b/app/services/ci/job_token_scope/remove_project_service.rb @@ -26,7 +26,7 @@ module Ci ServiceResponse.error(message: link.errors.full_messages.to_sentence, payload: { project_link: link }) end rescue EditScopeValidations::ValidationError => e - ServiceResponse.error(message: e.message) + ServiceResponse.error(message: e.message, reason: :insufficient_permissions) end end end diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb index 48c3e6490ae..e197821a0c0 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -6,6 +6,7 @@ module Ci include Gitlab::Utils::StrongMemoize BATCH_SIZE = 25 + PAGE_SIZE = 500 def initialize(pipeline) @pipeline = pipeline @@ -14,13 +15,24 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def execute + return if service_disabled? return if pipeline.parent_pipeline? # skip if child pipeline return unless project.auto_cancel_pending_pipelines? - Gitlab::OptimisticLocking - .retry_lock(parent_and_child_pipelines, name: 'cancel_pending_pipelines') do |cancelables| - cancelables.select(:id).each_batch(of: BATCH_SIZE) do |cancelables_batch| - auto_cancel_interruptible_pipelines(cancelables_batch.ids) + if Feature.enabled?(:use_offset_pagination_for_canceling_redundant_pipelines, project) + paginator.each do |ids| + pipelines = parent_and_child_pipelines(ids) + + Gitlab::OptimisticLocking.retry_lock(pipelines, name: 'cancel_pending_pipelines') do |cancelables| + auto_cancel_interruptible_pipelines(cancelables.ids) + end + end + else + Gitlab::OptimisticLocking + .retry_lock(parent_and_child_pipelines, name: 'cancel_pending_pipelines') do |cancelables| + cancelables.select(:id).each_batch(of: BATCH_SIZE) do |cancelables_batch| + auto_cancel_interruptible_pipelines(cancelables_batch.ids) + end end end end @@ -29,17 +41,40 @@ module Ci attr_reader :pipeline, :project - def parent_auto_cancelable_pipelines - project.all_pipelines + def paginator + page = 1 + Enumerator.new do |yielder| + loop do + # leverage the index_ci_pipelines_on_project_id_and_status_and_created_at index + records = project.all_pipelines + .created_after(1.week.ago) + .order(:status, :created_at) + .page(page) # use offset pagination because there is no other way to loop over the data + .per(PAGE_SIZE) + .pluck(:id) + + raise StopIteration if records.empty? + + yielder << records + page += 1 + end + end + end + + def parent_auto_cancelable_pipelines(ids = nil) + scope = project.all_pipelines .created_after(1.week.ago) .for_ref(pipeline.ref) .where_not_sha(project.commit(pipeline.ref).try(:id)) .where("created_at < ?", pipeline.created_at) .ci_sources + + scope = scope.id_in(ids) if ids.present? + scope end - def parent_and_child_pipelines - Ci::Pipeline.object_hierarchy(parent_auto_cancelable_pipelines, project_condition: :same) + def parent_and_child_pipelines(ids = nil) + Ci::Pipeline.object_hierarchy(parent_auto_cancelable_pipelines(ids), project_condition: :same) .base_and_descendants .alive_or_scheduled end @@ -59,12 +94,23 @@ module Ci ) # cascade_to_children not needed because we iterate through descendants here - cancelable_pipeline.cancel_running( + ::Ci::CancelPipelineService.new( + pipeline: cancelable_pipeline, + current_user: nil, auto_canceled_by_pipeline_id: pipeline.id, cascade_to_children: false - ) + ).force_execute end end + + # Finding the pipelines to cancel is an expensive task that is not well + # covered by indexes for all project use-cases and sometimes it might + # harm other services. See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/14758 + # This feature flag is in place to disable this feature for rogue projects. + # + def service_disabled? + Feature.enabled?(:disable_cancel_redundant_pipelines_service, project, type: :ops) + end end end end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 1094a131e68..c0ffbb401f6 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -22,9 +22,19 @@ module Ci # Run the process only if we can obtain an exclusive lease; returns nil if lease is unavailable success = try_obtain_lease { process! } - # Re-schedule if we need further processing - if success && pipeline.needs_processing? - PipelineProcessWorker.perform_async(pipeline.id) + if success + if ::Feature.enabled?(:ci_reset_skipped_jobs_in_atomic_processing, project) + # If any jobs changed from stopped to alive status during pipeline processing, we must + # re-reset their dependent jobs; see https://gitlab.com/gitlab-org/gitlab/-/issues/388539. + new_alive_jobs.group_by(&:user).each do |user, jobs| + log_running_reset_skipped_jobs_service(jobs) + + ResetSkippedJobsService.new(project, user).execute(jobs) + end + end + + # Re-schedule if we need further processing + PipelineProcessWorker.perform_async(pipeline.id) if pipeline.needs_processing? end success @@ -105,6 +115,25 @@ module Ci end end + # Gets the jobs that changed from stopped to alive status since the initial status collection + # was evaluated. We determine this by checking if their current status is no longer stopped. + def new_alive_jobs + initial_stopped_job_names = @collection.stopped_job_names + + return [] if initial_stopped_job_names.empty? + + new_collection = AtomicProcessingService::StatusCollection.new(pipeline) + new_alive_job_names = initial_stopped_job_names - new_collection.stopped_job_names + + return [] if new_alive_job_names.empty? + + pipeline + .current_jobs + .by_name(new_alive_job_names) + .preload(:user) # rubocop: disable CodeReuse/ActiveRecord + .to_a + end + def project pipeline.project end @@ -116,6 +145,17 @@ module Ci def lease_timeout DEFAULT_LEASE_TIMEOUT end + + def log_running_reset_skipped_jobs_service(jobs) + Gitlab::AppJsonLogger.info( + class: self.class.name.to_s, + message: 'Running ResetSkippedJobsService on new alive jobs', + project_id: project.id, + pipeline_id: pipeline.id, + user_id: jobs.first.user.id, + jobs_count: jobs.count + ) + end end end end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb index 85646b79254..9a53c6d8fc1 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 @@ -67,6 +67,11 @@ module Ci all_jobs.lazy.reject { |job| job[:processed] } end + # This method returns the names of jobs that have a stopped status + def stopped_job_names + all_jobs.select { |job| job[:status].in?(Ci::HasStatus::STOPPED_STATUSES) }.pluck(:name) # rubocop: disable CodeReuse/ActiveRecord + end + private # We use these columns to perform an efficient calculation of a status diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb index 1a5c8d0dccf..dfbb37cf0dc 100644 --- a/app/services/ci/pipelines/add_job_service.rb +++ b/app/services/ci/pipelines/add_job_service.rb @@ -18,12 +18,6 @@ module Ci in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do Ci::Pipeline.transaction do - # This is used to reduce the deadlocks when partitioning `ci_builds` - # since inserting into this table requires locks on all foreign keys - # and we need to lock all the tables in a specific order for the - # migration to succeed. - Ci::Pipeline.connection.execute('LOCK "ci_pipelines", "ci_stages" IN ROW SHARE MODE;') - yield(job) job.update_older_statuses_retried! diff --git a/app/services/ci/reset_skipped_jobs_service.rb b/app/services/ci/reset_skipped_jobs_service.rb index cb793eb3e06..9e5c887b31b 100644 --- a/app/services/ci/reset_skipped_jobs_service.rb +++ b/app/services/ci/reset_skipped_jobs_service.rb @@ -7,7 +7,6 @@ module Ci def execute(processables) @processables = Array.wrap(processables) @pipeline = @processables.first.pipeline - @processable = @processables.first # Remove with FF `ci_support_reset_skipped_jobs_for_multiple_jobs` process_subsequent_jobs reset_source_bridge @@ -43,27 +42,17 @@ module Ci end def stage_dependent_jobs - if ::Feature.enabled?(:ci_support_reset_skipped_jobs_for_multiple_jobs, project) - # Get all jobs after the earliest stage of the inputted jobs - min_stage_idx = @processables.map(&:stage_idx).min - @pipeline.processables.after_stage(min_stage_idx) - else - @pipeline.processables.after_stage(@processable.stage_idx) - end + # Get all jobs after the earliest stage of the inputted jobs + min_stage_idx = @processables.map(&:stage_idx).min + @pipeline.processables.after_stage(min_stage_idx) end def needs_dependent_jobs - if ::Feature.enabled?(:ci_support_reset_skipped_jobs_for_multiple_jobs, project) - # We must include the hierarchy base here because @processables may include both a parent job - # and its dependents, and we do not want to exclude those dependents from being processed. - ::Gitlab::Ci::ProcessableObjectHierarchy.new( - ::Ci::Processable.where(id: @processables.map(&:id)) - ).base_and_descendants - else - ::Gitlab::Ci::ProcessableObjectHierarchy.new( - ::Ci::Processable.where(id: @processable.id) - ).descendants - end + # We must include the hierarchy base here because @processables may include both a parent job + # and its dependents, and we do not want to exclude those dependents from being processed. + ::Gitlab::Ci::ProcessableObjectHierarchy.new( + ::Ci::Processable.where(id: @processables.map(&:id)) + ).base_and_descendants end def ordered_by_dag(jobs) diff --git a/app/services/ci/runners/assign_runner_service.rb b/app/services/ci/runners/assign_runner_service.rb index 290f945cc72..4e7b08bdd7a 100644 --- a/app/services/ci/runners/assign_runner_service.rb +++ b/app/services/ci/runners/assign_runner_service.rb @@ -17,6 +17,10 @@ module Ci return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden) end + unless @user.can?(:register_project_runners, @project) + return ServiceResponse.error(message: 'user not allowed to add runners to project', http_status: :forbidden) + end + if @runner.assign_to(@project, @user) ServiceResponse.success else diff --git a/app/services/ci/runners/stale_managers_cleanup_service.rb b/app/services/ci/runners/stale_managers_cleanup_service.rb index b39f7315bc6..e216d8ea3d6 100644 --- a/app/services/ci/runners/stale_managers_cleanup_service.rb +++ b/app/services/ci/runners/stale_managers_cleanup_service.rb @@ -4,29 +4,32 @@ module Ci module Runners class StaleManagersCleanupService MAX_DELETIONS = 1000 + SUB_BATCH_LIMIT = 100 def execute - ServiceResponse.success(payload: { - # the `stale` relationship can return duplicates, so we don't try to return a precise count here - deleted_managers: delete_stale_runner_managers > 0 - }) + ServiceResponse.success(payload: delete_stale_runner_managers) end private def delete_stale_runner_managers + batch_counts = [] total_deleted_count = 0 loop do - sub_batch_limit = [100, MAX_DELETIONS].min + sub_batch_limit = [SUB_BATCH_LIMIT, MAX_DELETIONS].min - # delete_all discards part of the `stale` scope query, so we expliclitly wrap it with a SELECT as a workaround + # delete_all discards part of the `stale` scope query, so we explicitly wrap it with a SELECT as a workaround deleted_count = Ci::RunnerManager.id_in(Ci::RunnerManager.stale.limit(sub_batch_limit)).delete_all + batch_counts << deleted_count total_deleted_count += deleted_count break if deleted_count == 0 || total_deleted_count >= MAX_DELETIONS end - total_deleted_count + { + total_deleted: total_deleted_count, + batch_counts: batch_counts + } end end end diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb index 66a3cb04d98..efa9716d2c8 100644 --- a/app/services/clusters/agent_tokens/create_service.rb +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -4,6 +4,7 @@ module Clusters module AgentTokens class CreateService ALLOWED_PARAMS = %i[agent_id description name].freeze + ACTIVE_TOKENS_LIMIT = 2 attr_reader :agent, :current_user, :params @@ -15,6 +16,7 @@ module Clusters def execute return error_no_permissions unless current_user.can?(:create_cluster, agent.project) + return error_active_tokens_limit_reached if active_tokens_limit_reached? token = ::Clusters::AgentToken.new(filtered_params.merge(agent_id: agent.id, created_by_user: current_user)) @@ -33,6 +35,16 @@ module Clusters ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project')) end + def error_active_tokens_limit_reached + ServiceResponse.error(message: s_('ClusterAgent|An agent can have only two active tokens at a time')) + end + + def active_tokens_limit_reached? + return false unless Feature.enabled?(:cluster_agents_limit_tokens_created) + + ::Clusters::AgentTokensFinder.new(agent, current_user, status: :active).execute.count >= ACTIVE_TOKENS_LIMIT + end + def filtered_params params.slice(*ALLOWED_PARAMS) end diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb index 7e982bf7686..2a634c5ec71 100644 --- a/app/services/commits/cherry_pick_service.rb +++ b/app/services/commits/cherry_pick_service.rb @@ -2,9 +2,18 @@ module Commits class CherryPickService < ChangeService + def initialize(*args) + super + + @start_project = params[:target_project] || @project + @source_project = params[:source_project] || @project + end + def create_commit! - commit_change(:cherry_pick).tap do |sha| - track_mr_picking(sha) + Gitlab::Git::CrossRepo.new(@project.repository, @source_project.repository).execute(@commit.id) do + commit_change(:cherry_pick).tap do |sha| + track_mr_picking(sha) + end end end diff --git a/app/services/concerns/search/filter.rb b/app/services/concerns/search/filter.rb new file mode 100644 index 00000000000..e234edcfce4 --- /dev/null +++ b/app/services/concerns/search/filter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Search + module Filter + private + + def filters + { state: params[:state], confidential: params[:confidential], include_archived: params[:include_archived] } + end + end +end + +Search::Filter.prepend_mod diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index a0b4040cff7..bb43cab79bb 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -14,12 +14,16 @@ module UpdateRepositoryStorageMethods end def execute - repository_storage_move.with_lock do - return ServiceResponse.success unless repository_storage_move.scheduled? # rubocop:disable Cop/AvoidReturnFromBlocks + response = repository_storage_move.with_lock do + next ServiceResponse.success unless repository_storage_move.scheduled? repository_storage_move.start! + + nil end + return response if response + mirror_repositories unless same_filesystem? repository_storage_move.transaction do diff --git a/app/services/database/mark_migration_service.rb b/app/services/database/mark_migration_service.rb new file mode 100644 index 00000000000..aff10fa5f76 --- /dev/null +++ b/app/services/database/mark_migration_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Database + class MarkMigrationService + def initialize(connection:, version:) + @connection = connection + @version = version + end + + def execute + return error(reason: :not_found) unless migration.present? + return error(reason: :invalid) if all_versions.include?(migration.version) + + if create_version(version) + ServiceResponse.success + else + error(reason: :invalid) + end + end + + private + + attr_reader :connection, :version + + def migration + @migration ||= connection + .migration_context + .migrations + .find { |migration| migration.version == version } + end + + def all_versions + all_executed_migrations.map(&:to_i) + end + + def all_executed_migrations + sm = Arel::SelectManager.new(arel_table) + sm.project(arel_table[:version]) + sm.order(arel_table[:version].asc) # rubocop: disable CodeReuse/ActiveRecord + connection.select_values(sm, "#{self.class} Load") + end + + def create_version(version) + im = Arel::InsertManager.new + im.into(arel_table) + im.insert(arel_table[:version] => version) + connection.insert(im, "#{self.class} Create", :version, version) + end + + def arel_table + @arel_table ||= Arel::Table.new(:schema_migrations) + end + + def error(reason:) + ServiceResponse.error(message: 'error', reason: reason) + end + end +end diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb new file mode 100644 index 00000000000..760c8a6e306 --- /dev/null +++ b/app/services/environments/create_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Environments + class CreateService < BaseService + ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent].freeze + + def execute + unless can?(current_user, :create_environment, project) + return ServiceResponse.error( + message: _('Unauthorized to create an environment'), + payload: { environment: nil } + ) + end + + if unauthorized_cluster_agent? + return ServiceResponse.error( + message: _('Unauthorized to access the cluster agent in this project'), + payload: { environment: nil }) + end + + environment = project.environments.create(**params.slice(*ALLOWED_ATTRIBUTES)) + + if environment.persisted? + ServiceResponse.success(payload: { environment: environment }) + else + ServiceResponse.error( + message: environment.errors.full_messages, + payload: { environment: nil } + ) + end + end + + private + + def unauthorized_cluster_agent? + return false unless params[:cluster_agent] + + ::Clusters::Agents::Authorizations::UserAccess::Finder + .new(current_user, agent: params[:cluster_agent], project: project) + .execute + .empty? + end + end +end diff --git a/app/services/environments/destroy_service.rb b/app/services/environments/destroy_service.rb new file mode 100644 index 00000000000..f1530489a40 --- /dev/null +++ b/app/services/environments/destroy_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Environments + class DestroyService < BaseService + def execute(environment) + unless can?(current_user, :destroy_environment, environment) + return ServiceResponse.error( + message: 'Unauthorized to delete the environment' + ) + end + + environment.destroy + + unless environment.destroyed? + return ServiceResponse.error( + message: 'Attemped to destroy the environment but failed' + ) + end + + ServiceResponse.success + end + end +end diff --git a/app/services/environments/update_service.rb b/app/services/environments/update_service.rb new file mode 100644 index 00000000000..5eb4880ec4b --- /dev/null +++ b/app/services/environments/update_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Environments + class UpdateService < BaseService + ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent].freeze + + def execute(environment) + unless can?(current_user, :update_environment, environment) + return ServiceResponse.error( + message: _('Unauthorized to update the environment'), + payload: { environment: environment } + ) + end + + if unauthorized_cluster_agent? + return ServiceResponse.error( + message: _('Unauthorized to access the cluster agent in this project'), + payload: { environment: environment }) + end + + if environment.update(**params.slice(*ALLOWED_ATTRIBUTES)) + ServiceResponse.success(payload: { environment: environment }) + else + ServiceResponse.error( + message: environment.errors.full_messages, + payload: { environment: environment } + ) + end + end + + private + + def unauthorized_cluster_agent? + return false unless params[:cluster_agent] + + ::Clusters::Agents::Authorizations::UserAccess::Finder + .new(current_user, agent: params[:cluster_agent], project: project) + .execute + .empty? + end + end +end diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb deleted file mode 100644 index 8cb3793ba97..00000000000 --- a/app/services/error_tracking/collect_error_service.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module ErrorTracking - class CollectErrorService < ::BaseService - include Gitlab::Utils::StrongMemoize - - def execute - error_repository.report_error( - name: exception['type'], - description: exception['value'], - actor: actor, - platform: event['platform'], - occurred_at: timestamp, - environment: event['environment'], - level: event['level'], - payload: event - ) - end - - private - - def error_repository - Gitlab::ErrorTracking::ErrorRepository.build(project) - end - - def event - @event ||= format_event(params[:event]) - end - - def format_event(event) - # Some SDK send exception payload as Array. For exmple Go lang SDK. - # We need to convert it to hash format we expect. - if event['exception'].is_a?(Array) - exception = event['exception'] - event['exception'] = { 'values' => exception } - end - - event - end - - def exception - strong_memoize(:exception) do - # Find the first exception that has a stacktrace since the first - # exception may not provide adequate context (e.g. in the Go SDK). - entries = event['exception']['values'] - entries.find { |x| x.key?('stacktrace') } || entries.first - end - end - - def stacktrace_frames - strong_memoize(:stacktrace_frames) do - exception.dig('stacktrace', 'frames') - end - end - - def actor - return event['transaction'] if event['transaction'].present? - - # Some SDKs do not have a transaction attribute. - # So we build it by combining function name and module name from - # the last item in stacktrace. - return unless stacktrace_frames.present? - - last_line = stacktrace_frames.last - - "#{last_line['function']}(#{last_line['module']})" - end - - def timestamp - return @timestamp if @timestamp - - @timestamp = (event['timestamp'] || Time.zone.now) - - # Some SDK send timestamp in numeric format like '1630945472.13'. - if @timestamp.to_s =~ /\A\d+(\.\d+)?\z/ - @timestamp = Time.zone.at(@timestamp.to_f) - end - - @timestamp - end - end -end diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb index 028906a0b43..834409bf3c4 100644 --- a/app/services/feature_flags/base_service.rb +++ b/app/services/feature_flags/base_service.rb @@ -39,9 +39,9 @@ module FeatureFlags def created_strategy_message(strategy) scopes = strategy.scopes - .map { |scope| %Q("#{scope.environment_scope}") } + .map { |scope| %("#{scope.environment_scope}") } .join(', ') - %Q(Created strategy "#{strategy.name}" with scopes #{scopes}.) + %(Created strategy "#{strategy.name}" with scopes #{scopes}.) end def feature_flag_by_name diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 2ead2e2a113..31da099d078 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -4,6 +4,9 @@ module Git class BranchHooksService < ::Git::BaseHooksService extend ::Gitlab::Utils::Override + JIRA_SYNC_BATCH_SIZE = 20 + JIRA_SYNC_BATCH_DELAY = 10.seconds + def execute execute_branch_hooks @@ -99,7 +102,6 @@ module Git def branch_change_hooks enqueue_process_commit_messages enqueue_jira_connect_sync_messages - enqueue_metrics_dashboard_sync track_ci_config_change_event end @@ -107,13 +109,6 @@ module Git project.repository.after_remove_branch(expire_cache: false) end - def enqueue_metrics_dashboard_sync - return unless default_branch? - return unless modified_file_types.include?(:metrics_dashboard) - - ::Metrics::Dashboard::SyncDashboardsWorker.perform_async(project.id) - end - def track_ci_config_change_event return unless ::ServicePing::ServicePingSettings.enabled? return unless default_branch? @@ -157,13 +152,34 @@ module Git return unless project.jira_subscription_exists? 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? - JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync, Atlassian::JiraConnect::Client.generate_update_sequence_id) + commits_to_sync = filtered_commit_shas + + return if branch_to_sync.nil? && commits_to_sync.empty? + + if commits_to_sync.any? && Feature.enabled?(:batch_delay_jira_branch_sync_worker, project) + commits_to_sync.each_slice(JIRA_SYNC_BATCH_SIZE).with_index do |commits, i| + JiraConnect::SyncBranchWorker.perform_in( + JIRA_SYNC_BATCH_DELAY * i, + project.id, + branch_to_sync, + commits, + Atlassian::JiraConnect::Client.generate_update_sequence_id + ) + end + else + JiraConnect::SyncBranchWorker.perform_async( + project.id, + branch_to_sync, + commits_to_sync, + Atlassian::JiraConnect::Client.generate_update_sequence_id + ) end end + def filtered_commit_shas + limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha) + end + def signature_types [ ::CommitSignatures::GpgSignature, diff --git a/app/services/google_cloud/enable_vision_ai_service.rb b/app/services/google_cloud/enable_vision_ai_service.rb new file mode 100644 index 00000000000..f7adea706ed --- /dev/null +++ b/app/services/google_cloud/enable_vision_ai_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module GoogleCloud + class EnableVisionAiService < ::GoogleCloud::BaseService + def execute + gcp_project_ids = unique_gcp_project_ids + + if gcp_project_ids.empty? + error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.") + else + gcp_project_ids.each do |gcp_project_id| + google_api_client.enable_vision_api(gcp_project_id) + end + + success({ gcp_project_ids: gcp_project_ids }) + end + end + end +end diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb index 791be69f4d4..95de1fa21b7 100644 --- a/app/services/google_cloud/generate_pipeline_service.rb +++ b/app/services/google_cloud/generate_pipeline_service.rb @@ -4,6 +4,7 @@ module GoogleCloud class GeneratePipelineService < ::GoogleCloud::BaseService ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN' ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE' + ACTION_VISION_AI_PIPELINE = 'VISION_AI_PIPELINE' def execute commit_attributes = generate_commit_attributes @@ -53,6 +54,15 @@ module GoogleCloud branch_name: branch_name, start_branch: branch_name } + when ACTION_VISION_AI_PIPELINE + branch_name = "vision-ai-pipeline-#{SecureRandom.hex(8)}" + { + commit_message: 'Enable Vision AI Pipeline', + file_path: '.gitlab-ci.yml', + file_content: pipeline_content('gcp/vision-ai.gitlab-ci.yml'), + branch_name: branch_name, + start_branch: branch_name + } end end @@ -67,7 +77,11 @@ module GoogleCloud def append_remote_include(gitlab_ci_yml, include_url) stages = gitlab_ci_yml['stages'] || [] - gitlab_ci_yml['stages'] = (stages + %w[build test deploy]).uniq + gitlab_ci_yml['stages'] = if action == ACTION_VISION_AI_PIPELINE + (stages + %w[validate detect render]).uniq + else + (stages + %w[build test deploy]).uniq + end includes = gitlab_ci_yml['include'] || [] includes = Array.wrap(includes) diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 1c8df157716..16454360ee2 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -69,7 +69,7 @@ module Groups return false if group.root_ancestor == @new_parent_group.root_ancestor return true if group.contacts.exists? && !current_user.can?(:admin_crm_contact, @new_parent_group.root_ancestor) - return true if group.organizations.exists? && !current_user.can?(:admin_crm_organization, @new_parent_group.root_ancestor) + return true if group.crm_organizations.exists? && !current_user.can?(:admin_crm_organization, @new_parent_group.root_ancestor) false end diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb index 70834b8a85a..cfaf3e831eb 100644 --- a/app/services/import_csv/base_service.rb +++ b/app/services/import_csv/base_service.rb @@ -103,16 +103,12 @@ module ImportCsv strong_memoize_attr :detect_col_sep def create_object(attributes) - # NOTE: CSV imports are performed by workers, so we do not have a request context in order - # to create a SpamParams object to pass to the issuable create service. - spam_params = nil - # default_params can be extracted into a method if we need # to support creation of objects that belongs to groups. default_params = { container: project, current_user: user, params: attributes, - spam_params: spam_params } + perform_spam_check: false } create_service = create_object_class.new(**default_params.merge(extra_create_service_params)) diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb index a75c5d2e75c..fe0b41a3a31 100644 --- a/app/services/incident_management/incidents/create_service.rb +++ b/app/services/incident_management/incidents/create_service.rb @@ -25,7 +25,7 @@ module IncidentManagement severity: severity, alert_management_alerts: [alert].compact }, - spam_params: nil + perform_spam_check: false ).execute if alert diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb index 34af03640d3..e9d86e9228d 100644 --- a/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb +++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb @@ -22,7 +22,7 @@ module Integrations container: project, current_user: find_user.user, params: incident_params, - spam_params: nil + perform_spam_check: false ).execute raise IssueCreateError, create_response.errors.to_sentence if create_response.error? diff --git a/app/services/issuable/callbacks/base.rb b/app/services/issuable/callbacks/base.rb index 3fabce2c949..368dd76c16c 100644 --- a/app/services/issuable/callbacks/base.rb +++ b/app/services/issuable/callbacks/base.rb @@ -12,6 +12,7 @@ module Issuable end def after_initialize; end + def before_update; end def after_update_commit; end def after_save_commit; end diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index 261afb767bb..47770d101f9 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -8,11 +8,15 @@ module Issuable end def execute(issuable) + before_destroy(issuable) after_destroy(issuable) if issuable.destroy end private + # overriden in EE + def before_destroy(issuable); end + def after_destroy(issuable) delete_associated_records(issuable) issuable.update_project_counter_caches diff --git a/app/services/issuable/discussions_list_service.rb b/app/services/issuable/discussions_list_service.rb index cb9271de11d..83efbf65b92 100644 --- a/app/services/issuable/discussions_list_service.rb +++ b/app/services/issuable/discussions_list_service.rb @@ -4,7 +4,6 @@ # System notes also have a discussion ID assigned including Synthetic system notes. module Issuable class DiscussionsListService - include RendersNotes include Gitlab::Utils::StrongMemoize attr_reader :current_user, :issuable, :params @@ -37,7 +36,9 @@ module Issuable ).execute(notes) end - notes = prepare_notes_for_rendering(notes) + # Here we assume all notes belong to the same project as the work item + project = notes.first&.project + notes = ::Preloaders::Projects::NotesPreloader.new(project, current_user).call(notes) # we need to check the permission on every note, because some system notes for instance can have references to # resources that some user do not have read access, so those notes are filtered out from the list of notes. diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e9312bd6b31..3b007d4dba7 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -314,16 +314,19 @@ class IssuableBaseService < ::BaseContainerService before_update(issuable) - # Do not touch when saving the issuable if only changes position within a list. We should call - # this method at this point to capture all possible changes. - should_touch = update_timestamp?(issuable) - - issuable.updated_by = current_user if should_touch # 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) issuable_saved = issuable.with_transaction_returning_status do + @callbacks.each(&:before_update) + + # Do not touch when saving the issuable if only changes position within a list. We should call + # this method at this point to capture all possible changes. + should_touch = update_timestamp?(issuable) + + issuable.updated_by = current_user if should_touch + transaction_update(issuable, { save_with_touch: should_touch }) end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index efe42fb29d5..f982d66eb08 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -124,6 +124,10 @@ module Issues def update_project_counter_caches?(issue) super || issue.confidential_changed? end + + def log_audit_event(issue, user, event_type, message) + # defined in EE + end end end diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index c2a724254a7..8af44fb1e3c 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -68,9 +68,7 @@ module Issues new_params.delete(:created_at) new_params.delete(:updated_at) - # spam checking is not necessary, as no new content is being created. Passing nil for - # spam_params will cause SpamActionService to skip checking and return a success response. - spam_params = nil + # spam checking is not necessary, as no new content is being created. # Skip creation of system notes for existing attributes of the issue when cloning with notes. # The system notes of the old issue are copied over so we don't want to end up with duplicate notes. @@ -79,7 +77,7 @@ module Issues container: target_project, current_user: current_user, params: new_params, - spam_params: spam_params + perform_spam_check: false ).execute(skip_system_notes: with_notes) raise CloneError, create_result.errors.join(', ') if create_result.error? && create_result[:issue].blank? diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index e45033f2b91..f848a8db12a 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -28,6 +28,11 @@ module Issues event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note + if current_user.project_bot? + log_audit_event(issue, current_user, "#{issue.issue_type}_closed_by_project_bot", + "Closed #{issue.issue_type.humanize(capitalize: false)} #{issue.title}") + end + closed_via = _("commit %{commit_id}") % { commit_id: closed_via.id } if closed_via.is_a?(Commit) notification_service.async.close_issue(issue, current_user, { closed_via: closed_via }) if notifications diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index ba8f00d03d4..17b6866773e 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -9,14 +9,10 @@ module Issues rate_limit key: :issues_create, opts: { scope: [:project, :current_user, :external_author] } - # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because - # spam_checking is likely to be necessary. However, if there is not a request available in scope - # in the caller (for example, an issue created via email) and the required arguments to the - # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil. - def initialize(container:, spam_params:, current_user: nil, params: {}, build_service: nil) + def initialize(container:, current_user: nil, params: {}, build_service: nil, perform_spam_check: true) @extra_params = params.delete(:extra_params) || {} super(container: container, current_user: current_user, params: params) - @spam_params = spam_params + @perform_spam_check = perform_spam_check @build_service = build_service || BuildService.new(container: project, current_user: current_user, params: params) end @@ -51,12 +47,7 @@ module Issues end def before_create(issue) - Spam::SpamActionService.new( - spammable: issue, - spam_params: spam_params, - user: current_user, - action: :create - ).execute + issue.check_for_spam(user: current_user, action: :create) if perform_spam_check # current_user (defined in BaseService) is not available within run_after_commit block user = current_user @@ -109,7 +100,7 @@ module Issues :create_issue end - attr_reader :spam_params, :extra_params + attr_reader :perform_spam_check, :extra_params def create_timeline_event(issue) return unless issue.work_item_type&.incident? @@ -118,7 +109,7 @@ module Issues end def user_agent_detail_service - UserAgentDetailService.new(spammable: @issue, spam_params: spam_params) + UserAgentDetailService.new(spammable: @issue, perform_spam_check: perform_spam_check) end def handle_add_related_issue(issue) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index a2180dabdea..c1599ceef6e 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -90,9 +90,7 @@ module Issues new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params) new_params = new_params.merge(rewritten_old_entity_attributes) - # spam checking is not necessary, as no new content is being created. Passing nil for - # spam_params will cause SpamActionService to skip checking and return a success response. - spam_params = nil + # spam checking is not necessary, as no new content is being created. # Skip creation of system notes for existing attributes of the issue. The system notes of the old # issue are copied over so we don't want to end up with duplicate notes. @@ -100,7 +98,7 @@ module Issues container: @target_project, current_user: @current_user, params: new_params, - spam_params: spam_params + perform_spam_check: false ).execute(skip_system_notes: true) raise MoveError, create_result.errors.join(', ') if create_result.error? && create_result[:issue].blank? diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index f4d229ecec7..d71ba4e3414 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -7,6 +7,12 @@ module Issues if issue.reopen event_service.reopen_issue(issue, current_user) + + if current_user.project_bot? + log_audit_event(issue, current_user, "#{issue.issue_type}_reopened_by_project_bot", + "Reopened #{issue.issue_type.humanize(capitalize: false)} #{issue.title}") + end + create_note(issue, 'reopened') notification_service.async.reopen_issue(issue, current_user) perform_incident_management_actions(issue) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 201bf19b535..7ad56d5a755 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -2,12 +2,12 @@ module Issues class UpdateService < Issues::BaseService - # NOTE: For Issues::UpdateService, we default the spam_params to nil, because spam_checking is not - # necessary in many cases, and we don't want to require every caller to explicitly pass it as nil + # NOTE: For Issues::UpdateService, we default perform_spam_check to false, because spam_checking is not + # necessary in many cases, and we don't want to require every caller to explicitly pass it # to disable spam checking. - def initialize(container:, current_user: nil, params: {}, spam_params: nil) + def initialize(container:, current_user: nil, params: {}, perform_spam_check: false) super(container: container, current_user: current_user, params: params) - @spam_params = spam_params + @perform_spam_check = perform_spam_check end def execute(issue) @@ -26,14 +26,9 @@ module Issues def before_update(issue, skip_spam_check: false) change_work_item_type(issue) - return if skip_spam_check + return if skip_spam_check || !perform_spam_check - Spam::SpamActionService.new( - spammable: issue, - spam_params: spam_params, - user: current_user, - action: :update - ).execute + issue.check_for_spam(user: current_user, action: :update) end def change_work_item_type(issue) @@ -115,7 +110,14 @@ module Issues private - attr_reader :spam_params + attr_reader :perform_spam_check + + override :after_update + def after_update(issue, _old_associations) + super + + GraphqlTriggers.work_item_updated(issue) + end def handle_date_changes(issue) return unless issue.previous_changes.slice('due_date', 'start_date').any? diff --git a/app/services/jira_connect_installations/update_service.rb b/app/services/jira_connect_installations/update_service.rb index ff5b9671e2b..d0cf614a068 100644 --- a/app/services/jira_connect_installations/update_service.rb +++ b/app/services/jira_connect_installations/update_service.rb @@ -51,7 +51,7 @@ module JiraConnectInstallations 'Could not be installed on the instance. Network error' end - ServiceResponse.error(message: { instance_url: [message] }) + ServiceResponse.error(message: message) end def update_error diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index f174778e12e..5c1ec5add73 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -7,7 +7,9 @@ module MergeRequests def execute(merge_request) merge_request.ensure_merge_request_diff + logger.info(**log_payload(merge_request, 'Executing hooks')) execute_hooks(merge_request) + logger.info(**log_payload(merge_request, 'Executed hooks')) prepare_for_mergeability(merge_request) prepare_merge_request(merge_request) @@ -17,7 +19,9 @@ module MergeRequests private def prepare_for_mergeability(merge_request) + logger.info(**log_payload(merge_request, 'Creating pipeline')) create_pipeline_for(merge_request, current_user) + logger.info(**log_payload(merge_request, 'Pipeline created')) merge_request.update_head_pipeline check_mergeability(merge_request) end @@ -58,6 +62,17 @@ module MergeRequests def mark_merge_request_as_prepared(merge_request) merge_request.update!(prepared_at: Time.current) end + + def logger + @logger ||= Gitlab::AppLogger + end + + def log_payload(merge_request, message) + Gitlab::ApplicationContext.current.merge( + merge_request_id: merge_request.id, + message: message + ) + end end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 3a7b577d59a..b8853e8bcbc 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -334,7 +334,7 @@ module MergeRequests strong_memoize(:issue_iid) do @params_issue_iid || begin id = if target_project.external_issue_tracker - source_branch.match(target_project.external_issue_reference_pattern).try(:[], 0) + target_project.external_issue_reference_pattern.match(source_branch).try(:[], 0) end id || source_branch.match(/\A(\d+)-/).try(:[], 1) diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index da3a9652d69..62928e05a89 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -12,6 +12,7 @@ module MergeRequests merge_request.allow_broken = true if merge_request.close + expire_unapproved_key(merge_request) create_event(merge_request) merge_request_activity_counter.track_close_mr_action(user: current_user) create_note(merge_request) @@ -40,8 +41,14 @@ module MergeRequests end end + def expire_unapproved_key(merge_request) + nil + end + def trigger_merge_request_merge_status_updated(merge_request) GraphqlTriggers.merge_request_merge_status_updated(merge_request) end end end + +MergeRequests::CloseService.prepend_mod diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 39e1594d215..9135a80c883 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -41,6 +41,7 @@ module MergeRequests # timeout, we do this before we attempt to save the merge request. merge_request.skip_ensure_merge_request_diff = true + merge_request.check_for_spam(user: current_user, action: :create) end def set_projects! diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 10301774f96..5e41375e7a0 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -160,7 +160,7 @@ module MergeRequests end def handle_merge_error(log_message:, save_message_on_model: false) - log_error("MergeService ERROR: #{merge_request_info} - #{log_message}") + log_error("MergeService ERROR: #{merge_request_info}:#{merge_status} - #{log_message}") @merge_request.update(merge_error: log_message) if save_message_on_model end @@ -186,6 +186,10 @@ module MergeRequests @merge_request_info ||= merge_request.to_reference(full: true) end + def merge_status + @merge_status ||= @merge_request.merge_status + end + def source_matches? # params-keys are symbols coming from the controller, but when they get # loaded from the database they're strings diff --git a/app/services/merge_requests/mergeability/logger.rb b/app/services/merge_requests/mergeability/logger.rb index 88ef6d81eaa..612c79f0aae 100644 --- a/app/services/merge_requests/mergeability/logger.rb +++ b/app/services/merge_requests/mergeability/logger.rb @@ -22,8 +22,8 @@ module MergeRequests result = yield + observe_result(mergeability_name, result) observe("mergeability.#{mergeability_name}.duration_s", current_monotonic_time - op_started_at) - observe_sql_counters(mergeability_name, op_start_db_counters, current_db_counter_payload) result @@ -31,7 +31,13 @@ module MergeRequests private - attr_reader :destination, :merge_request + attr_reader :destination, :merge_request, :stored_result + + def observe_result(name, result) + return unless result.respond_to?(:success?) + + observe("mergeability.#{name}.successful", result.success?) + end def observe(name, value) observations[name.to_s].push(value) diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index d2247a6d4c1..b2e15cc7c7e 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -37,3 +37,5 @@ module MergeRequests end end end + +MergeRequests::ReopenService.prepend_mod diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index aaed01403cb..598dbaf93a9 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -209,6 +209,11 @@ module MergeRequests old_branch, new_branch) end + override :before_update + def before_update(merge_request, skip_spam_check: false) + merge_request.check_for_spam(user: current_user, action: :update) unless skip_spam_check + end + override :handle_quick_actions def handle_quick_actions(merge_request) super diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 7dd6cd9a87c..fdab2a07990 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -21,6 +21,8 @@ module Notes # only, there is no need be create a note! execute_quick_actions(note) do |only_commands| + note.check_for_spam(action: :create, user: current_user) unless only_commands + note.run_after_commit do # Finish the harder work in the background NewNoteWorker.perform_async(note.id) @@ -105,16 +107,10 @@ module Notes def do_commands(note, update_params, message, command_names, only_commands) return if quick_actions_service.commands_executed_count.to_i == 0 - if update_params.present? - invalid_message = validate_commands(note, update_params) - - if invalid_message - note.errors.add(:validation, invalid_message) - message = invalid_message - else - quick_actions_service.apply_updates(update_params, note) - note.commands_changes = update_params - end + update_error = quick_actions_update_errors(note, update_params) + if update_error + note.errors.add(:validation, update_error) + message = update_error end # We must add the error after we call #save because errors are reset @@ -127,6 +123,19 @@ module Notes end end + def quick_actions_update_errors(note, params) + return unless params.present? + + invalid_message = validate_commands(note, params) + return invalid_message if invalid_message + + service_response = quick_actions_service.apply_updates(params, note) + note.commands_changes = params + return if service_response.success? + + service_response.message.join(', ') + end + def quick_action_options { merge_request_diff_head_sha: params[:merge_request_diff_head_sha], diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 38f7a23ce29..cba7398ebc0 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -50,7 +50,21 @@ module Notes update_params[:spend_time][:note_id] = note.id end - noteable_update_service(note, update_params).execute(note.noteable) + execute_update_service(note, update_params) + end + + private + + def execute_update_service(note, params) + service_response = noteable_update_service(note, params).execute(note.noteable) + + service_errors = if service_response.respond_to?(:errors) + service_response.errors.full_messages + elsif service_response.respond_to?(:[]) && service_response[:status] == :error + service_response[:message] + end + + service_errors.blank? ? ServiceResponse.success : ServiceResponse.error(message: service_errors) end def noteable_update_service(note, update_params) diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index e04891da7f8..52940281018 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -9,6 +9,8 @@ module Notes note.assign_attributes(params) + return note unless note.valid? + track_note_edit_usage_for_issues(note) if note.for_issue? track_note_edit_usage_for_merge_requests(note) if note.for_merge_request? @@ -23,10 +25,7 @@ module Notes note.note = content end - if note.note_changed? - note.assign_attributes(last_edited_at: Time.current, updated_by: current_user) - end - + update_note(note, only_commands) note.save unless only_commands || note.for_personal_snippet? @@ -45,7 +44,6 @@ module Notes if only_commands delete_note(note, message) - note = nil else note.save end @@ -56,6 +54,13 @@ module Notes private + def update_note(note, only_commands) + return unless note.note_changed? + + note.assign_attributes(last_edited_at: Time.current, updated_by: current_user) + note.check_for_spam(action: :update, user: current_user) unless only_commands + end + def delete_note(note, message) # We must add the error after we call #save because errors are reset # when #save is called diff --git a/app/services/object_storage/delete_stale_direct_uploads_service.rb b/app/services/object_storage/delete_stale_direct_uploads_service.rb new file mode 100644 index 00000000000..e9560753fc4 --- /dev/null +++ b/app/services/object_storage/delete_stale_direct_uploads_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ObjectStorage + class DeleteStaleDirectUploadsService < BaseService + MAX_EXEC_DURATION = 250.seconds.freeze + + def initialize; end + + def execute + total_pending_entries = ObjectStorage::PendingDirectUpload.count + total_deleted_stale_entries = 0 + + timeout = false + start = Time.current + + ObjectStorage::PendingDirectUpload.each do |pending_upload| + if pending_upload.stale? + pending_upload.delete + total_deleted_stale_entries += 1 + end + + if (Time.current - start) > MAX_EXEC_DURATION + timeout = true + break + end + end + + success( + total_pending_entries: total_pending_entries, + total_deleted_stale_entries: total_deleted_stale_entries, + execution_timeout: timeout + ) + end + end +end diff --git a/app/services/packages/cleanup/execute_policy_service.rb b/app/services/packages/cleanup/execute_policy_service.rb index b432f6d0acb..891866bce5f 100644 --- a/app/services/packages/cleanup/execute_policy_service.rb +++ b/app/services/packages/cleanup/execute_policy_service.rb @@ -79,10 +79,9 @@ module Packages end def batch_deadline - strong_memoize(:batch_deadline) do - MAX_EXECUTION_TIME.from_now - end + MAX_EXECUTION_TIME.from_now end + strong_memoize_attr :batch_deadline def response_success(timeout:) ServiceResponse.success( diff --git a/app/services/packages/cleanup/update_policy_service.rb b/app/services/packages/cleanup/update_policy_service.rb index 6744accc007..911a060a18f 100644 --- a/app/services/packages/cleanup/update_policy_service.rb +++ b/app/services/packages/cleanup/update_policy_service.rb @@ -18,10 +18,9 @@ module Packages private def policy - strong_memoize(:policy) do - project.packages_cleanup_policy - end + project.packages_cleanup_policy end + strong_memoize_attr :policy def allowed? can?(current_user, :admin_package, project) diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb index 0f5429f667e..ae5933fad7c 100644 --- a/app/services/packages/composer/create_package_service.rb +++ b/app/services/packages/composer/create_package_service.rb @@ -27,10 +27,9 @@ module Packages end def composer_json - strong_memoize(:composer_json) do - ::Packages::Composer::ComposerJsonService.new(project, target).execute - end + ::Packages::Composer::ComposerJsonService.new(project, target).execute end + strong_memoize_attr :composer_json def package_name composer_json['name'] diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb index 24e40b5c986..2e9299a847c 100644 --- a/app/services/packages/debian/create_package_file_service.rb +++ b/app/services/packages/debian/create_package_file_service.rb @@ -14,7 +14,7 @@ module Packages raise ArgumentError, "Invalid user" unless current_user.present? # Debian package file are first uploaded to incoming with empty metadata, - # and are moved later by Packages::Debian::ProcessChangesService + # and are moved later by Packages::Debian::ProcessPackageFileService package_file = package.package_files.create!( file: params[:file], size: params[:file]&.size, @@ -29,14 +29,12 @@ module Packages } ) - if params[:distribution].present? && params[:component].present? + if end_of_new_upload? ::Packages::Debian::ProcessPackageFileWorker.perform_async( package_file.id, params[:distribution], params[:component] ) - elsif params[:file_name].end_with? '.changes' - ::Packages::Debian::ProcessChangesWorker.perform_async(package_file.id, current_user.id) end package_file @@ -45,6 +43,10 @@ module Packages private attr_reader :package, :current_user, :params + + def end_of_new_upload? + params[:distribution].present? || params[:file_name].end_with?('.changes') + end end end end diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb index 43a4db5bdfc..5f06f46de58 100644 --- a/app/services/packages/debian/extract_changes_metadata_service.rb +++ b/app/services/packages/debian/extract_changes_metadata_service.rb @@ -26,10 +26,9 @@ module Packages private def metadata - strong_memoize(:metadata) do - ::Packages::Debian::ExtractMetadataService.new(@package_file).execute - end + ::Packages::Debian::ExtractMetadataService.new(@package_file).execute end + strong_memoize_attr :metadata def file_type metadata[:file_type] @@ -40,20 +39,19 @@ module Packages end def files - strong_memoize(:files) do - raise ExtractionError, "is not a changes file" unless file_type == :changes - raise ExtractionError, "Files field is missing" if fields['Files'].blank? - raise ExtractionError, "Checksums-Sha1 field is missing" if fields['Checksums-Sha1'].blank? - raise ExtractionError, "Checksums-Sha256 field is missing" if fields['Checksums-Sha256'].blank? - - init_entries_from_files - entries_from_checksums_sha1 - entries_from_checksums_sha256 - entries_from_package_files - - @entries - end + raise ExtractionError, "is not a changes file" unless file_type == :changes + raise ExtractionError, "Files field is missing" if fields['Files'].blank? + raise ExtractionError, "Checksums-Sha1 field is missing" if fields['Checksums-Sha1'].blank? + raise ExtractionError, "Checksums-Sha256 field is missing" if fields['Checksums-Sha256'].blank? + + init_entries_from_files + entries_from_checksums_sha1 + entries_from_checksums_sha256 + entries_from_package_files + + @entries end + strong_memoize_attr :files def init_entries_from_files each_lines_for('Files') do |line| @@ -101,12 +99,17 @@ module Packages def entries_from_package_files @entries.each do |filename, entry| - entry.package_file = ::Packages::PackageFileFinder.new(@package_file.package, filename).execute! + entry.package_file = ::Packages::PackageFileFinder.new(incoming, filename).execute! entry.validate! rescue ActiveRecord::RecordNotFound raise ExtractionError, "#{filename} is listed in Files but was not uploaded" end end + + def incoming + @package_file.package.project.packages.debian_incoming_package! + end + strong_memoize_attr(:incoming) end end end diff --git a/app/services/packages/debian/generate_distribution_key_service.rb b/app/services/packages/debian/generate_distribution_key_service.rb index 917965da58e..25c84955a52 100644 --- a/app/services/packages/debian/generate_distribution_key_service.rb +++ b/app/services/packages/debian/generate_distribution_key_service.rb @@ -43,10 +43,9 @@ module Packages attr_reader :params def passphrase - strong_memoize(:passphrase) do - params[:passphrase] || ::User.random_password - end + params[:passphrase] || ::User.random_password end + strong_memoize_attr :passphrase def pinentry_script_content escaped_passphrase = Shellwords.escape(passphrase) @@ -90,7 +89,7 @@ module Packages 'Name-Email': params[:name_email] || Gitlab.config.gitlab.email_reply_to, 'Name-Comment': params[:name_comment] || 'GitLab Debian repository automatic signing key', 'Expire-Date': params[:expire_date] || 0, - 'Passphrase': passphrase + Passphrase: passphrase }.map { |k, v| "#{k}: #{v}\n" }.join + '</GnupgKeyParms>' end diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index d69f6eb1511..9feb860ae87 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -213,10 +213,9 @@ module Packages end def release_content - strong_memoize(:release_content) do - release_header + release_sums - end + release_header + release_sums end + strong_memoize_attr :release_content def release_header [ @@ -235,10 +234,9 @@ module Packages end def release_date - strong_memoize(:release_date) do - Time.now.utc - end + Time.now.utc end + strong_memoize_attr :release_date def release_sums # NB: MD5Sum was removed for FIPS compliance diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb index 129f2e5c9bc..eb88e7c9b59 100644 --- a/app/services/packages/debian/process_changes_service.rb +++ b/app/services/packages/debian/process_changes_service.rb @@ -76,10 +76,9 @@ module Packages end def metadata - strong_memoize(:metadata) do - ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute - end + ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute end + strong_memoize_attr :metadata def files metadata[:files] @@ -90,16 +89,15 @@ module Packages end def package - strong_memoize(:package) do - params = { - 'name': metadata[:fields]['Source'], - 'version': metadata[:fields]['Version'], - 'distribution_name': metadata[:fields]['Distribution'] - } - response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute - response.payload[:package] - end + params = { + name: metadata[:fields]['Source'], + version: metadata[:fields]['Version'], + distribution_name: metadata[:fields]['Distribution'] + } + response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute + response.payload[:package] end + strong_memoize_attr :package # used by ExclusiveLeaseGuard def lease_key diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb index f4fcd3a563c..684192f6006 100644 --- a/app/services/packages/debian/process_package_file_service.rb +++ b/app/services/packages/debian/process_package_file_service.rb @@ -10,6 +10,8 @@ module Packages # used by ExclusiveLeaseGuard DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + SIMPLE_DEB_FILE_TYPES = %i[deb udeb ddeb].freeze + def initialize(package_file, distribution_name, component_name) @package_file = package_file @distribution_name = distribution_name @@ -22,9 +24,10 @@ module Packages validate! try_obtain_lease do - package.transaction do + distribution.transaction do rename_package_and_set_version update_package + update_files_metadata if changes_file? update_file_metadata cleanup_temp_package end @@ -36,28 +39,61 @@ module Packages private def validate! - raise ArgumentError, 'missing distribution name' unless @distribution_name.present? - raise ArgumentError, 'missing component name' unless @component_name.present? raise ArgumentError, 'package file without Debian metadata' unless @package_file.debian_file_metadatum raise ArgumentError, 'already processed package file' unless @package_file.debian_file_metadatum.unknown? - if file_metadata[:file_type] == :deb || file_metadata[:file_type] == :udeb || file_metadata[:file_type] == :ddeb - return - end + changes_file? ? validate_changes_file! : validate_package_file! + end + + def changes_file? + @package_file.file_name.end_with?('.changes') + end + + def validate_changes_file! + raise ArgumentError, 'unwanted distribution name' unless @distribution_name.nil? + raise ArgumentError, 'unwanted component name' unless @component_name.nil? + raise ArgumentError, 'missing Source field' unless file_metadata.dig(:fields, 'Source').present? + raise ArgumentError, 'missing Version field' unless file_metadata.dig(:fields, 'Version').present? + raise ArgumentError, 'missing Distribution field' unless file_metadata.dig(:fields, 'Distribution').present? + end + + def validate_package_file! + raise ArgumentError, 'missing distribution name' unless @distribution_name.present? + raise ArgumentError, 'missing component name' unless @component_name.present? + + return if SIMPLE_DEB_FILE_TYPES.include?(file_metadata[:file_type]) raise ArgumentError, "invalid package file type: #{file_metadata[:file_type]}" end def file_metadata - ::Packages::Debian::ExtractMetadataService.new(@package_file).execute + metadata_service_class.new(@package_file).execute end strong_memoize_attr :file_metadata + def metadata_service_class + changes_file? ? ::Packages::Debian::ExtractChangesMetadataService : ::Packages::Debian::ExtractMetadataService + end + + def distribution + Packages::Debian::DistributionsFinder.new( + @package_file.package.project, + codename_or_suite: package_distribution + ).execute.last! + end + strong_memoize_attr :distribution + + def package_distribution + return file_metadata[:fields]['Distribution'] if changes_file? + + @distribution_name + end + def package packages = temp_package.project .packages .existing_debian_packages_with(name: package_name, version: package_version) - package = packages.with_debian_codename_or_suite(@distribution_name) + package = packages.with_debian_codename_or_suite(package_distribution) .first unless package @@ -79,10 +115,14 @@ module Packages strong_memoize_attr :temp_package def package_name + return file_metadata[:fields]['Source'] if changes_file? + package_name_and_version[0] end def package_version + return file_metadata[:fields]['Version'] if changes_file? + package_name_and_version[1] end @@ -121,13 +161,24 @@ module Packages package.id == temp_package.id end - def distribution - Packages::Debian::DistributionsFinder.new( - @package_file.package.project, - codename_or_suite: @distribution_name - ).execute.last! + def update_files_metadata + file_metadata[:files].each do |_, entry| + file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute + + ::Packages::UpdatePackageFileService.new(entry.package_file, package_id: package.id) + .execute + + # Force reload from database, as package has changed + entry.package_file.reload_package + + entry.package_file.debian_file_metadatum.update!( + file_type: file_metadata[:file_type], + component: entry.component, + architecture: file_metadata[:architecture], + fields: file_metadata[:fields] + ) + end end - strong_memoize_attr :distribution def update_file_metadata ::Packages::UpdatePackageFileService.new(@package_file, package_id: package.id) @@ -150,7 +201,7 @@ module Packages # used by ExclusiveLeaseGuard def lease_key - "packages:debian:process_package_file_service:package_file:#{@package_file.id}" + "packages:debian:process_package_file_service:#{temp_package.project_id}_#{package_name}_#{package_version}" end # used by ExclusiveLeaseGuard diff --git a/app/services/packages/helm/process_file_service.rb b/app/services/packages/helm/process_file_service.rb index f53c63d2b01..219f3d8c781 100644 --- a/app/services/packages/helm/process_file_service.rb +++ b/app/services/packages/helm/process_file_service.rb @@ -57,28 +57,25 @@ module Packages end def temp_package - strong_memoize(:temp_package) do - package_file.package - end + package_file.package end + strong_memoize_attr :temp_package def package - strong_memoize(:package) do - project_packages = package_file.package.project.packages - package = project_packages.with_package_type(:helm) - .with_name(metadata['name']) - .with_version(metadata['version']) - .not_pending_destruction - .last - package || temp_package - end + project_packages = package_file.package.project.packages + package = project_packages.with_package_type(:helm) + .with_name(metadata['name']) + .with_version(metadata['version']) + .not_pending_destruction + .last + package || temp_package end + strong_memoize_attr :package def metadata - strong_memoize(:metadata) do - ::Packages::Helm::ExtractFileMetadataService.new(package_file).execute - end + ::Packages::Helm::ExtractFileMetadataService.new(package_file).execute end + strong_memoize_attr :metadata def file_name "#{metadata['name']}-#{metadata['version']}.tgz" diff --git a/app/services/packages/maven/metadata/base_create_xml_service.rb b/app/services/packages/maven/metadata/base_create_xml_service.rb index 3b0d93e1dfb..d67d5a21a91 100644 --- a/app/services/packages/maven/metadata/base_create_xml_service.rb +++ b/app/services/packages/maven/metadata/base_create_xml_service.rb @@ -19,12 +19,11 @@ module Packages attr_reader :logger def xml_doc - strong_memoize(:xml_doc) do - Nokogiri::XML(@metadata_content) do |config| - config.default_xml.noblanks - end + Nokogiri::XML(@metadata_content) do |config| + config.default_xml.noblanks end end + strong_memoize_attr :xml_doc def xml_node(name, content) xml_doc.create_element(name).tap { |e| e.content = content } diff --git a/app/services/packages/maven/metadata/create_plugins_xml_service.rb b/app/services/packages/maven/metadata/create_plugins_xml_service.rb index 707a8c577ba..e99a72bc0ab 100644 --- a/app/services/packages/maven/metadata/create_plugins_xml_service.rb +++ b/app/services/packages/maven/metadata/create_plugins_xml_service.rb @@ -40,37 +40,34 @@ module Packages end def plugins_xml_node - strong_memoize(:plugins_xml_node) do - xml_doc.xpath(XPATH_PLUGINS) + xml_doc.xpath(XPATH_PLUGINS) .first - end end + strong_memoize_attr :plugins_xml_node def plugin_artifact_ids_from_xml - strong_memoize(:plugin_artifact_ids_from_xml) do - plugins_xml_node.xpath(XPATH_PLUGIN_ARTIFACT_ID) + plugins_xml_node.xpath(XPATH_PLUGIN_ARTIFACT_ID) .map(&:content) - end end + strong_memoize_attr :plugin_artifact_ids_from_xml def plugin_artifact_ids_from_database - strong_memoize(:plugin_artifact_ids_from_database) do - package_names = plugin_artifact_ids_from_xml.map do |artifact_id| - "#{@package.name}/#{artifact_id}" - end - - packages = @package.project.packages - .maven - .displayable - .with_name(package_names) - .has_version - - ::Packages::Maven::Metadatum.for_package_ids(packages.select(:id)) - .order_created - .pluck_app_name - .uniq + package_names = plugin_artifact_ids_from_xml.map do |artifact_id| + "#{@package.name}/#{artifact_id}" end + + packages = @package.project.packages + .maven + .displayable + .with_name(package_names) + .has_version + + ::Packages::Maven::Metadatum.for_package_ids(packages.select(:id)) + .order_created + .pluck_app_name + .uniq end + strong_memoize_attr :plugin_artifact_ids_from_database def plugin_node_for(artifact_id) xml_doc.create_element('plugin').tap do |plugin_node| diff --git a/app/services/packages/maven/metadata/create_versions_xml_service.rb b/app/services/packages/maven/metadata/create_versions_xml_service.rb index c2ac7fea703..966540bcba2 100644 --- a/app/services/packages/maven/metadata/create_versions_xml_service.rb +++ b/app/services/packages/maven/metadata/create_versions_xml_service.rb @@ -91,49 +91,43 @@ module Packages end def versioning_xml_node - strong_memoize(:versioning_xml_node) do - xml_doc.xpath(XPATH_VERSIONING).first - end + xml_doc.xpath(XPATH_VERSIONING).first end + strong_memoize_attr :versioning_xml_node def versions_xml_node - strong_memoize(:versions_xml_node) do - versioning_xml_node&.xpath(XPATH_VERSIONS) + versioning_xml_node&.xpath(XPATH_VERSIONS) &.first - end end + strong_memoize_attr :versions_xml_node def version_xml_nodes versions_xml_node&.xpath(XPATH_VERSION) end def latest_xml_node - strong_memoize(:latest_xml_node) do - versioning_xml_node&.xpath(XPATH_LATEST) + versioning_xml_node&.xpath(XPATH_LATEST) &.first - end end + strong_memoize_attr :latest_xml_node def release_xml_node - strong_memoize(:release_xml_node) do - versioning_xml_node&.xpath(XPATH_RELEASE) + versioning_xml_node&.xpath(XPATH_RELEASE) &.first - end end + strong_memoize_attr :release_xml_node def last_updated_xml_node - strong_memoize(:last_updated_xml_mode) do - versioning_xml_node.xpath(XPATH_LAST_UPDATED) + versioning_xml_node.xpath(XPATH_LAST_UPDATED) .first - end end + strong_memoize_attr :last_updated_xml_node def versions_from_xml - strong_memoize(:versions_from_xml) do - versions_xml_node.xpath(XPATH_VERSION) + versions_xml_node.xpath(XPATH_VERSION) .map(&:text) - end end + strong_memoize_attr :versions_from_xml def latest_from_xml latest_xml_node&.text @@ -144,27 +138,25 @@ module Packages end def versions_from_database - strong_memoize(:versions_from_database) do - @package.project.packages + @package.project.packages .maven .displayable .with_name(@package.name) .has_version .order_created .pluck_versions - end end + strong_memoize_attr :versions_from_database def latest_from_database versions_from_database.last end def release_from_database - strong_memoize(:release_from_database) do - non_snapshot_versions_from_database = versions_from_database.reject { |v| v.ends_with?('SNAPSHOT') } - non_snapshot_versions_from_database.last - end + non_snapshot_versions_from_database = versions_from_database.reject { |v| v.ends_with?('SNAPSHOT') } + non_snapshot_versions_from_database.last end + strong_memoize_attr :release_from_database def log_malformed_content(reason) logger.warn( diff --git a/app/services/packages/maven/metadata/sync_service.rb b/app/services/packages/maven/metadata/sync_service.rb index dacf6750412..14196f090dd 100644 --- a/app/services/packages/maven/metadata/sync_service.rb +++ b/app/services/packages/maven/metadata/sync_service.rb @@ -70,25 +70,22 @@ module Packages end def metadata_package_file_for_versions - strong_memoize(:metadata_file_for_versions) do - metadata_package_file_for(versionless_package_for_versions) - end + metadata_package_file_for(versionless_package_for_versions) end + strong_memoize_attr :metadata_package_file_for_versions def versionless_package_for_versions - strong_memoize(:versionless_package_for_versions) do - versionless_package_named(package_name) - end + versionless_package_named(package_name) end + strong_memoize_attr :versionless_package_for_versions def metadata_package_file_for_plugins - strong_memoize(:metadata_package_file_for_plugins) do - pkg_name = package_name_for_plugins - next unless pkg_name + pkg_name = package_name_for_plugins + return unless pkg_name - metadata_package_file_for(versionless_package_named(package_name_for_plugins)) - end + metadata_package_file_for(versionless_package_named(package_name_for_plugins)) end + strong_memoize_attr :metadata_package_file_for_plugins def metadata_package_file_for(package) return unless package diff --git a/app/services/packages/ml_model/create_package_file_service.rb b/app/services/packages/ml_model/create_package_file_service.rb new file mode 100644 index 00000000000..574f70940fc --- /dev/null +++ b/app/services/packages/ml_model/create_package_file_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Packages + module MlModel + class CreatePackageFileService < BaseService + def execute + ::Packages::Package.transaction do + create_package_file(find_or_create_package) + end + end + + private + + def find_or_create_package + package_params = { + name: params[:package_name], + version: params[:package_version], + build: params[:build], + status: params[:status] + } + + package = ::Packages::MlModel::FindOrCreatePackageService + .new(project, current_user, package_params) + .execute + + package.update_column(:status, params[:status]) if params[:status] && params[:status] != package.status + + package.create_build_infos!(params[:build]) + + package + end + + def create_package_file(package) + file_params = { + file: params[:file], + size: params[:file].size, + file_sha256: params[:file].sha256, + file_name: params[:file_name], + build: params[:build] + } + + ::Packages::CreatePackageFileService.new(package, file_params).execute + end + end + end +end diff --git a/app/services/packages/ml_model/find_or_create_package_service.rb b/app/services/packages/ml_model/find_or_create_package_service.rb new file mode 100644 index 00000000000..cab99e1b008 --- /dev/null +++ b/app/services/packages/ml_model/find_or_create_package_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Packages + module MlModel + class FindOrCreatePackageService < ::Packages::CreatePackageService + def execute + find_or_create_package!(::Packages::Package.package_types['ml_model']) + end + end + end +end diff --git a/app/services/packages/npm/create_metadata_cache_service.rb b/app/services/packages/npm/create_metadata_cache_service.rb index 1cc5f7f34e7..75cff5c5453 100644 --- a/app/services/packages/npm/create_metadata_cache_service.rb +++ b/app/services/packages/npm/create_metadata_cache_service.rb @@ -9,10 +9,9 @@ module Packages # used by ExclusiveLeaseGuard DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze - def initialize(project, package_name, packages) + def initialize(project, package_name) @project = project @package_name = package_name - @packages = packages end def execute @@ -28,13 +27,19 @@ module Packages private - attr_reader :package_name, :packages, :project + attr_reader :package_name, :project def metadata_content metadata.payload.to_json end strong_memoize_attr :metadata_content + def packages + ::Packages::Npm::PackageFinder + .new(package_name, project: project) + .execute + end + def metadata Packages::Npm::GenerateMetadataService.new(package_name, packages).execute end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index c71ae060dd9..2c578760cc5 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -61,10 +61,9 @@ module Packages end def version - strong_memoize(:version) do - params[:versions].each_key.first - end + params[:versions].each_key.first end + strong_memoize_attr :version def version_data params[:versions][version] @@ -79,30 +78,27 @@ module Packages end def package_file_name - strong_memoize(:package_file_name) do - "#{name}-#{version}.tgz" - end + "#{name}-#{version}.tgz" end + strong_memoize_attr :package_file_name def attachment - strong_memoize(:attachment) do - params['_attachments'][package_file_name] - end + params['_attachments'][package_file_name] end + strong_memoize_attr :attachment # TODO (technical debt): Extract the package size calculation to its own component and unit test it separately. def calculated_package_file_size - strong_memoize(:calculated_package_file_size) do - # This calculation is based on: - # 1. 4 chars in a Base64 encoded string are 3 bytes in the original string. Meaning 1 char is 0.75 bytes. - # 2. The encoded string may have 1 or 2 extra '=' chars used for padding. Each padding char means 1 byte less in the original string. - # Reference: - # - https://blog.aaronlenoir.com/2017/11/10/get-original-length-from-base-64-string/ - # - https://en.wikipedia.org/wiki/Base64#Decoding_Base64_with_padding - encoded_data = attachment['data'] - ((encoded_data.length * 0.75) - encoded_data[-2..].count('=')).to_i - end + # This calculation is based on: + # 1. 4 chars in a Base64 encoded string are 3 bytes in the original string. Meaning 1 char is 0.75 bytes. + # 2. The encoded string may have 1 or 2 extra '=' chars used for padding. Each padding char means 1 byte less in the original string. + # Reference: + # - https://blog.aaronlenoir.com/2017/11/10/get-original-length-from-base-64-string/ + # - https://en.wikipedia.org/wiki/Base64#Decoding_Base64_with_padding + encoded_data = attachment['data'] + ((encoded_data.length * 0.75) - encoded_data[-2..].count('=')).to_i end + strong_memoize_attr :calculated_package_file_size def file_params { @@ -134,29 +130,26 @@ module Packages end def field_sizes - strong_memoize(:field_sizes) do - package_json.transform_values do |value| - value.to_s.size - end + package_json.transform_values do |value| + value.to_s.size end end + strong_memoize_attr :field_sizes def filtered_field_sizes - strong_memoize(:filtered_field_sizes) do - field_sizes.select do |_, size| - size >= ::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING - end + field_sizes.select do |_, size| + size >= ::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING end end + strong_memoize_attr :filtered_field_sizes def largest_fields - strong_memoize(:largest_fields) do - field_sizes + field_sizes .sort_by { |a| a[1] } .reverse[0..::Packages::Npm::Metadatum::NUM_FIELDS_FOR_ERROR_TRACKING - 1] .to_h - end end + strong_memoize_attr :largest_fields def field_sizes_for_error_tracking filtered_field_sizes.empty? ? largest_fields : filtered_field_sizes diff --git a/app/services/packages/npm/create_tag_service.rb b/app/services/packages/npm/create_tag_service.rb index 82974d0ca4b..e212b37c9ba 100644 --- a/app/services/packages/npm/create_tag_service.rb +++ b/app/services/packages/npm/create_tag_service.rb @@ -23,12 +23,11 @@ module Packages private def existing_tag - strong_memoize(:existing_tag) do - Packages::TagsFinder + Packages::TagsFinder .new(package.project, package.name, package_type: package.package_type) .find_by_name(tag_name) - end end + strong_memoize_attr :existing_tag end end end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index 02086b2a282..5c60a2912ae 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -7,18 +7,22 @@ module Packages ExtractionError = Class.new(StandardError) + ROOT_XPATH = '//xmlns:package/xmlns:metadata/xmlns' + XPATHS = { - package_name: '//xmlns:package/xmlns:metadata/xmlns:id', - package_version: '//xmlns:package/xmlns:metadata/xmlns:version', - license_url: '//xmlns:package/xmlns:metadata/xmlns:licenseUrl', - project_url: '//xmlns:package/xmlns:metadata/xmlns:projectUrl', - icon_url: '//xmlns:package/xmlns:metadata/xmlns:iconUrl' + package_name: "#{ROOT_XPATH}:id", + package_version: "#{ROOT_XPATH}:version", + authors: "#{ROOT_XPATH}:authors", + description: "#{ROOT_XPATH}:description", + license_url: "#{ROOT_XPATH}:licenseUrl", + project_url: "#{ROOT_XPATH}:projectUrl", + icon_url: "#{ROOT_XPATH}:iconUrl" }.freeze - XPATH_DEPENDENCIES = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:dependency' - XPATH_DEPENDENCY_GROUPS = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:group' - XPATH_TAGS = '//xmlns:package/xmlns:metadata/xmlns:tags' - XPATH_PACKAGE_TYPES = '//xmlns:package/xmlns:metadata/xmlns:packageTypes/xmlns:packageType' + XPATH_DEPENDENCIES = "#{ROOT_XPATH}:dependencies/xmlns:dependency".freeze + XPATH_DEPENDENCY_GROUPS = "#{ROOT_XPATH}:dependencies/xmlns:group".freeze + XPATH_TAGS = "#{ROOT_XPATH}:tags".freeze + XPATH_PACKAGE_TYPES = "#{ROOT_XPATH}:packageTypes/xmlns:packageType".freeze MAX_FILE_SIZE = 4.megabytes.freeze @@ -35,14 +39,9 @@ module Packages private def package_file - strong_memoize(:package_file) do - ::Packages::PackageFile.find_by_id(@package_file_id) - end - end - - def project - package_file.package.project + ::Packages::PackageFile.find_by_id(@package_file_id) end + strong_memoize_attr :package_file def valid_package_file? package_file && diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb index fea424b3aa8..7d1585f8903 100644 --- a/app/services/packages/nuget/search_service.rb +++ b/app/services/packages/nuget/search_service.rb @@ -89,17 +89,16 @@ module Packages end def base_matching_package_names - strong_memoize(:base_matching_package_names) do - # rubocop: disable CodeReuse/ActiveRecord - pkgs = nuget_packages.order_name + # rubocop: disable CodeReuse/ActiveRecord + pkgs = nuget_packages.order_name .select_distinct_name .where(project_id: project_ids) - pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions? - pkgs = pkgs.search_by_name(@search_term) if @search_term.present? - pkgs - # rubocop: enable CodeReuse/ActiveRecord - end + pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions? + pkgs = pkgs.search_by_name(@search_term) if @search_term.present? + pkgs + # rubocop: enable CodeReuse/ActiveRecord end + strong_memoize_attr :base_matching_package_names def nuget_packages Packages::Package.nuget @@ -111,11 +110,10 @@ module Packages def project_ids_cte return unless use_project_ids_cte? - strong_memoize(:project_ids_cte) do - query = projects_visible_to_user(@current_user, within_group: @project_or_group) - Gitlab::SQL::CTE.new(:project_ids, query.select(:id)) - end + query = projects_visible_to_user(@current_user, within_group: @project_or_group) + Gitlab::SQL::CTE.new(:project_ids, query.select(:id)) end + strong_memoize_attr :project_ids_cte def project_ids return @project_or_group.id if project? diff --git a/app/services/packages/nuget/sync_metadatum_service.rb b/app/services/packages/nuget/sync_metadatum_service.rb index ca9cc4d5b78..189b972c156 100644 --- a/app/services/packages/nuget/sync_metadatum_service.rb +++ b/app/services/packages/nuget/sync_metadatum_service.rb @@ -15,6 +15,8 @@ module Packages metadatum.destroy! if metadatum.persisted? else metadatum.update!( + authors: authors, + description: description, license_url: license_url, project_url: project_url, icon_url: icon_url @@ -24,26 +26,57 @@ module Packages private + attr_reader :package, :metadata + def metadatum - strong_memoize(:metadatum) do - @package.nuget_metadatum || @package.build_nuget_metadatum - end + package.nuget_metadatum || package.build_nuget_metadatum end + strong_memoize_attr :metadatum def blank_metadata? - project_url.blank? && license_url.blank? && icon_url.blank? + [authors, description, project_url, license_url, icon_url].all?(&:blank?) + end + + def authors + truncate_value(:authors, ::Packages::Nuget::Metadatum::MAX_AUTHORS_LENGTH) end + strong_memoize_attr :authors + + def description + truncate_value(:description, ::Packages::Nuget::Metadatum::MAX_DESCRIPTION_LENGTH) + end + strong_memoize_attr :description def project_url - @metadata[:project_url] + metadata[:project_url] end def license_url - @metadata[:license_url] + metadata[:license_url] end def icon_url - @metadata[:icon_url] + metadata[:icon_url] + end + + def truncate_value(field, max_length) + return unless metadata[field] + + if metadata[field].size > max_length + log_info("#{field.capitalize} is too long (maximum is #{max_length} characters)", field) + end + + metadata[field].truncate(max_length) + end + + def log_info(message, field) + Gitlab::AppLogger.info( + class: self.class.name, + message: message, + package_id: package.id, + project_id: package.project_id, + field => metadata[field] + ) end end end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb index 5456ad4cad7..8e2679db31b 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -9,6 +9,9 @@ module Packages # used by ExclusiveLeaseGuard DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze SYMBOL_PACKAGE_IDENTIFIER = 'SymbolsPackage' + INVALID_METADATA_ERROR_MESSAGE = 'package name, version, authors and/or description not found in metadata' + INVALID_METADATA_ERROR_SYMBOL_MESSAGE = 'package name, version and/or description not found in metadata' + MISSING_MATCHING_PACKAGE_ERROR_MESSAGE = 'symbol package is invalid, matching package does not exist' InvalidMetadataError = Class.new(StandardError) @@ -17,7 +20,10 @@ module Packages end def execute - raise InvalidMetadataError, 'package name and/or package version not found in metadata' unless valid_metadata? + unless valid_metadata? + error_message = symbol_package? ? INVALID_METADATA_ERROR_SYMBOL_MESSAGE : INVALID_METADATA_ERROR_MESSAGE + raise InvalidMetadataError, error_message + end try_obtain_lease do @package_file.transaction do @@ -39,7 +45,7 @@ module Packages target_package = existing_package else if symbol_package? - raise InvalidMetadataError, 'symbol package is invalid, matching package does not exist' + raise InvalidMetadataError, MISSING_MATCHING_PACKAGE_ERROR_MESSAGE end update_linked_package @@ -55,17 +61,21 @@ module Packages return if symbol_package? ::Packages::Nuget::SyncMetadatumService - .new(package, metadata.slice(:project_url, :license_url, :icon_url)) + .new(package, metadata.slice(:authors, :description, :project_url, :license_url, :icon_url)) .execute + ::Packages::UpdateTagsService .new(package, package_tags) .execute + rescue StandardError => e raise InvalidMetadataError, e.message end def valid_metadata? - package_name.present? && package_version.present? + fields = [package_name, package_version, package_description] + fields << package_authors unless symbol_package? + fields.all?(&:present?) end def link_to_existing_package @@ -93,15 +103,14 @@ module Packages end def existing_package - strong_memoize(:existing_package) do - @package_file.project.packages - .nuget - .with_name(package_name) - .with_version(package_version) - .not_pending_destruction - .first - end + @package_file.project.packages + .nuget + .with_name(package_name) + .with_version(package_version) + .not_pending_destruction + .first end + strong_memoize_attr :existing_package def package_name metadata[:package_name] @@ -123,15 +132,22 @@ module Packages metadata.fetch(:package_types, []) end + def package_authors + metadata[:authors] + end + + def package_description + metadata[:description] + end + def symbol_package? package_types.include?(SYMBOL_PACKAGE_IDENTIFIER) end def metadata - strong_memoize(:metadata) do - ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute - end + ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute end + strong_memoize_attr :metadata def package_filename "#{package_name.downcase}.#{package_version.downcase}.#{symbol_package? ? 'snupkg' : 'nupkg'}" diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb index b464ce4504a..087a8e42a66 100644 --- a/app/services/packages/pypi/create_package_service.rb +++ b/app/services/packages/pypi/create_package_service.rb @@ -29,10 +29,9 @@ module Packages private def created_package - strong_memoize(:created_package) do - find_or_create_package!(:pypi) - end + find_or_create_package!(:pypi) end + strong_memoize_attr :created_package def file_params { diff --git a/app/services/packages/rpm/parse_package_service.rb b/app/services/packages/rpm/parse_package_service.rb index d2751c77c5b..3995eedef53 100644 --- a/app/services/packages/rpm/parse_package_service.rb +++ b/app/services/packages/rpm/parse_package_service.rb @@ -43,10 +43,9 @@ module Packages end def package_tags - strong_memoize(:package_tags) do - rpm.tags - end + rpm.tags end + strong_memoize_attr :package_tags def extract_static_attributes STATIC_ATTRIBUTES.index_with do |attribute| diff --git a/app/services/packages/rubygems/dependency_resolver_service.rb b/app/services/packages/rubygems/dependency_resolver_service.rb index 839a7683632..214a4adc47f 100644 --- a/app/services/packages/rubygems/dependency_resolver_service.rb +++ b/app/services/packages/rubygems/dependency_resolver_service.rb @@ -33,10 +33,9 @@ module Packages private def packages - strong_memoize(:packages) do - project.packages.with_name(gem_name) - end + project.packages.with_name(gem_name) end + strong_memoize_attr :packages def gem_name params[:gem_name] diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb index c771af28f73..ca4aaa8fdde 100644 --- a/app/services/packages/rubygems/process_gem_service.rb +++ b/app/services/packages/rubygems/process_gem_service.rb @@ -64,10 +64,9 @@ module Packages end def gemspec - strong_memoize(:gemspec) do - gem.spec - end + gem.spec end + strong_memoize_attr :gemspec def success ServiceResponse.success(payload: { package: package }) @@ -78,24 +77,21 @@ module Packages end def temp_package - strong_memoize(:temp_package) do - package_file.package - end + package_file.package end + strong_memoize_attr :temp_package def package - strong_memoize(:package) do - # if package with name/version already exists, use that package - package = temp_package.project + package = temp_package.project .packages .rubygems .with_name(gemspec.name) .with_version(gemspec.version.to_s) .not_pending_destruction .last - package || temp_package - end + package || temp_package end + strong_memoize_attr :package def gem # use_file will set an exclusive lease on the file for as long as diff --git a/app/services/packages/terraform_module/create_package_service.rb b/app/services/packages/terraform_module/create_package_service.rb index 3afecc6c1ca..9df722db529 100644 --- a/app/services/packages/terraform_module/create_package_service.rb +++ b/app/services/packages/terraform_module/create_package_service.rb @@ -43,16 +43,14 @@ module Packages end def name - strong_memoize(:name) do - "#{params[:module_name]}/#{params[:module_system]}" - end + "#{params[:module_name]}/#{params[:module_system]}" end + strong_memoize_attr :name def file_name - strong_memoize(:file_name) do - "#{params[:module_name]}-#{params[:module_system]}-#{params[:module_version]}.tgz" - end + "#{params[:module_name]}-#{params[:module_system]}-#{params[:module_version]}.tgz" end + strong_memoize_attr :file_name def file_params { diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb index f29c54dacb9..cf1acc6ee19 100644 --- a/app/services/packages/update_tags_service.rb +++ b/app/services/packages/update_tags_service.rb @@ -21,10 +21,9 @@ module Packages private def existing_tags - strong_memoize(:existing_tags) do - @package.tag_names - end + @package.tag_names end + strong_memoize_attr :existing_tags def rows(tags) now = Time.zone.now diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb index adb7924f35e..31ba88af46c 100644 --- a/app/services/personal_access_tokens/create_service.rb +++ b/app/services/personal_access_tokens/create_service.rb @@ -13,7 +13,7 @@ module PersonalAccessTokens def execute return ServiceResponse.error(message: 'Not permitted to create') unless creation_permitted? - token = target_user.personal_access_tokens.create(params.slice(*allowed_params)) + token = target_user.personal_access_tokens.create(personal_access_token_params) if token.persisted? log_event(token) @@ -31,13 +31,17 @@ module PersonalAccessTokens attr_reader :target_user, :ip_address - def allowed_params - [ - :name, - :impersonation, - :scopes, - :expires_at - ] + def personal_access_token_params + { + name: params[:name], + impersonation: params[:impersonation] || false, + scopes: params[:scopes], + expires_at: pat_expiration + } + end + + def pat_expiration + params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now end def creation_permitted? diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb index 9066fd1acdf..6fc3110a70b 100644 --- a/app/services/personal_access_tokens/last_used_service.rb +++ b/app/services/personal_access_tokens/last_used_service.rb @@ -22,7 +22,14 @@ module PersonalAccessTokens last_used = @personal_access_token.last_used_at - last_used.nil? || (last_used <= 1.day.ago) + return true if last_used.nil? + + if Feature.enabled?(:update_personal_access_token_usage_information_every_10_minutes) && + last_used <= 10.minutes.ago + return true + end + + last_used <= 1.day.ago end end end diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index c376b4036f8..5ab5732ecf5 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -86,14 +86,15 @@ class PostReceiveService banner = nil if project - scoped_messages = BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message| - message.target_path.present? && message.matches_current_path(project.full_path) - end + scoped_messages = + BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message| + message.target_path.present? && message.matches_current_path(project.full_path) && message.show_in_cli? + end banner = scoped_messages.last end - banner ||= BroadcastMessage.current_banner_messages.last + banner ||= BroadcastMessage.current_show_in_cli_banner_messages.last banner&.message end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 8ad2b0ac761..e37b6516d21 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -24,7 +24,7 @@ module Projects def execute params[:wiki_enabled] = params[:wiki_access_level] if params[:wiki_access_level] params[:builds_enabled] = params[:builds_access_level] if params[:builds_access_level] - params[:snippets_enabled] = params[:builds_access_level] if params[:snippets_access_level] + params[:snippets_enabled] = params[:snippets_access_level] if params[:snippets_access_level] params[:merge_requests_enabled] = params[:merge_requests_access_level] if params[:merge_requests_access_level] params[:issues_enabled] = params[:issues_access_level] if params[:issues_access_level] @@ -231,7 +231,7 @@ module Projects @project.create_labels unless @project.gitlab_project_import? - break if @project.import? + next if @project.import? unless @project.create_repository(default_branch: default_branch) raise 'Failed to create repository' diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index ceab7098b32..e22b728cea3 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -20,6 +20,8 @@ module Projects add_repository_to_project + validate_repository_size! + download_lfs_objects import_data @@ -58,6 +60,10 @@ module Projects attr_reader :resolved_address + def validate_repository_size! + # Defined in EE::Projects::ImportService + end + def after_execute_hook # Defined in EE::Projects::ImportService end diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb index c9791041088..95ddff45dff 100644 --- a/app/services/projects/lfs_pointers/lfs_import_service.rb +++ b/app/services/projects/lfs_pointers/lfs_import_service.rb @@ -14,7 +14,7 @@ module Projects end success - rescue StandardError => e + rescue StandardError, GRPC::Core::CallError => e error(e.message) end end diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index d0bef9da329..e7a8d5305ea 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -14,8 +14,6 @@ module Projects def project_update_params error_tracking_params .merge(alerting_setting_params) - .merge(metrics_setting_params) - .merge(grafana_integration_params) .merge(prometheus_integration_params) .merge(incident_management_setting_params) end @@ -37,15 +35,6 @@ module Projects { alerting_setting_attributes: attr } end - def metrics_setting_params - attribs = params[:metrics_setting_attributes] - return {} unless attribs - - attribs[:external_dashboard_url] = attribs[:external_dashboard_url].presence - - { metrics_setting_attributes: attribs } - end - def error_tracking_params settings = params[:error_tracking_setting_attributes] return {} if settings.blank? @@ -99,14 +88,6 @@ module Projects params end - def grafana_integration_params - return {} unless attrs = params[:grafana_integration_attributes] - - destroy = attrs[:grafana_url].blank? && attrs[:token].blank? - - { grafana_integration_attributes: attrs.merge(_destroy: destroy) } - end - def prometheus_integration_params return {} unless attrs = params[:prometheus_integration_attributes] diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index c29770d0c5f..8c807e0016b 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -45,7 +45,7 @@ module Projects def visible_groups visible_groups = project.invited_groups - unless project.team.owner?(current_user) + unless project.team.member?(current_user) visible_groups = visible_groups.public_or_visible_to_user(current_user) end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 1e084c0e5eb..f1c093c89b7 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -36,7 +36,7 @@ module Projects truncate_alerts! if max_alerts_exceeded? - process_prometheus_alerts + process_prometheus_alerts(integration) created end @@ -79,12 +79,18 @@ module Projects end def valid_alert_manager_token?(token, integration) - valid_for_manual?(token) || - valid_for_alerts_endpoint?(token, integration) || + valid_for_alerts_endpoint?(token, integration) || + valid_for_manual?(token) || valid_for_cluster?(token) end def valid_for_manual?(token) + # If migration from Integrations::Prometheus to + # AlertManagement::HttpIntegrations is complete, + # we should use use the HttpIntegration as SSOT. + # Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/409734 + return false if project.alert_management_http_integrations.legacy.prometheus.any? + prometheus = project.find_or_initialize_integration('prometheus') return false unless prometheus.manual_configuration? @@ -145,10 +151,10 @@ module Projects ActiveSupport::SecurityUtils.secure_compare(expected, actual) end - def process_prometheus_alerts + def process_prometheus_alerts(integration) alerts.map do |alert| AlertManagement::ProcessPrometheusAlertService - .new(project, alert) + .new(project, alert, integration: integration) .execute end end diff --git a/app/services/projects/readme_renderer_service.rb b/app/services/projects/readme_renderer_service.rb index 6871976aded..8fd33a717c5 100644 --- a/app/services/projects/readme_renderer_service.rb +++ b/app/services/projects/readme_renderer_service.rb @@ -17,9 +17,9 @@ module Projects end def sanitized_filename(template_name) - path = Gitlab::Utils.check_path_traversal!("#{template_name}.md.tt") + path = Gitlab::PathTraversal.check_path_traversal!("#{template_name}.md.tt") path = TEMPLATE_PATH.join(path).to_s - Gitlab::Utils.check_allowed_absolute_path!(path, [TEMPLATE_PATH.to_s]) + Gitlab::PathTraversal.check_allowed_absolute_path!(path, [TEMPLATE_PATH.to_s]) path end diff --git a/app/services/projects/slack_application_install_service.rb b/app/services/projects/slack_application_install_service.rb new file mode 100644 index 00000000000..812b8b0a082 --- /dev/null +++ b/app/services/projects/slack_application_install_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Projects + class SlackApplicationInstallService < BaseService + include Gitlab::Routing + + # Endpoint to initiate the OAuth flow, redirects to Slack's authorization screen + # https://api.slack.com/authentication/oauth-v2#asking + SLACK_AUTHORIZE_URL = 'https://slack.com/oauth/v2/authorize' + + # Endpoint to exchange the temporary authorization code for an access token + # https://api.slack.com/authentication/oauth-v2#exchanging + SLACK_EXCHANGE_TOKEN_URL = 'https://slack.com/api/oauth.v2.access' + + def execute + slack_data = exchange_slack_token + + return error("Slack: #{slack_data['error']}") unless slack_data['ok'] + + integration = project.gitlab_slack_application_integration \ + || project.create_gitlab_slack_application_integration! + + installation = integration.slack_integration || integration.build_slack_integration + + installation.update!( + bot_user_id: slack_data['bot_user_id'], + bot_access_token: slack_data['access_token'], + team_id: slack_data.dig('team', 'id'), + team_name: slack_data.dig('team', 'name'), + alias: project.full_path, + user_id: slack_data.dig('authed_user', 'id'), + authorized_scope_names: slack_data['scope'] + ) + + update_legacy_installations!(installation) + + success + end + + private + + def exchange_slack_token + query = { + client_id: Gitlab::CurrentSettings.slack_app_id, + client_secret: Gitlab::CurrentSettings.slack_app_secret, + code: params[:code], + # NOTE: Needs to match the `redirect_uri` passed to the authorization endpoint, + # otherwise we get a `bad_redirect_uri` error. + redirect_uri: slack_auth_project_settings_slack_url(project) + } + + Gitlab::HTTP.get(SLACK_EXCHANGE_TOKEN_URL, query: query).to_hash + end + + # Update any legacy SlackIntegration records for the Slack Workspace. Legacy SlackIntegration records + # are any created before our Slack App was upgraded to use Granular Bot Permissions and issue a + # bot_access_token. Any SlackIntegration records for the Slack Workspace will already have the same + # bot_access_token. + def update_legacy_installations!(installation) + updatable_attributes = installation.attributes.slice( + 'user_id', + 'bot_user_id', + 'encrypted_bot_access_token', + 'encrypted_bot_access_token_iv', + 'updated_at' + ) + + SlackIntegration.by_team(installation.team_id).id_not_in(installation.id).each_batch do |batch| + batch_ids = batch.pluck(:id) # rubocop: disable CodeReuse/ActiveRecord + batch.update_all(updatable_attributes) + + ::Integrations::SlackWorkspace::IntegrationApiScope.update_scopes(batch_ids, installation.slack_api_scopes) + end + end + end +end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index e5883ca06f4..f0243d844d9 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -81,11 +81,17 @@ module Releases tag: tag.name, sha: tag.dereferenced_target.sha, released_at: released_at, - links_attributes: params.dig(:assets, 'links') || [], + links_attributes: links_attributes, milestones: milestones ) end + def links_attributes + (params.dig(:assets, 'links') || []).map do |link_params| + Releases::Links::Params.new(link_params).allowed_params + end + end + def create_evidence!(release, pipeline) return if release.historical_release? || release.upcoming_release? diff --git a/app/services/releases/links/base_service.rb b/app/services/releases/links/base_service.rb index 8bab258f80a..4c260e3183f 100644 --- a/app/services/releases/links/base_service.rb +++ b/app/services/releases/links/base_service.rb @@ -18,17 +18,7 @@ module Releases private def allowed_params - @allowed_params ||= params.slice(:name, :url, :link_type).tap do |hash| - hash[:filepath] = filepath if provided_filepath? - end - end - - def provided_filepath? - params.key?(:direct_asset_path) || params.key?(:filepath) - end - - def filepath - params[:direct_asset_path] || params[:filepath] + Params.new(params).allowed_params end end end diff --git a/app/services/releases/links/params.rb b/app/services/releases/links/params.rb new file mode 100644 index 00000000000..124ab333bbc --- /dev/null +++ b/app/services/releases/links/params.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Releases + module Links + class Params + def initialize(params) + @params = params.with_indifferent_access + end + + def allowed_params + @allowed_params ||= params.slice(:name, :url, :link_type).tap do |hash| + hash[:filepath] = filepath if provided_filepath? + end + end + + private + + attr_reader :params + + def provided_filepath? + params.key?(:direct_asset_path) || params.key?(:filepath) + end + + def filepath + params[:direct_asset_path] || params[:filepath] + end + end + end +end diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb index 4d7e4ffe267..b262b4a1f7b 100644 --- a/app/services/repositories/base_service.rb +++ b/app/services/repositories/base_service.rb @@ -29,7 +29,7 @@ class Repositories::BaseService < BaseService end def move_error(path) - error = %Q{Repository "#{path}" could not be moved} + error = %{Repository "#{path}" could not be moved} log_error(error) error(error) diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 553315f08f9..1fea894a599 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -17,6 +17,8 @@ module ResourceAccessTokens access_level = params[:access_level] || Gitlab::Access::MAINTAINER return error("Could not provision owner access to project access token") if do_not_allow_owner_access_level_for_project_bot?(access_level) + return error(s_('AccessTokens|Access token limit reached')) if reached_access_token_limit? + user = create_user return error(user.errors.full_messages.to_sentence) unless user.persisted? @@ -45,6 +47,10 @@ module ResourceAccessTokens attr_reader :resource_type, :resource + def reached_access_token_limit? + false + end + def username_and_email_generator Gitlab::Utils::UsernameAndEmailGenerator.new( username_prefix: "#{resource_type}_#{resource.id}_bot", @@ -91,7 +97,7 @@ module ResourceAccessTokens name: params[:name] || "#{resource_type}_bot", impersonation: false, scopes: params[:scopes] || default_scopes, - expires_at: params[:expires_at] || nil + expires_at: pat_expiration } end @@ -100,15 +106,11 @@ module ResourceAccessTokens end def create_membership(resource, user, access_level) - resource.add_member(user, access_level, expires_at: default_pat_expiration) + resource.add_member(user, access_level, expires_at: pat_expiration) end - def default_pat_expiration - if Feature.enabled?(:default_pat_expiration) - params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now - else - params[:expires_at] - end + def pat_expiration + params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now end def log_event(token) diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index cee59360b4b..f4c0a743ef0 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -2,9 +2,11 @@ module Search class GlobalService + include Search::Filter include Gitlab::Utils::StrongMemoize - ALLOWED_SCOPES = %w(issues merge_requests milestones users).freeze + DEFAULT_SCOPE = 'projects' + ALLOWED_SCOPES = %w(projects issues merge_requests milestones users).freeze attr_accessor :current_user, :params @@ -19,12 +21,12 @@ module Search projects, order_by: params[:order_by], sort: params[:sort], - filters: { state: params[:state], confidential: params[:confidential] }) + filters: filters) end # rubocop: disable CodeReuse/ActiveRecord def projects - @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :project_topics) + @projects ||= ProjectsFinder.new(current_user: current_user).execute.preload(:topics, :project_topics) end def allowed_scopes @@ -33,7 +35,7 @@ module Search def scope strong_memoize(:scope) do - allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects' + allowed_scopes.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE end end end diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb index daed0df83f3..fa80a6ecf58 100644 --- a/app/services/search/group_service.rb +++ b/app/services/search/group_service.rb @@ -18,7 +18,7 @@ module Search group: group, order_by: params[:order_by], sort: params[:sort], - filters: { state: params[:state], confidential: params[:confidential] } + filters: filters ) end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 6acc32ea0a8..71314f85984 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -2,9 +2,11 @@ module Search class ProjectService + include Search::Filter include Gitlab::Utils::StrongMemoize + include ProjectsHelper - ALLOWED_SCOPES = %w(notes issues merge_requests milestones wiki_blobs commits users).freeze + ALLOWED_SCOPES = %w(blobs issues merge_requests wiki_blobs commits notes milestones users).freeze attr_accessor :project, :current_user, :params @@ -21,7 +23,7 @@ module Search repository_ref: params[:repository_ref], order_by: params[:order_by], sort: params[:sort], - filters: { confidential: params[:confidential], state: params[:state] } + filters: filters ) end @@ -31,7 +33,11 @@ module Search def scope strong_memoize(:scope) do - allowed_scopes.include?(params[:scope]) ? params[:scope] : 'blobs' + next params[:scope] if allowed_scopes.include?(params[:scope]) && project_search_tabs?(params[:scope].to_sym) + + allowed_scopes.find do |scope| + project_search_tabs?(scope.to_sym) + end end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 7fca6ed7a20..5705e4c7cef 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -113,6 +113,8 @@ class SearchService end def global_search_enabled_for_scope? + return false if show_snippets? && Feature.disabled?(:global_search_snippet_titles_tab, current_user, type: :ops) + case params[:scope] when 'blobs' Feature.enabled?(:global_search_code_tab, current_user, type: :ops) @@ -122,6 +124,8 @@ class SearchService Feature.enabled?(:global_search_issues_tab, current_user, type: :ops) when 'merge_requests' Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops) + when 'snippet_titles' + Feature.enabled?(:global_search_snippet_titles_tab, current_user, type: :ops) when 'wiki_blobs' Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops) when 'users' diff --git a/app/services/service_desk/custom_email_verifications/base_service.rb b/app/services/service_desk/custom_email_verifications/base_service.rb new file mode 100644 index 00000000000..fe456e4d3f3 --- /dev/null +++ b/app/services/service_desk/custom_email_verifications/base_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmailVerifications + class BaseService < ::BaseProjectService + attr_reader :settings + + def initialize(project:, current_user: nil, params: {}) + super(project: project, current_user: current_user, params: params) + + @settings = project.service_desk_setting + end + + private + + def notify_project_owners_and_user_with_email(email_method_name: nil, user: nil) + owner_emails = project.owners.map(&:email) + + owner_emails << user.email if user.present? + + owner_emails.uniq(&:downcase).each do |email_address| + Notify.try(email_method_name, settings, email_address).deliver_later + end + end + + def notify_project_owners_and_user_about_result(user: nil) + notify_project_owners_and_user_with_email( + email_method_name: :service_desk_verification_result_email, + user: user + ) + end + + def error_feature_flag_disabled + error_response('Feature flag service_desk_custom_email is not enabled') + end + + def error_response(message) + ServiceResponse.error(message: message) + end + + def error_not_verified(error_identifier) + ServiceResponse.error( + message: _('ServiceDesk|Custom email address could not be verified.'), + reason: error_identifier.to_s + ) + end + end + end +end diff --git a/app/services/service_desk/custom_email_verifications/create_service.rb b/app/services/service_desk/custom_email_verifications/create_service.rb new file mode 100644 index 00000000000..db518bfdf24 --- /dev/null +++ b/app/services/service_desk/custom_email_verifications/create_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmailVerifications + class CreateService < BaseService + attr_reader :ramp_up_error + + def execute + return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) + return error_settings_missing unless settings.present? + return error_user_not_authorized unless can?(current_user, :admin_project, project) + + update_settings + notify_project_owners_and_user_about_verification_start + send_verification_email_and_catch_delivery_errors + + if ramp_up_error + handle_error_case + else + ServiceResponse.success + end + end + + private + + def verification + @verification ||= settings.custom_email_verification || + ServiceDesk::CustomEmailVerification.new(project_id: settings.project_id) + end + + def update_settings + settings.update!(custom_email_enabled: false) if settings.custom_email_enabled? + + verification.mark_as_started!(current_user) + # We use verification association from project, to use it in email, we need to reset it here. + project.reset + end + + def notify_project_owners_and_user_about_verification_start + notify_project_owners_and_user_with_email( + email_method_name: :service_desk_verification_triggered_email, + user: current_user + ) + end + + def send_verification_email_and_catch_delivery_errors + # Send this synchronously as we need to get direct feedback on delivery errors. + Notify.service_desk_custom_email_verification_email(settings).deliver + rescue SocketError, OpenSSL::SSL::SSLError + # e.g. host not found or host certificate issues + @ramp_up_error = :smtp_host_issue + rescue Net::SMTPAuthenticationError + # incorrect username or password + @ramp_up_error = :invalid_credentials + end + + def handle_error_case + notify_project_owners_and_user_about_result(user: current_user) + + verification.mark_as_failed!(ramp_up_error) + + error_not_verified(ramp_up_error) + end + + def error_settings_missing + error_response(_('ServiceDesk|Service Desk setting missing')) + end + + def error_user_not_authorized + error_response(_('ServiceDesk|User cannot manage project.')) + end + end + end +end diff --git a/app/services/service_desk/custom_email_verifications/update_service.rb b/app/services/service_desk/custom_email_verifications/update_service.rb new file mode 100644 index 00000000000..813624cde23 --- /dev/null +++ b/app/services/service_desk/custom_email_verifications/update_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmailVerifications + class UpdateService < BaseService + EMAIL_TOKEN_REGEXP = /Verification token: ([A-Za-z0-9_-]{12})/ + + def execute + return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) + return error_parameter_missing if settings.blank? || verification.blank? + return error_already_finished if already_finished_and_no_mail? + return error_already_failed if already_failed_and_no_mail? + + verification_error = verify + + settings.update!(custom_email_enabled: false) if settings.custom_email_enabled? + + notify_project_owners_and_user_about_result(user: verification.triggerer) + + if verification_error.present? + verification.mark_as_failed!(verification_error) + + error_not_verified(verification_error) + else + verification.mark_as_finished! + + ServiceResponse.success + end + end + + private + + def mail + params[:mail] + end + + def verification + @verification ||= settings.custom_email_verification + end + + def already_finished_and_no_mail? + verification.finished? && mail.blank? + end + + def already_failed_and_no_mail? + verification.failed? && mail.blank? + end + + def verify + return :mail_not_received_within_timeframe if mail_not_received_within_timeframe? + return :incorrect_from if incorrect_from? + return :incorrect_token if incorrect_token? + + nil + end + + def mail_not_received_within_timeframe? + # (For completeness) also raise if no email provided + mail.blank? || !verification.in_timeframe? + end + + def incorrect_from? + # Does the email forwarder preserve the FROM header? + mail.from.first != settings.custom_email + end + + def incorrect_token? + message, _stripped_text = Gitlab::Email::ReplyParser.new(mail).execute + + scan_result = message.scan(EMAIL_TOKEN_REGEXP) + + return true if scan_result.empty? + + scan_result.first.first != verification.token + end + + def error_parameter_missing + error_response(_('ServiceDesk|Service Desk setting or verification object missing')) + end + + def error_already_finished + error_response(_('ServiceDesk|Custom email address has already been verified.')) + end + + def error_already_failed + error_response(_('ServiceDesk|Custom email address verification has already been processed and failed.')) + end + end + end +end diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb index 6d39174b6c7..72d0c022609 100644 --- a/app/services/service_ping/submit_service.rb +++ b/app/services/service_ping/submit_service.rb @@ -3,7 +3,7 @@ module ServicePing class SubmitService PRODUCTION_BASE_URL = 'https://version.gitlab.com' - STAGING_BASE_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org' + STAGING_BASE_URL = 'https://gitlab-org-gitlab-services-version-gitlab-com-staging.version-staging.gitlab.org' USAGE_DATA_PATH = 'usage_data' ERROR_PATH = 'usage_ping_errors' METADATA_PATH = 'usage_ping_metadata' diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index a62d5290271..569b8b76518 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -2,11 +2,9 @@ module Snippets class CreateService < Snippets::BaseService - # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because - # spam_checking is likely to be necessary. - def initialize(project:, spam_params:, current_user: nil, params: {}) + def initialize(project:, current_user: nil, params: {}, perform_spam_check: true) super(project: project, current_user: current_user, params: params) - @spam_params = spam_params + @perform_spam_check = perform_spam_check end def execute @@ -20,16 +18,12 @@ module Snippets @snippet.author = current_user - Spam::SpamActionService.new( - spammable: @snippet, - spam_params: spam_params, - user: current_user, - action: :create, - extra_features: { files: file_paths_to_commit } - ).execute + if perform_spam_check + @snippet.check_for_spam(user: current_user, action: :create, extra_features: { files: file_paths_to_commit }) + end if save_and_commit - UserAgentDetailService.new(spammable: @snippet, spam_params: spam_params).create + UserAgentDetailService.new(spammable: @snippet, perform_spam_check: perform_spam_check).create Gitlab::UsageDataCounters::SnippetCounter.count(:create) move_temporary_files @@ -42,7 +36,7 @@ module Snippets private - attr_reader :snippet, :spam_params + attr_reader :snippet, :perform_spam_check def build_from_params if project diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 067680f2abc..662e31a93aa 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -6,12 +6,9 @@ module Snippets UpdateError = Class.new(StandardError) - # NOTE: For Snippets::UpdateService, we default the spam_params to nil, because spam_checking is not - # necessary in many cases, and we don't want every caller to have to explicitly pass it as nil - # to disable spam checking. - def initialize(project:, current_user: nil, params: {}, spam_params: nil) + def initialize(project:, current_user: nil, params: {}, perform_spam_check: false) super(project: project, current_user: current_user, params: params) - @spam_params = spam_params + @perform_spam_check = perform_spam_check end def execute(snippet) @@ -25,13 +22,9 @@ module Snippets files = snippet.all_files.map { |f| { path: f } } + file_paths_to_commit - Spam::SpamActionService.new( - spammable: snippet, - spam_params: spam_params, - user: current_user, - action: :update, - extra_features: { files: files } - ).execute + if perform_spam_check + snippet.check_for_spam(user: current_user, action: :update, extra_features: { files: files }) + end if save_and_commit(snippet) Gitlab::UsageDataCounters::SnippetCounter.count(:update) @@ -44,7 +37,7 @@ module Snippets private - attr_reader :spam_params + attr_reader :perform_spam_check def visibility_changed?(snippet) visibility_level && visibility_level.to_i != snippet.visibility_level diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 7c96f003e46..0527412e9bc 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -4,22 +4,35 @@ module Spam class SpamActionService include SpamConstants - def initialize(spammable:, spam_params:, user:, action:, extra_features: {}) + def initialize(spammable:, user:, action:, extra_features: {}) @target = spammable - @spam_params = spam_params @user = user @action = action @extra_features = extra_features end - # rubocop:disable Metrics/AbcSize def execute - # If spam_params is passed as `nil`, no check will be performed. This is the easiest way to allow - # composed services which may not need to do spam checking to "opt out". For example, when - # MoveService is calling CreateService, spam checking is not necessary, as no new content is - # being created. return ServiceResponse.success(message: 'Skipped spam check because spam_params was not present') unless spam_params + return ServiceResponse.success(message: 'Skipped spam check because user was not present') unless user + if target.supports_recaptcha? + execute_with_captcha_support + else + execute_spam_check + end + end + + delegate :check_for_spam?, to: :target + + private + + attr_reader :user, :action, :target, :spam_log, :extra_features + + def spam_params + Gitlab::RequestContext.instance.spam_params + end + + def execute_with_captcha_support recaptcha_verified = Captcha::CaptchaVerificationService.new(spam_params: spam_params).execute if recaptcha_verified @@ -28,20 +41,17 @@ module Spam SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id) ServiceResponse.success(message: "CAPTCHA successfully verified") else - return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user) - return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?(user: user) - - perform_spam_service_check - ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement") + execute_spam_check end end - # rubocop:enable Metrics/AbcSize - delegate :check_for_spam?, to: :target - - private + def execute_spam_check + return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user) + return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?(user: user) - attr_reader :user, :action, :target, :spam_params, :spam_log, :extra_features + perform_spam_service_check + ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement") + end ## # In order to be proceed to the spam check process, the target must be diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 1279adf327b..2ecd431fd91 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -68,7 +68,7 @@ module Spam begin result = spamcheck_client.spam?(spammable: target, user: user, context: context, extra_features: extra_features) - if result.evaluated? && Feature.enabled?(:user_spam_scores) + if result.evaluated? Abuse::TrustScore.create!(user: user, score: result.score, source: :spamcheck) end diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb index 1c74e803e0b..1d50e5081ff 100644 --- a/app/services/tasks_to_be_done/base_service.rb +++ b/app/services/tasks_to_be_done/base_service.rb @@ -19,7 +19,7 @@ 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 - create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: nil) + create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, perform_spam_check: false) create_service.execute end end diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb index 01a98a15869..ccb5cec2df8 100644 --- a/app/services/user_agent_detail_service.rb +++ b/app/services/user_agent_detail_service.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true class UserAgentDetailService - def initialize(spammable:, spam_params:) + def initialize(spammable:, perform_spam_check:) @spammable = spammable - @spam_params = spam_params + @perform_spam_check = perform_spam_check end def create - unless spam_params&.user_agent && spam_params&.ip_address - messasge = 'Skipped UserAgentDetail creation because necessary spam_params were not provided' - return ServiceResponse.success(message: messasge) + spam_params = Gitlab::RequestContext.instance.spam_params + if !perform_spam_check || spam_params&.user_agent.blank? || spam_params&.ip_address.blank? + message = 'Skipped UserAgentDetail creation because necessary spam_params were not provided' + return ServiceResponse.success(message: message) end spammable.create_user_agent_detail(user_agent: spam_params.user_agent, ip_address: spam_params.ip_address) @@ -17,5 +18,5 @@ class UserAgentDetailService private - attr_reader :spammable, :spam_params + attr_reader :spammable, :perform_spam_check end diff --git a/app/services/users/activate_service.rb b/app/services/users/activate_service.rb new file mode 100644 index 00000000000..dfc2996bcce --- /dev/null +++ b/app/services/users/activate_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Users + class ActivateService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + return error(_('You are not authorized to perform this action'), :forbidden) unless allowed? + + return error(_('Error occurred. A blocked user must be unblocked to be activated'), :forbidden) if user.blocked? + + return success(_('Successfully activated')) if user.active? + + if user.activate + after_activate_hook(user) + log_event(user) + success(_('Successfully activated')) + else + error(user.errors.full_messages.to_sentence, :unprocessable_entity) + end + end + + private + + attr_reader :current_user + + def allowed? + can?(current_user, :admin_all_resources) + end + + def after_activate_hook(user) + # overridden by EE module + end + + def log_event(user) + Gitlab::AppLogger.info(message: 'User activated', user: user.username.to_s, email: user.email.to_s, + activated_by: current_user.username.to_s, ip_address: current_user.current_sign_in_ip.to_s) + end + + def success(message) + ::ServiceResponse.success(message: message) + end + + def error(message, reason) + ::ServiceResponse.error(message: message, reason: reason) + end + end +end + +Users::ActivateService.prepend_mod_with('Users::ActivateService') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/services/users/set_namespace_commit_email_service.rb b/app/services/users/set_namespace_commit_email_service.rb new file mode 100644 index 00000000000..30ee597120d --- /dev/null +++ b/app/services/users/set_namespace_commit_email_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Users + class SetNamespaceCommitEmailService + include Gitlab::Allowable + + attr_reader :current_user, :target_user, :namespace, :email_id + + def initialize(current_user, namespace, email_id, params) + @current_user = current_user + @target_user = params.delete(:user) || current_user + @namespace = namespace + @email_id = email_id + end + + def execute + return error(_('Namespace must be provided.')) if namespace.nil? + + unless can?(current_user, :admin_user_email_address, target_user) + return error(_("User doesn't exist or you don't have permission to change namespace commit emails.")) + end + + unless can?(target_user, :read_namespace, namespace) + return error(_("Namespace doesn't exist or you don't have permission.")) + end + + email = target_user.emails.find_by(id: email_id) unless email_id.nil? # rubocop: disable CodeReuse/ActiveRecord + existing_namespace_commit_email = target_user.namespace_commit_email_for_namespace(namespace) + if existing_namespace_commit_email.nil? + return error(_('Email must be provided.')) if email.nil? + + create_namespace_commit_email(email) + elsif email_id.nil? + remove_namespace_commit_email(existing_namespace_commit_email) + else + update_namespace_commit_email(existing_namespace_commit_email, email) + end + end + + private + + def remove_namespace_commit_email(namespace_commit_email) + namespace_commit_email.destroy + success(nil) + end + + def create_namespace_commit_email(email) + namespace_commit_email = ::Users::NamespaceCommitEmail.new( + user: target_user, + namespace: namespace, + email: email + ) + + save_namespace_commit_email(namespace_commit_email) + end + + def update_namespace_commit_email(namespace_commit_email, email) + namespace_commit_email.email = email + + save_namespace_commit_email(namespace_commit_email) + end + + def save_namespace_commit_email(namespace_commit_email) + if !namespace_commit_email.save + error_in_save(namespace_commit_email) + else + success(namespace_commit_email) + end + end + + def success(namespace_commit_email) + ServiceResponse.success(payload: { + namespace_commit_email: namespace_commit_email + }) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_in_save(namespace_commit_email) + return error(_('Failed to save namespace commit email.')) if namespace_commit_email.errors.empty? + + error(namespace_commit_email.errors.full_messages.to_sentence) + end + end +end diff --git a/app/services/webauthn/destroy_service.rb b/app/services/webauthn/destroy_service.rb new file mode 100644 index 00000000000..afad2680d42 --- /dev/null +++ b/app/services/webauthn/destroy_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Webauthn + class DestroyService < BaseService + attr_reader :webauthn_registration, :user, :current_user + + def initialize(current_user, user, webauthn_registrations_id) + @current_user = current_user + @user = user + @webauthn_registration = user.webauthn_registrations.find(webauthn_registrations_id) + end + + def execute + return error(_('You are not authorized to perform this action')) unless authorized? + + webauthn_registration.destroy + user.reset_backup_codes! if last_two_factor_registration? + end + + private + + def last_two_factor_registration? + user.webauthn_registrations.empty? && !user.otp_required_for_login? + end + + def authorized? + current_user.can?(:disable_two_factor, user) + end + end +end diff --git a/app/services/work_items/callbacks/award_emoji.rb b/app/services/work_items/callbacks/award_emoji.rb new file mode 100644 index 00000000000..6344813d4b9 --- /dev/null +++ b/app/services/work_items/callbacks/award_emoji.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class AwardEmoji < Base + def before_update + return unless params.present? && params.key?(:name) && params.key?(:action) + return unless has_permission?(:award_emoji) + + execute_emoji_service(params[:action], params[:name]) + end + + private + + def execute_emoji_service(action, name) + class_name = { + add: ::AwardEmojis::AddService, + remove: ::AwardEmojis::DestroyService + } + + raise_error(invalid_action_error(action)) unless class_name.key?(action) + + result = class_name[action].new(work_item, name, current_user).execute + + raise_error(result[:message]) if result[:status] == :error + end + + def invalid_action_error(key) + format(_("%{key} is not a valid action."), key: key) + end + end + end +end diff --git a/app/services/work_items/callbacks/base.rb b/app/services/work_items/callbacks/base.rb new file mode 100644 index 00000000000..c91e2b37d10 --- /dev/null +++ b/app/services/work_items/callbacks/base.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class Base < Issuable::Callbacks::Base + alias_method :work_item, :issuable + + def raise_error(message) + raise ::WorkItems::Widgets::BaseService::WidgetError, message + end + end + end +end diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb index ae09e44b952..ba22b170a28 100644 --- a/app/services/work_items/create_and_link_service.rb +++ b/app/services/work_items/create_and_link_service.rb @@ -6,12 +6,12 @@ module WorkItems # This class should always be run inside a transaction as we could end up with # new work items that were never associated with other work items as expected. class CreateAndLinkService - def initialize(project:, spam_params:, current_user: nil, params: {}, link_params: {}) + def initialize(project:, perform_spam_check: true, current_user: nil, params: {}, link_params: {}) @project = project @current_user = current_user @params = params @link_params = link_params - @spam_params = spam_params + @perform_spam_check = perform_spam_check end def execute @@ -19,7 +19,7 @@ module WorkItems container: @project, current_user: @current_user, params: @params.merge(title: @params[:title].strip).reverse_merge(confidential: confidential_parent), - spam_params: @spam_params + perform_spam_check: @perform_spam_check ).execute return create_result if create_result.error? diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb index ced5b17a21c..25ec3169fe7 100644 --- a/app/services/work_items/create_from_task_service.rb +++ b/app/services/work_items/create_from_task_service.rb @@ -2,11 +2,11 @@ module WorkItems class CreateFromTaskService - def initialize(work_item:, spam_params:, current_user: nil, work_item_params: {}) + def initialize(work_item:, perform_spam_check: true, current_user: nil, work_item_params: {}) @work_item = work_item @current_user = current_user @work_item_params = work_item_params - @spam_params = spam_params + @perform_spam_check = perform_spam_check @errors = [] end @@ -16,7 +16,7 @@ module WorkItems project: @work_item.project, current_user: @current_user, params: @work_item_params.slice(:title, :work_item_type_id), - spam_params: @spam_params, + perform_spam_check: @perform_spam_check, link_params: { parent_work_item: @work_item } ).execute diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index ae355dc6d96..903736cf662 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -5,12 +5,12 @@ module WorkItems extend ::Gitlab::Utils::Override include WidgetableService - def initialize(container:, spam_params:, current_user: nil, params: {}, widget_params: {}) + def initialize(container:, perform_spam_check: true, current_user: nil, params: {}, widget_params: {}) super( container: container, current_user: current_user, params: params, - spam_params: spam_params, + perform_spam_check: perform_spam_check, build_service: ::WorkItems::BuildService.new(container: container, current_user: current_user, params: params) ) @widget_params = widget_params diff --git a/app/services/work_items/delete_task_service.rb b/app/services/work_items/delete_task_service.rb index 3d66716543a..4c0ee2f827d 100644 --- a/app/services/work_items/delete_task_service.rb +++ b/app/services/work_items/delete_task_service.rb @@ -22,7 +22,7 @@ module WorkItems current_user: @current_user ).execute - break ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error? + next ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error? delete_result = ::WorkItems::DeleteService.new( container: @task.project, diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index defdeebfed8..27b318d280f 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -5,10 +5,10 @@ module WorkItems extend Gitlab::Utils::Override include WidgetableService - def initialize(container:, current_user: nil, params: {}, spam_params: nil, widget_params: {}) + def initialize(container:, current_user: nil, params: {}, perform_spam_check: false, widget_params: {}) params[:widget_params] = true if widget_params.present? - super(container: container, current_user: current_user, params: params, spam_params: spam_params) + super(container: container, current_user: current_user, params: params, perform_spam_check: perform_spam_check) @widget_params = widget_params end @@ -59,6 +59,7 @@ module WorkItems super end + override :after_update def after_update(work_item, old_associations) super 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 deleted file mode 100644 index 7c58c0c9af9..00000000000 --- a/app/services/work_items/widgets/award_emoji_service/update_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -# 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/uploaders/ci/secure_file_uploader.rb b/app/uploaders/ci/secure_file_uploader.rb index 09d9b3abafb..85a285ff581 100644 --- a/app/uploaders/ci/secure_file_uploader.rb +++ b/app/uploaders/ci/secure_file_uploader.rb @@ -6,6 +6,10 @@ module Ci storage_location :ci_secure_files + # TODO: Remove this line + # See https://gitlab.com/gitlab-org/gitlab/-/issues/232917 + alias_method :upload, :model + # Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks) encrypt(key: :key) diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index 2eb34288bd7..06bf742a22d 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -189,10 +189,10 @@ class GitlabUploader < CarrierWave::Uploader::Base # # @param [CarrierWave::SanitizedFile] # @return [Nil] - # @raise [Gitlab::Utils::PathTraversalAttackError] + # @raise [Gitlab::PathTraversal::PathTraversalAttackError] def protect_from_path_traversal!(file) PROTECTED_METHODS.each do |method| - Gitlab::Utils.check_path_traversal!(self.send(method)) # rubocop: disable GitlabSecurity/PublicSend + Gitlab::PathTraversal.check_path_traversal!(self.send(method)) # rubocop: disable GitlabSecurity/PublicSend rescue ObjectNotReadyError # Do nothing. This test was attempted before the file was ready for that method diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 0a30f0e99f7..672433ec534 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -11,6 +11,7 @@ module ObjectStorage RemoteStoreError = Class.new(StandardError) UnknownStoreError = Class.new(StandardError) ObjectStorageUnavailable = Class.new(StandardError) + MissingFinalStorePathRootId = Class.new(StandardError) class ExclusiveLeaseTaken < StandardError def initialize(lease_key) @@ -153,21 +154,30 @@ module ObjectStorage [CarrierWave.generate_cache_id, SecureRandom.hex].join('-') end - def generate_final_store_path + def generate_final_store_path(root_id:) hash = Digest::SHA2.hexdigest(SecureRandom.uuid) # We prefix '@final' to prevent clashes and make the files easily recognizable # as having been created by this code. - File.join('@final', hash[0..1], hash[2..3], hash[4..]) + sub_path = File.join('@final', hash[0..1], hash[2..3], hash[4..]) + + # We generate a hashed path of the root ID (e.g. Project ID) to distribute directories instead of + # filling up one root directory with a bunch of files. + Gitlab::HashedPath.new(sub_path, root_hash: root_id).to_s end - def workhorse_authorize(has_length:, maximum_size: nil, use_final_store_path: false) + def workhorse_authorize( + has_length:, + maximum_size: nil, + use_final_store_path: false, + final_store_path_root_id: nil) {}.tap do |hash| if self.direct_upload_to_object_store? hash[:RemoteObject] = workhorse_remote_upload_options( has_length: has_length, maximum_size: maximum_size, - use_final_store_path: use_final_store_path + use_final_store_path: use_final_store_path, + final_store_path_root_id: final_store_path_root_id ) else hash[:TempPath] = workhorse_local_upload_path @@ -190,11 +200,17 @@ module ObjectStorage ObjectStorage::Config.new(object_store_options) end - def workhorse_remote_upload_options(has_length:, maximum_size: nil, use_final_store_path: false) + def workhorse_remote_upload_options( + has_length:, + maximum_size: nil, + use_final_store_path: false, + final_store_path_root_id: nil) return unless direct_upload_to_object_store? if use_final_store_path - id = generate_final_store_path + raise MissingFinalStorePathRootId unless final_store_path_root_id.present? + + id = generate_final_store_path(root_id: final_store_path_root_id) upload_path = with_bucket_prefix(id) prepare_pending_direct_upload(id) else @@ -410,7 +426,7 @@ module ObjectStorage end def retrieve_from_store!(identifier) - Gitlab::Utils.check_path_traversal!(identifier) + Gitlab::PathTraversal.check_path_traversal!(identifier) # We need to force assign the value of @filename so that we will still # get the original_filename in cases wherein the file points to a random generated diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb index 45ac695c5ec..ff390a624c5 100644 --- a/app/validators/abstract_path_validator.rb +++ b/app/validators/abstract_path_validator.rb @@ -26,11 +26,22 @@ class AbstractPathValidator < ActiveModel::EachValidator return end - full_path = record.build_full_path - return unless full_path + if build_full_path_to_validate_against_reserved_names? + path_to_validate_against_reserved_names = record.build_full_path + return unless path_to_validate_against_reserved_names + else + path_to_validate_against_reserved_names = value + end - unless self.class.valid_path?(full_path) + unless self.class.valid_path?(path_to_validate_against_reserved_names) record.errors.add(attribute, "#{value} is a reserved name") end end + + def build_full_path_to_validate_against_reserved_names? + # By default, entities using the `Routable` concern can build full paths. + # But entities like `Organization` do not have a parent, and hence cannot build full paths, + # and this method can be overridden to return `false` in such cases. + true + end end diff --git a/app/validators/json_schemas/abuse_event_metadata.json b/app/validators/json_schemas/abuse_event_metadata.json new file mode 100644 index 00000000000..b24ec93f877 --- /dev/null +++ b/app/validators/json_schemas/abuse_event_metadata.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Metadata to support an abuse event", + "type": "object", + "properties": { + } +} diff --git a/app/validators/json_schemas/abuse_report_evidence.json b/app/validators/json_schemas/abuse_report_evidence.json new file mode 100644 index 00000000000..e00628d5704 --- /dev/null +++ b/app/validators/json_schemas/abuse_report_evidence.json @@ -0,0 +1,107 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Evidence to support an abuse report", + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "description" + ] + } + }, + "snippets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "content": { + "type": "string" + } + }, + "required": [ + "id", + "content" + ] + } + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "content": { + "type": "string" + } + }, + "required": [ + "id", + "content" + ] + } + }, + "user": { + "type": "object", + "properties": { + "login_count": { + "type": "integer" + }, + "account_age": { + "type": "integer" + }, + "spam_score": { + "type": "number" + }, + "telesign_score": { + "type": "number" + }, + "arkos_score": { + "type": "number" + }, + "pvs_score": { + "type": "number" + }, + "product_coverage": { + "type": "number" + }, + "virus_total_score": { + "type": "number" + } + }, + "required": [ + "login_count", + "account_age", + "spam_score", + "telesign_score", + "arkos_score", + "pvs_score", + "product_coverage", + "virus_total_score" + ] + } + }, + "required": [ + "user" + ] +} diff --git a/app/validators/json_schemas/ci_job_annotation_data.json b/app/validators/json_schemas/ci_job_annotation_data.json new file mode 100644 index 00000000000..d623ed3c179 --- /dev/null +++ b/app/validators/json_schemas/ci_job_annotation_data.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Build annotation", + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "external_link": { + "$ref": "./ci_job_external_link_data.json" + } + }, + "additionalProperties": false + } + ] + }, + "maxItems": 1000 +} diff --git a/app/validators/json_schemas/ci_job_external_link_data.json b/app/validators/json_schemas/ci_job_external_link_data.json new file mode 100644 index 00000000000..7f420963432 --- /dev/null +++ b/app/validators/json_schemas/ci_job_external_link_data.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Build annotation external link", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/app/validators/json_schemas/default_branch_protection_defaults.json b/app/validators/json_schemas/default_branch_protection_defaults.json new file mode 100644 index 00000000000..bd2945c08fb --- /dev/null +++ b/app/validators/json_schemas/default_branch_protection_defaults.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Default settings for default branch protection", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "allow_force_push": { + "type": "boolean" + }, + "allowed_to_merge": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "group_id": { + "type": "integer" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "access_level": { + "type": "integer" + } + } + } + ] + } + }, + "allowed_to_push": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "group_id": { + "type": "integer" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "access_level": { + "type": "integer" + } + } + } + ] + } + }, + "code_owner_approval_required": { + "type": "boolean" + }, + "merge_access_level": { + "type": "integer" + }, + "push_access_level": { + "type": "integer" + }, + "unprotect_access_level": { + "type": "integer" + } + }, + "additionalProperties": false +} diff --git a/app/validators/json_schemas/plan_limits_history.json b/app/validators/json_schemas/plan_limits_history.json new file mode 100644 index 00000000000..80d4165018a --- /dev/null +++ b/app/validators/json_schemas/plan_limits_history.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "enforcement_limit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "value": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "user_id", + "username", + "timestamp", + "value" + ] + } + }, + "notification_limit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "value": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "user_id", + "username", + "timestamp", + "value" + ] + } + }, + "storage_size_limit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "value": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "user_id", + "username", + "timestamp", + "value" + ] + } + }, + "dashboard_limit_enabled_at": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "value": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "user_id", + "username", + "timestamp", + "value" + ] + } + } + }, + "additionalProperties": false +} diff --git a/app/validators/json_schemas/position.json b/app/validators/json_schemas/position.json index d2c83be7639..4cbd4196c61 100644 --- a/app/validators/json_schemas/position.json +++ b/app/validators/json_schemas/position.json @@ -146,6 +146,12 @@ { "type": "integer" }, { "type": "string", "maxLength": 10 } ] + }, + "ignore_whitespace_change": { + "oneOf": [ + { "type": "null" }, + { "type": "boolean" } + ] } } } diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb index 0094d6156a3..3eb7aa4b652 100644 --- a/app/validators/key_restriction_validator.rb +++ b/app/validators/key_restriction_validator.rb @@ -31,7 +31,7 @@ class KeyRestrictionValidator < ActiveModel::EachValidator sizes << "allowed" if valid_restriction?(ALLOWED) sizes += self.class.supported_sizes(options[:type]) - Gitlab::Utils.to_exclusive_sentence(sizes) + Gitlab::Sentence.to_exclusive_sentence(sizes) end def valid_restriction?(value) diff --git a/app/validators/organizations/path_validator.rb b/app/validators/organizations/path_validator.rb new file mode 100644 index 00000000000..a1c22654a32 --- /dev/null +++ b/app/validators/organizations/path_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Organizations + class PathValidator < ::NamespacePathValidator + def self.path_regex + Gitlab::PathRegex.organization_path_regex + end + + def build_full_path_to_validate_against_reserved_names? + # full paths cannot be built for organizations because organizations do not have a parent + # and it does not include the `Routable` concern. + false + end + end +end diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index df08a1123c7..d29fa9c5b85 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -9,21 +9,21 @@ = f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold' = f.number_field :default_projects_limit, class: 'form-control gl-form-input', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' } .form-group - = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold' + = f.label :max_attachment_size, _('Maximum attachment size (MiB)'), class: 'label-bold' = f.number_field :max_attachment_size, class: 'form-control gl-form-input', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' } = render 'admin/application_settings/repository_size_limit_setting_registration_features_cta', form: f = render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f .form-group - = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light' + = f.label :receive_max_input_size, _('Maximum push size (MiB)'), class: 'label-light' = f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' } .form-group - = f.label :max_export_size, _('Maximum export size (MB)'), class: 'label-light' + = f.label :max_export_size, _('Maximum export size (MiB)'), class: 'label-light' = f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('Set to 0 for no size limit.') .form-group - = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light' + = f.label :max_import_size, _('Maximum import size (MiB)'), class: 'label-light' = f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.') .form-group diff --git a/app/views/admin/application_settings/_ai_access.html.haml b/app/views/admin/application_settings/_ai_access.html.haml new file mode 100644 index 00000000000..41b0a08128e --- /dev/null +++ b/app/views/admin/application_settings/_ai_access.html.haml @@ -0,0 +1,32 @@ +- return if Gitlab.org_or_com? + +- expanded = integration_expanded?('ai_access') +- token_is_present = @application_setting.ai_access_token.present? +- token_label = token_is_present ? s_('CodeSuggestionsSM|Enter new personal access token') : s_('CodeSuggestionsSM|Personal access token') +- token_value = token_is_present ? ApplicationSettingMaskedAttrs::MASK : '' + +%section.settings.no-animate#js-ai-access-settings{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('CodeSuggestionsSM|Code Suggestions') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p + = s_('CodeSuggestionsSM|Enable Code Suggestion for users of this GitLab instance.') + = link_to sprite_icon('question-o'), code_suggestions_docs_url, target: '_blank', class: 'has-tooltip', title: _('More information') + + .settings-content + = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-ai-access-settings'), html: { class: 'fieldset-form', id: 'ai-access-settings' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.gitlab_ui_checkbox_component :instance_level_code_suggestions_enabled, + s_('CodeSuggestionsSM|Turn on Code Suggestions for this instance. By turning on this feature, you:'), + help_text: code_suggestions_agreement + = f.label :ai_access_token, token_label, class: 'label-bold' + = f.password_field :ai_access_token, value: token_value, autocomplete: 'on', class: 'form-control gl-form-input', aria: { describedby: 'code_suggestions_token_explanation' } + %p.form-text.text-muted{ id: 'code_suggestions_token_explanation' } + = code_suggestions_token_explanation + + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_diagramsnet.html.haml b/app/views/admin/application_settings/_diagramsnet.html.haml new file mode 100644 index 00000000000..e493110a9dc --- /dev/null +++ b/app/views/admin/application_settings/_diagramsnet.html.haml @@ -0,0 +1,25 @@ +- expanded = integration_expanded?('diagramsnet_') +%section.settings.as-diagramsnet.no-animate#js-diagramsnet-settings{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _('Diagrams.net') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p + = _('Render diagrams in your documents using diagrams.net.') + = link_to _('Learn more.'), help_page_path('administration/integration/diagrams_net.md'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-diagramsnet-settings'), html: { class: 'fieldset-form', id: 'diagramsnet-settings' } do |f| + = form_errors(@application_setting) if expanded + + %fieldset + .form-group + = f.gitlab_ui_checkbox_component :diagramsnet_enabled, + _('Enable diagrams.net') + .form-group + = f.label :diagramsnet_url, _('Diagrams.net URL'), class: 'label-bold' + = f.text_field :diagramsnet_url, class: 'form-control gl-form-input', placeholder: 'https://embed.diagrams.net' + .form-text.text-muted + = _('The hostname of your diagrams.net server.') + + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 2e8eb25b1d5..153600f1299 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -3,7 +3,7 @@ %fieldset .form-group - = f.label :diff_max_patch_bytes, _('Maximum diff patch size (Bytes)'), class: 'label-light' + = f.label :diff_max_patch_bytes, _('Maximum diff patch size (bytes)'), class: 'label-light' = f.number_field :diff_max_patch_bytes, class: 'form-control gl-form-input' %span.form-text.text-muted = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index 97d9426581e..1eb6b747704 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -16,7 +16,7 @@ s_("AdminSettings|Disable public access to Pages sites"), help_text: s_("AdminSettings|Select to disable public access for Pages sites, which requires users to sign in for access to the Pages sites in your instance. %{link_start}Learn more.%{link_end}").html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe } .form-group - = f.label :max_pages_size, _('Maximum size of pages (MB)'), class: 'label-bold' + = f.label :max_pages_size, _('Maximum size of pages (MiB)'), class: 'label-bold' = f.number_field :max_pages_size, class: 'form-control gl-form-input' .form-text.text-muted - pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-size-of-gitlab-pages-site-in-a-project') diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index 86a01e1785e..bfa548b70e5 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -11,16 +11,16 @@ = f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold' = f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling.') + = _('Maximum number of requests per minute for each raw path (default is `300`). Set to `0` to disable throttling.') .form-group = f.label :push_event_hooks_limit, class: 'label-bold' = f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Maximum number of changes (branches or tags) in a single push for which webhooks and services trigger (default is 3).') + = _('Maximum number of changes (branches or tags) in a single push above which webhooks and integrations are not triggered (default is `3`). Setting to `0` does not disable throttling.') .form-group = f.label :push_event_activities_limit, class: 'label-bold' = f.number_field :push_event_activities_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3).') + = _('Maximum number of changes (branches or tags) in a single push above which a bulk push event is created (default is `3`). Setting to `0` does not disable throttling.') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml b/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml index 8daa5aa8c73..040a22ff2ac 100644 --- a/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml +++ b/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml @@ -2,7 +2,7 @@ .form-group = form.label :disabled_repository_size_limit, class: 'label-bold' do - = _('Size limit per repository (MB)') + = _('Size limit per repository (MiB)') = form.number_field :disabled_repository_size_limit, value: '', class: 'form-control gl-form-input', disabled: true %span.form-text.text-muted = render 'shared/registration_features_discovery_message' diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 50b5e797559..85841059c5e 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -29,7 +29,7 @@ .form-text.text-muted = _('Maximum time that users are allowed to skip the setup of two-factor authentication (in hours). Set to 0 (zero) to enforce at next sign in.') .form-group - = f.label :admin_mode, _('Admin Mode'), class: 'label-bold' + = f.label :admin_mode, _('Admin mode'), class: 'label-bold' = sprite_icon('lock', css_class: 'gl-icon') - help_text = _('Require additional authentication for administrative tasks.') - help_link = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml index c35056383fa..c21d1ec47e6 100644 --- a/app/views/admin/application_settings/_user_restrictions.html.haml +++ b/app/views/admin/application_settings/_user_restrictions.html.haml @@ -5,3 +5,4 @@ = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: form = form.gitlab_ui_checkbox_component :can_create_group, _("Allow new users to create top-level groups") = form.gitlab_ui_checkbox_component :user_defaults_to_private_profile, _("Make new users' profiles private by default") + = render_if_exists 'admin/application_settings/allow_account_deletion', form: form diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index e6c27c1bc84..022930bd6b4 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -94,6 +94,7 @@ = render 'admin/application_settings/kroki' = render 'admin/application_settings/mailgun' = render 'admin/application_settings/plantuml' += render 'admin/application_settings/diagramsnet' = render 'admin/application_settings/sourcegraph' = render_if_exists 'admin/application_settings/slack' -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/417 @@ -108,3 +109,4 @@ = render 'admin/application_settings/floc' = render_if_exists 'admin/application_settings/add_license' = render 'admin/application_settings/jira_connect' += render_if_exists 'admin/application_settings/ai_access' diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml index e42c1091bf2..24f132b982a 100644 --- a/app/views/admin/application_settings/service_usage_data.html.haml +++ b/app/views/admin/application_settings/service_usage_data.html.haml @@ -25,7 +25,7 @@ - c.with_body do - enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics') - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url } - - generate_manually_link_url = help_page_path('development/service_ping/troubleshooting', anchor: 'generate-service-ping') + - generate_manually_link_url = help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping') - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url } = html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe } diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index 7b00019cc21..c5632e0d70b 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -10,8 +10,10 @@ - else .top-area .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } + = sprite_icon('chevron-lg-right', size: 12) - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 8680bae5207..e643ec040a1 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -10,7 +10,7 @@ .labels.labels-container.admin-labels.js-admin-labels-container.gl-mt-4 .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10 - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' } + .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24 = _('Labels') %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index e942a513166..31ec4935f64 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -4,8 +4,10 @@ .top-area.gl-flex-direction-column-reverse .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } + = sprite_icon('chevron-lg-right', size: 12) = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav nav gl-tabs-nav' }) do = gl_tab_link_to _('All'), admin_projects_path(visibility_level: nil), { item_active: params[:visibility_level].empty? } = gl_tab_link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 13c647cd45f..d0ee3acf0b8 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -3,5 +3,5 @@ = label_tag :user_password, _('Password'), class: 'label-bold' = password_field_tag 'user[password]', nil, { class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } } - .submit-container.move-submit-down + .submit-container = submit_tag _('Enter admin mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml index 15005bb9224..70cad880293 100644 --- a/app/views/admin/sessions/_signin_box.html.haml +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -2,7 +2,7 @@ - if crowd_enabled? .login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) } .login-body - = render 'devise/sessions/new_crowd' + = render 'devise/sessions/new_crowd', render_remember_me: false, submit_message: _('Enter admin mode') - ldap_servers.each_with_index do |server, i| .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) } diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml index f7b4035488d..a27dea52884 100644 --- a/app/views/admin/sessions/_two_factor_otp.html.haml +++ b/app/views/admin/sessions/_two_factor_otp.html.haml @@ -5,5 +5,5 @@ %p.form-text.text-muted.hint = _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.") - .submit-container.move-submit-down + .submit-container = submit_tag 'Verify code', class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 4fc30cbaecf..7301b0f6e04 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -8,7 +8,7 @@ - if any_form_based_providers_enabled? = render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false - else - = render 'devise/shared/tab_single', tab_title: page_title + = render 'devise/shared/tab_single', tab_title: page_title if Feature.disabled?(:restyle_login_page, @project) .tab-content - if allow_admin_mode_password_authentication_for_web? || ldap_sign_in_enabled? || crowd_enabled? = render 'admin/sessions/signin_box' diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml index 3bbf768d7be..bfe66e2477e 100644 --- a/app/views/admin/sessions/two_factor.html.haml +++ b/app/views/admin/sessions/two_factor.html.haml @@ -1,15 +1,14 @@ -- page_title _('Enter 2FA for Admin Mode') +- page_title _('Two-factor authentication for admin mode') - add_page_specific_style 'page_bundles/login' .row.justify-content-center .col-md-5.new-session-forms-container .login-page #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) } - = render 'devise/shared/tab_single', tab_title: _('Enter admin mode') - .tab-content - .login-box.tab-pane.gl-p-5.active{ id: 'login-pane', role: 'tabpanel' } - .login-body - - if current_user.two_factor_enabled? - = render 'admin/sessions/two_factor_otp' - - if current_user.two_factor_webauthn_enabled? - = render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path + = render 'devise/shared/tab_single', tab_title: _('Enter admin mode') if Feature.disabled?(:restyle_login_page, @project) + .login-box.gl-p-5 + .login-body + - if current_user.two_factor_enabled? + = render 'admin/sessions/two_factor_otp' + - if current_user.two_factor_webauthn_enabled? + = render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 183667679b9..6aed8508a6a 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -22,6 +22,8 @@ %td = truncate(spam_log.description, length: 100) %td + = moderation_status(user) + %td - if user = render Pajamas::ButtonComponent.new(size: :small, variant: :danger, diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml index 001662c4015..9a0756510ec 100644 --- a/app/views/admin/spam_logs/index.html.haml +++ b/app/views/admin/spam_logs/index.html.haml @@ -14,6 +14,7 @@ %th= _('Type') %th= _('Title') %th= _('Description') + %th= _('User Status') %th= _('Primary Action') %th = render @spam_logs diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index 544310e312c..c3b5161d617 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -18,14 +18,15 @@ .form-group = f.label :description, _("Description") - = render layout: 'shared/md_preview', locals: { url: preview_markdown_admin_topics_path, referenced_users: false } do - = render 'shared/zen', f: f, attr: :description, - classes: 'note-textarea', - placeholder: _('Write a description…'), - supports_quick_actions: false, - supports_autocomplete: false, - qa_selector: 'topic_form_description' - = render 'shared/notes/hints', supports_file_upload: false + .js-markdown-editor{ data: { render_markdown_path: preview_markdown_admin_topics_path, + markdown_docs_path: help_page_path('user/markdown'), + qa_selector: 'topic_form_description', + form_field_placeholder: _('Write a description…'), + supports_quick_actions: 'false', + enable_autocomplete: 'false', + disable_attachments: 'true', + form_field_classes: 'note-textarea js-gfm-input markdown-area' } } + = f.hidden_field :description .form-group.gl-mt-3.gl-mb-3 = f.label :avatar, _('Topic avatar'), class: 'gl-display-block' diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml index c63828cf41f..ce2b5ad793c 100644 --- a/app/views/admin/topics/_topic.html.haml +++ b/app/views/admin/topics/_topic.html.haml @@ -6,7 +6,7 @@ .gl-min-w-0.gl-flex-grow-1.gl-ml-3 .title - = link_to title, topic_explore_projects_path(topic_name: topic.name) + = link_to title, topic_explore_projects_cleaned_path(topic_name: topic.name) %div = topic.name diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index c9264535a13..213d5847986 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -9,9 +9,9 @@ .top-area .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full - .fade-left + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } = sprite_icon('chevron-lg-left', size: 12) - .fade-right + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } = sprite_icon('chevron-lg-right', size: 12) = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full' }) do = gl_tab_link_to admin_users_path, { item_active: active_when(params[:filter].nil?), class: 'gl-border-0!' } do diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml index c8c970f3c2f..86219d5f021 100644 --- a/app/views/ci/group_variables/_index.html.haml +++ b/app/views/ci/group_variables/_index.html.haml @@ -1,14 +1,5 @@ -- variables = @project.group.self_and_ancestors.flat_map(&:variables) - -.ci-variable-table - %table.gl-table.gl-w-full.gl-table-layout-fixed - = render 'ci/group_variables/variable_header' - - variables.each do |variable| - %tr - %td.gl-text-truncate - = variable.key - %td.gl-text-truncate - = variable.environment_scope - %td.gl-text-truncate - %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) } - = variable.group.name +#js-inherited-group-ci-variables{ + data: { + project_path: @project.full_path, + } +} diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index 65e57d68288..f7ab495111a 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,12 +1,2 @@ = format(s_('CiVariables|Variables store information, like passwords and secret keys, that you can use in job scripts. Each %{entity} can define a maximum of %{limit} variables.'), entity: entity, limit: variable_limit).html_safe = link_to _('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer' -%p - = _('Variables can have several attributes.') - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer' -%ul - %li - = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index da2c8a71dcd..5eed4e92386 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -2,6 +2,17 @@ - is_group = !@group.nil? - is_project = !@project.nil? +%p + = _('Variables can have several attributes.') + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer' +%ul + %li + = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li + = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li + = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + #js-ci-variables{ data: { endpoint: save_endpoint, is_project: is_project.to_s, project_id: @project&.id || '', diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 6461b71b10d..7d5d41c2851 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -8,12 +8,12 @@ = render Pajamas::AlertComponent.new(variant: :warning, alert_options: { class: 'hidden js-cluster-api-unreachable' }) do |c| - = c.body do + - c.with_body do = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') = render Pajamas::AlertComponent.new(variant: :warning, alert_options: { class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable' }) do |c| - = c.body do + - c.with_body do = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } diff --git a/app/views/clusters/clusters/_deprecation_alert.html.haml b/app/views/clusters/clusters/_deprecation_alert.html.haml index 0318c0f7dfa..4f35ba78cc6 100644 --- a/app/views/clusters/clusters/_deprecation_alert.html.haml +++ b/app/views/clusters/clusters/_deprecation_alert.html.haml @@ -1,5 +1,5 @@ = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mt-6 gl-mb-3' }) do |c| - = c.body do + - c.with_body do - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - issue_link_start = link_start % { url: 'https://gitlab.com/gitlab-org/configure/general/-/issues/199' } - docs_link_start = link_start % { url: help_page_path('user/clusters/agent/index.md') } diff --git a/app/views/clusters/clusters/_details_tab.html.haml b/app/views/clusters/clusters/_details_tab.html.haml index 734910686e7..75dc82527f2 100644 --- a/app/views/clusters/clusters/_details_tab.html.haml +++ b/app/views/clusters/clusters/_details_tab.html.haml @@ -1,4 +1,4 @@ - active = params[:tab] == 'details' || !params[:tab].present? -= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'details' }), { item_active: active } do += gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'details' }), { item_active: active, data: { testid: 'cluster-details-tab' } } do = _('Details') diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 40632e27fa7..08badbb4963 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -4,8 +4,8 @@ alert_options: { class: 'gcp-signup-offer', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path }}, close_button_options: { data: { track_action: 'click_dismiss', track_label: 'gcp_signup_offer_banner' }}) do |c| - = c.body do + - c.with_body do = s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } - = c.actions do + - c.with_actions do = render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', button_options: { rel: 'noopener noreferrer', data: { track_action: 'click_button', track_label: 'gcp_signup_offer_banner' } }) do = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml deleted file mode 100644 index 9e7820d3136..00000000000 --- a/app/views/clusters/clusters/_health.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- add_page_specific_style 'page_bundles/prometheus' - -%section.settings.no-animate.expanded.cluster-health-graphs#cluster-health - - if @cluster&.integration_prometheus_available? - #prometheus-graphs{ data: @cluster.health_data(clusterable) } - - - else - %p.settings-message.text-center= s_("ClusterIntegration|In order to view the health of your cluster, you must first enable Prometheus in the Integrations tab.") diff --git a/app/views/clusters/clusters/_health_tab.html.haml b/app/views/clusters/clusters/_health_tab.html.haml deleted file mode 100644 index 4292066cc6f..00000000000 --- a/app/views/clusters/clusters/_health_tab.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- active = params[:tab] == 'health' - -= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'health' }), { item_active: active, data: { testid: 'cluster-health-tab' } } do - = _('Health') diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 19ca9407513..57de6d980f8 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -32,7 +32,6 @@ = gl_tabs_nav do = render 'clusters/clusters/details_tab' = render_if_exists 'clusters/clusters/environments_tab' - = render 'clusters/clusters/health_tab' if !Feature.enabled?(:remove_monitor_metrics) = render 'clusters/clusters/integrations_tab' if !Feature.enabled?(:remove_monitor_metrics) = render 'clusters/clusters/advanced_settings_tab' diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index e600d84f492..b72b252a852 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -12,8 +12,10 @@ .top-area .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-flex-basis-0.gl-min-w-0 - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } + = sprite_icon('chevron-lg-right', size: 12) = render 'dashboard/projects_nav' .nav-controls = render 'shared/projects/search_form' diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index 601b6a8b1a7..ea7cd75152d 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -1,2 +1,2 @@ .js-groups-list-holder - #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } + #js-groups-tree{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index de34c709ff3..658632b70a6 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -1,11 +1,19 @@ -- page_title _("Merge requests") -- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) -- add_page_specific_style 'page_bundles/issuable_list' +:ruby + title = if params[:reviewer_username] == current_user.username + _("Review requests") + elsif params[:assignee_username] == current_user.username + _("Assigned merge requests") + else + _("Merge requests") + end + page_title title + @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) + add_page_specific_style 'page_bundles/issuable_list' = render_dashboard_ultimate_trial(current_user) .page-title-holder.d-flex.align-items-start.flex-column.flex-sm-row.align-items-sm-center - %h1.page-title.gl-font-size-h-display= _('Merge requests') + %h1.page-title.gl-font-size-h-display= title - if current_user .page-title-controls.ml-0.mb-3.ml-sm-auto.mb-sm-0 diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml index 855177fd836..94f956896d6 100644 --- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml @@ -4,7 +4,7 @@ - if has_start_trial? = render_if_exists "dashboard/projects/blank_state_ee_trial" - = link_to new_project_path, class: link_classes do + = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do .blank-state-icon = custom_icon("add_new_project", size: 50) .blank-state-body.gl-sm-pl-6 diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index c5fdc31a775..08b914a218d 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -2,7 +2,7 @@ .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between - if current_user.can_create_project? - = link_to new_project_path, class: link_classes do + = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do .blank-state-icon = custom_icon("add_new_project", size: 50) .blank-state-body.gl-sm-pl-6 @@ -12,7 +12,7 @@ = _('Projects are where you store your code, access issues, wiki and other features of GitLab.') - else = render Pajamas::AlertComponent.new(variant: :info, alert_options: { class: 'gl-mb-5 gl-w-full' }) do |c| - = c.body do + - c.with_body do = _("You see projects here when you're added to a group or project.").html_safe - if current_user.can_create_group? diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 5af247703f6..00652e8574a 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -15,7 +15,7 @@ = recaptcha_tags nonce: content_security_policy_nonce .gl-mt-5 - = render Pajamas::ButtonComponent.new(block: true, type: :submit, variant: :confirm) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do = _("Resend") .clearfix.prepend-top-20 diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 498fb08969c..35ee9a7679a 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -1,7 +1,7 @@ = render 'devise/shared/tab_single', tab_title: _('Change your password') .login-box .login-body - = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors gl-pt-5' }) do |f| + = gitlab_ui_form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors gl-pt-5' }) do |f| .devise-errors.gl-px-5 = render "devise/shared/error_messages", resource: resource = f.hidden_field :reset_password_token @@ -13,7 +13,8 @@ = f.label _('Confirm new password'), for: "user_password_confirmation" = f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true .clearfix.gl-px-5.gl-pb-5 - = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'change_password_button' } }) do + = _('Change your password') .clearfix.prepend-top-20 %p diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index 1400ac9ca72..8e55977fe7a 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -1,20 +1,23 @@ .login-box .login-body - = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| + = gitlab_ui_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }}) do |f| .devise-errors = render "devise/shared/error_messages", resource: resource - .form-group.gl-px-5.gl-pt-5 - = f.label :email, class: ("gl-mb-1" if Feature.enabled?(:restyle_login_page)) + .form-group + = f.label :email, _('Email') = f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.') .form-text.text-muted - = _('Requires your primary GitLab email address.') + = _('Requires a verified GitLab email address.') - if recaptcha_enabled? - .gl-px-5 + .gl-mb-5 = recaptcha_tags nonce: content_security_policy_nonce - .gl-p-5 - = f.submit _("Reset password"), class: "gl-button btn-confirm btn" + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do + = _('Reset password') -.clearfix.prepend-top-20 +- if Feature.enabled?(:restyle_login_page, @project) = render 'devise/shared/sign_in_link' +- else + .gl-mt-3 + = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index e75449bf320..18856e04eb8 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -7,6 +7,9 @@ = render "layouts/bizible" = render "layouts/google_tag_manager_body" +- content_for :omniauth_providers_bottom do + = render 'devise/shared/signup_omniauth_providers' + .signup-page = render signup_box_template, url: registration_path(resource_name, registration_path_params), diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 698e8c89a08..4825f192d4d 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,31 +1,27 @@ -= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| - .form-group.gl-px-5.gl-pt-5 - = render_if_exists 'devise/sessions/new_base_user_login_label', form: f - = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } - .form-group.gl-px-5 - = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" += gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'gl-p-5 gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| + .form-group + = f.label :login, _('Username or email') + = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input js-username-field', autocomplete: 'username', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } + .form-group + = f.label :password, _('Password') = f.password_field :password, class: 'form-control gl-form-input js-password', data: { id: "#{resource_name}_password", qa_selector: 'password_field', testid: 'password-field', name: "#{resource_name}[password]" } - .gl-px-5 - .gl-display-inline-block - - if remember_me_enabled? - = f.gitlab_ui_checkbox_component :remember_me, _('Remember me') - .gl-float-right + .form-text.gl-text-right - if unconfirmed_email? = link_to _('Resend confirmation email'), new_user_confirmation_path - else = link_to _('Forgot your password?'), new_password_path(:user) - %div + + .form-group - if Feature.enabled?(:arkose_labs_login_challenge) = render_if_exists 'devise/sessions/arkose_labs' - elsif captcha_enabled? || captcha_on_login_required? - .gl-px-5 - = recaptcha_tags nonce: content_security_policy_nonce + = recaptcha_tags nonce: content_security_policy_nonce + + - if remember_me_enabled? + = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } - .submit-container.move-submit-down.gl-px-5.gl-pb-5 - = f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } - - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project) - .gl-px-5 - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do + = _('Sign in') diff --git a/app/views/devise/sessions/_new_base_user_login_label.html.haml b/app/views/devise/sessions/_new_base_user_login_label.html.haml deleted file mode 100644 index 8a8b9f7a361..00000000000 --- a/app/views/devise/sessions/_new_base_user_login_label.html.haml +++ /dev/null @@ -1 +0,0 @@ -= local_assigns[:form].label _('Username or email'), for: 'user_login', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml index 293e287371a..bb398eaf4be 100644 --- a/app/views/devise/sessions/_new_crowd.html.haml +++ b/app/views/devise/sessions/_new_crowd.html.haml @@ -1,13 +1,16 @@ -= form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user', class: 'gl-show-field-errors') do - .form-group.gl-px-5.gl-pt-5 - = label_tag :username, _('Username or email') - = text_field_tag :username, nil, { class: "form-control top", title: _("This field is required."), autofocus: "autofocus", required: true } - .form-group.gl-px-5 - = label_tag :password - = password_field_tag :password, nil, { class: 'form-control gl-form-input js-password', data: { id: 'password', name: 'password' } } - - if remember_me_enabled? - .remember-me.gl-px-5 - %label{ for: "remember_me" } - = check_box_tag :remember_me, '1', false, id: 'remember_me' - %span= _('Remember me') - = submit_tag _("Sign in"), class: "gl-button btn-confirm btn gl-px-5" +- render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true) +- submit_message = local_assigns.fetch(:submit_message, _('Sign in')) + += gitlab_ui_form_for(:crowd, url: omniauth_authorize_path(:user, :crowd), html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }, data: { testid: 'new_crowd_user' }}) do |f| + .form-group + = f.label :username, _('Username or email') + = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', required: true + .form-group + = f.label :password, _('Password') + = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{:crowd}_password", name: 'password' } + + - if render_remember_me + = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' } + + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do + = submit_message diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index fb5a57b509c..f9b6f462661 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -1,19 +1,18 @@ - server = local_assigns.fetch(:server) +- provider = server['provider_name'] - render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true) - submit_message = local_assigns.fetch(:submit_message, _('Sign in')) -= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do - .form-group.gl-px-5.gl-pt-5 - = label_tag :username, "#{server['label']} Username" - = text_field_tag :username, nil, { class: "form-control gl-form-input top", title: _("This field is required."), autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true } - .form-group.gl-px-5 - = label_tag :password - = password_field_tag :password, nil, { class: 'form-control gl-form-input js-password', data: { id: 'password', name: 'password', qa_selector: 'password_field' } } += gitlab_ui_form_for(provider, url: omniauth_callback_path(:user, provider), html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }, data: { testid: 'new_ldap_user' }}) do |f| + .form-group + = f.label :username, _('Username') + = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', data: { qa_selector: 'username_field' }, required: true + .form-group + = f.label :password, _('Password') + = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' } + - if render_remember_me - .gl-px-5 - = render Pajamas::CheckboxTagComponent.new(name: 'remember_me') do |c| - = c.label do - = _('Remember me') + = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' } - .submit-container.move-submit-down.gl-px-5.gl-pb-5 - = submit_tag submit_message, class: "gl-button btn btn-confirm", data: { qa_selector: 'sign_in_button' } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'sign_in_button' } }) do + = submit_message diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml index 6cafcb941b4..e0b5a266961 100644 --- a/app/views/devise/sessions/email_verification.haml +++ b/app/views/devise/sessions/email_verification.haml @@ -2,7 +2,7 @@ = render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account') .login-box.gl-p-5 .login-body - = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f| + = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f| %p = s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "<strong>#{sanitize(obfuscated_email(resource.email))}</strong>".html_safe } %div @@ -11,7 +11,7 @@ %p.gl-field-error.gl-mt-2 = resource.errors.full_messages.to_sentence .gl-mt-5 - = f.submit s_('IdentityVerification|Verify code'), class: 'gl-button btn btn-confirm' + = f.submit s_('IdentityVerification|Verify code'), class: 'gl-w-full', pajamas_button: true - unless send_rate_limited?(resource) = link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0' %p.gl-p-5.gl-text-secondary diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 06152e3dac5..e3457040e6c 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -1,17 +1,20 @@ -%div - = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication') if Feature.disabled?(:restyle_login_page, @project) - .login-box.gl-p-5 - .login-body - - if @user.two_factor_enabled? - = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f| += render 'devise/shared/tab_single', tab_title: _('Two-factor authentication') if Feature.disabled?(:restyle_login_page, @project) +.login-box.gl-p-5 + .login-body + - if @user.two_factor_enabled? + = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f| + .form-group + = f.label :otp_attempt, _('Enter verification code') + = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' } + %p.form-text.text-muted.hint + = _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.") + + - if remember_me_enabled? - resource_params = params[resource_name].presence || params - - if remember_me_enabled? - = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) - %div - = f.label _('Enter verification code'), name: :otp_attempt, class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-mb-1' : '' - = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' } - %p.form-text.text-muted.hint= _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.") - .prepend-top-20 - = f.submit _("Verify code"), pajamas_button: true, data: { qa_selector: 'verify_code_button' } - - if @user.two_factor_webauthn_enabled? - = render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path + = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) + + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'verify_code_button' } }) do + = _("Verify code") + + - if @user.two_factor_webauthn_enabled? + = render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path diff --git a/app/views/devise/shared/_error_messages.html.haml b/app/views/devise/shared/_error_messages.html.haml index b7589a4460e..caebda72b9c 100644 --- a/app/views/devise/shared/_error_messages.html.haml +++ b/app/views/devise/shared/_error_messages.html.haml @@ -3,7 +3,7 @@ variant: :danger, dismissible: false, alert_options: { id: 'error_explanation', class: 'gl-mb-3'}) do |c| - = c.body do + - c.with_body do %ul.gl-pl-4 - resource.errors.full_messages.each do |message| %li= message diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 14a9bde2d9e..8f2c2c58790 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -18,5 +18,5 @@ = label_for_provider(provider) - if render_remember_me = render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c| - = c.label do + - c.with_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 31c541eebde..684ade87720 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,11 +1,9 @@ - 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' + = yield :omniauth_providers_top if show_omniauth_providers = 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 @@ -77,5 +75,5 @@ .gl-pt-5 = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) = render 'devise/shared/terms_of_service_notice', button_text: button_text - - if show_omniauth_providers && omniauth_providers_placement == :bottom - = render 'devise/shared/signup_omniauth_providers' + + = yield :omniauth_providers_bottom if show_omniauth_providers diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index 6294a93808b..e8c82e456ae 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -1,11 +1,10 @@ -- register_omniauth_params = { intent: :register } - if Feature.enabled?(:restyle_login_page, @project) .gl-text-center.gl-pt-5 %label.gl-font-weight-normal = _("Register with:") .gl-text-center.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-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do + = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text @@ -15,7 +14,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-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do + = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml index d2a47974e01..5ec3c7a4150 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -1,4 +1,4 @@ - if Feature.disabled?(:restyle_login_page, @project) .omniauth-divider.gl-display-flex.gl-align-items-center = _("or") -= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers += render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers, tracking_label: "free_registration" diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml deleted file mode 100644 index 8eb22c0b023..00000000000 --- a/app/views/devise/shared/_signup_omniauth_providers_top.haml +++ /dev/null @@ -1,3 +0,0 @@ -= render 'devise/shared/signup_omniauth_provider_list', providers: popular_enabled_button_based_providers -.omniauth-divider.gl-display-flex.gl-align-items-center - = _("or") diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml index bb2bd193565..3291129fd69 100644 --- a/app/views/explore/groups/_groups.html.haml +++ b/app/views/explore/groups/_groups.html.haml @@ -1,3 +1,3 @@ .js-groups-list-holder - #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } + #js-groups-tree{ data: { endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } = gl_loading_icon(size: 'md', css_class: 'gl-mt-6') diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index e49b3eb7781..88d57ed7e33 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -3,5 +3,5 @@ - if current_user - unless has_label - %span.gl-float-left= _("Visibility:") + %span.gl-float-left.gl-white-space-nowrap= _("Visibility:") = gl_redirect_listbox_tag(projects_filter_items, selected, class: 'gl-ml-3', data: { placement: 'right' }) diff --git a/app/views/explore/projects/_head.html.haml b/app/views/explore/projects/_head.html.haml index 605d85f49e0..c1d37965cd6 100644 --- a/app/views/explore/projects/_head.html.haml +++ b/app/views/explore/projects/_head.html.haml @@ -3,7 +3,7 @@ = render_dashboard_ultimate_trial(current_user) -.page-title-holder.gl-display-flex.gl-align-items-center +.page-title-holder.gl-display-flex.gl-align-items-center{ data: { testid: 'explore-projects-title' } } %h1.page-title.gl-font-size-h-display= page_title .page-title-controls - if current_user&.can_create_project? diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 80da61847ef..6b4832d81aa 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -13,7 +13,7 @@ .text-muted.gl-mb-5 = labels_function_introduction .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10 - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' } + .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24 = _('Labels') %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 21b1986bd34..d92a6b08b60 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -3,7 +3,7 @@ .sub-section %h4.warning-title= s_('GroupSettings|Change group URL') - = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@group) .form-group %p @@ -23,7 +23,7 @@ title: group_url_error_message, maxlength: ::Namespace::URL_MAX_LENGTH, "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - = f.submit s_('GroupSettings|Change group URL'), class: 'btn gl-button btn-danger' + = f.submit s_('GroupSettings|Change group URL'), class: 'btn-danger', pajamas_button: true = render 'groups/settings/transfer', group: @group = render 'groups/settings/remove', group: @group, remove_form_id: remove_form_id diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml index 9f04b579a97..74f9298133b 100644 --- a/app/views/groups/settings/_lfs.html.haml +++ b/app/views/groups/settings/_lfs.html.haml @@ -7,6 +7,6 @@ .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :lfs_enabled, - _('Allow projects within this group to use Git LFS'), - help_text: _('Can be overridden in each project.'), + _('Projects in this group can use Git LFS'), + help_text: _('Possible to override in each project.'), checkbox_options: { checked: @group.lfs_enabled?, data: { qa_selector: 'lfs_checkbox' } } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 6fa76297679..f4749617463 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -30,8 +30,6 @@ 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 - - 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 @@ -40,6 +38,7 @@ = render 'groups/settings/lfs', f: f = render_if_exists 'groups/settings/code_suggestions', f: f, group: @group = render_if_exists 'groups/settings/ai_related_settings', f: f, group: @group + = render_if_exists 'groups/settings/ai_third_party_settings', f: f, group: @group = render 'groups/settings/git_access_protocols', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index e42f524467d..6758598d4dd 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -19,7 +19,7 @@ = render partial: 'flash_messages' -= render_if_exists 'trials/alert', namespace: @group += render_if_exists 'subscriptions/trials/alert', namespace: @group = render 'groups/home_panel' diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index eb6d5668807..4b16c0199ba 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -1,4 +1,5 @@ - page_title _("IDE"), @project.full_name +- add_page_specific_style 'page_bundles/web_ide_loader' - unless use_new_web_ide? - add_page_specific_style 'page_bundles/build' @@ -9,4 +10,4 @@ - data = ide_data(project: @project, fork_info: @fork_info, params: params) -= render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the GitLab IDE...') } += render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the GitLab IDE') } diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml index 2dbb54a9a0e..760715b56ea 100644 --- a/app/views/import/shared/_errors.html.haml +++ b/app/views/import/shared/_errors.html.haml @@ -2,6 +2,6 @@ = render Pajamas::AlertComponent.new(variant: :danger, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - = c.body do + - c.with_body do - @errors.each do |error| = error diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml index 28118cf4aaa..1d5f2583bbd 100644 --- a/app/views/layouts/_header_search.html.haml +++ b/app/views/layouts/_header_search.html.haml @@ -1,4 +1,4 @@ -#js-header-search.header-search.is-not-active.gl-relative.gl-w-full{ data: { 'search-context' => header_search_context.to_json, +#js-header-search.header-search-form.is-not-active.gl-relative.gl-w-full{ data: { 'search-context' => header_search_context.to_json, 'search-path' => search_path, 'issues-path' => issues_dashboard_path, 'mr-path' => merge_requests_dashboard_path, diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index b20b95cade8..1e6f671aacb 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -17,8 +17,6 @@ -# Do not use preload_link_tag for fonts, to work around Firefox double-fetch bug. -# See https://github.com/web-platform-tests/wpt/pull/36930 %link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Bold.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-BoldItalic.woff2'), as: 'font', crossorigin: css_crossorigin } + %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono.woff2'), as: 'font', crossorigin: css_crossorigin } + %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin } = preload_link_tag(path_to_stylesheet('fonts'), crossorigin: css_crossorigin) diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index aa80de7f789..8e52f973e9e 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -20,6 +20,8 @@ .mobile-overlay = dispensable_render_if_exists 'layouts/header/verification_reminder' .alert-wrapper.gl-force-block-formatting-context + = yield :code_suggestions_third_party_alert + = dispensable_render 'shared/new_nav_announcement' = dispensable_render 'shared/outdated_browser' = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold" = dispensable_render_if_exists "layouts/header/token_expiry_notification" diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 71771dd7cb6..6e1d3ba678c 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,4 +1,5 @@ - add_page_specific_style 'page_bundles/login' +- custom_text = custom_sign_in_description !!! 5 %html.devise-layout-html{ class: system_message_class } = render "layouts/head", { startup_filename: 'signin' } @@ -10,14 +11,13 @@ .container.navless-container .content = render "layouts/flash" - - if current_appearance&.description? + - if custom_text.present? .row .col-md.order-12.sm-bg-gray-10 .col-sm-12 %h1.mb-3.gl-font-size-h2 = brand_title - = brand_text - = render_if_exists 'layouts/devise_help_text' + = custom_text .col-md.order-md-12 .col-sm-12.bar .gl-text-center @@ -29,7 +29,6 @@ = brand_image %h1.mb-3.gl-font-size-h2 = brand_title - = render_if_exists 'layouts/devise_help_text' .mb-3 .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar = yield @@ -49,8 +48,8 @@ .col-md-6.order-12.order-sm-1.brand-holder - unless recently_confirmed_com? = brand_image - - if current_appearance&.description? - = brand_text + - if custom_text.present? + = custom_text - else %h3.gl-sm-mt-0 = _('A complete DevOps platform') @@ -61,11 +60,6 @@ %p = _('This is a self-managed instance of GitLab.') - - if Gitlab::CurrentSettings.sign_in_text.present? - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) - - = render_if_exists 'layouts/devise_help_text' - .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' } = yield diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index f4f9f39c20e..da192822902 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -17,7 +17,7 @@ = render "layouts/broadcast" = yield :flash_message = render "layouts/flash", flash_container_no_margin: true - .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" } + .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch gl-p-0" } = yield - unless minimal = render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto" diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 1f742279756..c75b02aa6a6 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -9,6 +9,7 @@ - content_for :flash_message do = dispensable_render_if_exists "groups/storage_enforcement_alert", context: @group = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @group + = dispensable_render_if_exists "shared/namespace_combined_storage_users_alert", context: @group - content_for :page_specific_javascripts do - if current_user @@ -20,6 +21,8 @@ = render 'groups/invite_members_modal', group: @group = dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert" += dispensable_render_if_exists "shared/code_suggestions_alert" += dispensable_render_if_exists "shared/code_suggestions_third_party_alert", source: @group = dispensable_render_if_exists "shared/free_user_cap_alert", source: @group = dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 1739dee1511..65dbafc19da 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -42,9 +42,8 @@ %li.d-md-none = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url, data: { track_action: "click_link", track_label: "switch_to_canary", track_property: "navigation_top" } - - if Feature.enabled?(:super_sidebar_nav, current_user) - %li.divider - .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_url} } + %li.divider + .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_url} } - if current_user_menu?(:sign_out) %li.divider diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 7156a0e5931..2c6ccb4abaf 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -29,7 +29,7 @@ .navbar-collapse.gl-transition-medium.collapse.gl-mr-auto.global-search-container.hide-when-top-nav-responsive-open - search_menu_item = top_nav_search_menu_item_attrs %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 + %li.nav-item.header-search.gl-display-none.gl-lg-display-block.gl-w-full - unless current_controller?(:search) = render 'layouts/header_search' %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' } diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml index 5c70136a932..ee4644e9ff0 100644 --- a/app/views/layouts/header/_registration_enabled_callout.html.haml +++ b/app/views/layouts/header/_registration_enabled_callout.html.haml @@ -6,9 +6,9 @@ data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path }}, close_button_options: { data: { testid: 'close-registration-enabled-callout' }}) do |c| - = c.body do + - c.with_body do = _("Your GitLab instance allows anyone to register for an account, which is a security risk on public-facing GitLab instances. You should deactivate new sign ups if public users aren't expected to register for an account.") - = c.actions do + - c.with_actions do = render Pajamas::ButtonComponent.new(variant: :confirm, href: general_admin_application_settings_path(anchor: 'js-signup-settings')) do = _('Deactivate') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close gl-ml-3'}) do diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 31d02324e68..4ecae875056 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 "shared/namespace_combined_storage_users_alert", context: @project - content_for :project_javascripts do - project = @target_project || @project @@ -22,6 +23,8 @@ = render 'projects/invite_members_modal', project: @project = dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert" += dispensable_render_if_exists "projects/code_suggestions_alert", project: @project += dispensable_render_if_exists "projects/code_suggestions_third_party_alert", project: @project = dispensable_render_if_exists "projects/free_user_cap_alert", project: @project = dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 71c622d7a62..9a50e3e2eb2 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -17,9 +17,9 @@ %div{ class: "#{container_class} limit-container-width" } .content{ id: "content-body" } = render Pajamas::CardComponent.new do |c| - = c.header do + - c.with_header do = brand_header_logo({add_gitlab_black_text: true}) - = c.body do + - c.with_body do - if header_link?(:user_dropdown) .navbar-collapse %ul.nav.navbar-nav diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml index f6b517d6e34..9c25567696f 100644 --- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml +++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml @@ -78,7 +78,7 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } %img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" } %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } - %span= _('Merge request was scheduled to merge after pipeline succeeds') + %span= _('Merge request was set to auto-merge') %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } @@ -91,7 +91,7 @@ %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } %span{ style: "font-weight: 600;color:#333333;" }= _('Merge request') %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference - %span= _('was scheduled to merge after pipeline succeeds by') + %span= _('was set to auto-merge by') %img.avatar{ height: "24", src: avatar_icon_for_user(@mwps_set_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" } %a.muted{ href: user_url(@mwps_set_by), style: "color:#333333;text-decoration:none;" } = @mwps_set_by.name diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml index dbf742a5cbc..cbaa88befd2 100644 --- a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml +++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml @@ -1,4 +1,4 @@ -Merge request #{@merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{sanitize_name(@mwps_set_by.name)} +Merge request #{@merge_request.to_reference} was set to auto-merge by #{sanitize_name(@mwps_set_by.name)} Merge request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} diff --git a/app/views/organizations/organizations/directory.html.haml b/app/views/organizations/organizations/directory.html.haml new file mode 100644 index 00000000000..1d2fb66112b --- /dev/null +++ b/app/views/organizations/organizations/directory.html.haml @@ -0,0 +1,2 @@ +- breadcrumb_title @organization.name +- page_title @organization.name diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 0505a205333..fec5d2d5ff5 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -4,14 +4,14 @@ - if current_user.ldap_user? = render Pajamas::AlertComponent.new(alert_options: { class: 'gl-my-5' }, dismissible: false) do |c| - = c.body do + - c.with_body do = s_('Profiles|Some options are unavailable for LDAP accounts') - if params[:two_factor_auth_enabled_successfully] = render Pajamas::AlertComponent.new(variant: :success, alert_options: { class: 'gl-my-5' }, close_button_options: { class: 'js-close-2fa-enabled-success-alert' }) do |c| - = c.body do + - c.with_body do = html_escape(_('You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}.')) % { anchorOpen: '<a href="%{href}">'.html_safe % { href: help_page_path('user/profile/account/two_factor_authentication', anchor: 'generate-new-recovery-codes-using-ssh') }, anchorClose: '</a>'.html_safe } .row.gl-mt-3.js-search-settings-section @@ -52,43 +52,52 @@ %p = s_('Profiles|Changing your username can have unintended side effects.') = succeed '.' do - = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer' .col-lg-8 - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } #update-username{ data: data } .col-lg-12 %hr -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0.danger-title - = s_('Profiles|Delete account') - .col-lg-8 - - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user) +- if prevent_delete_account? + .row.gl-mt-3.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0.danger-title + = s_('Profiles|Delete account') + .col-lg-8 %p - = s_('Profiles|Deleting an account has the following effects:') - = render 'users/deletion_guidance', user: current_user - - -# Delete button here - = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do + = s_('Profiles|Account deletion is not allowed by your administrator.') +- else + .row.gl-mt-3.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0.danger-title = s_('Profiles|Delete account') - - #delete-account-modal{ data: { action_url: user_registration_path, - confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), - username: current_user.username } } - - else - - if current_user.solo_owned_groups.present? - %p - = s_('Profiles|Your account is currently an owner in these groups:') - %strong= current_user.solo_owned_groups.map(&:name).join(', ') - %p - = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - - elsif !current_user.can_remove_self? - %p - = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} + .col-lg-8 + - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user) %p - = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe} + = s_('Profiles|Deleting an account has the following effects:') + = render 'users/deletion_guidance', user: current_user + + -# Delete button here + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do + = s_('Profiles|Delete account') + + #delete-account-modal{ data: { action_url: user_registration_path, + confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), + username: current_user.username } } - else - %p - = s_("Profiles|You don't have access to delete this user.") + - if current_user.solo_owned_groups.present? + %p + = s_('Profiles|Your account is currently an owner in these groups:') + %strong= current_user.solo_owned_groups.map(&:name).join(', ') + %p + = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') + - elsif !current_user.can_remove_self? + %p + = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} + %p + = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe} + - else + %p + = s_("Profiles|You don't have access to delete this user.") .gl-mb-3 diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index 54736153223..1952655937e 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -11,6 +11,6 @@ .gl-mb-3 = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c| - - c.body do + - c.with_body do %ul.list-group.list-group-flush = render partial: 'profiles/active_sessions/active_session', collection: @sessions diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml index bc30ccc5821..b0a694f9bc6 100644 --- a/app/views/profiles/chat_names/new.html.haml +++ b/app/views/profiles/chat_names/new.html.haml @@ -3,9 +3,9 @@ %main{ role: 'main' } .gl-max-w-80.gl-mx-auto.gl-mt-6 = render Pajamas::CardComponent.new do |c| - - c.header do + - c.with_header do %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 + - c.with_body do %p = 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 @@ -16,7 +16,7 @@ %li= s_('SlackIntegration|Run ChatOps jobs.') %p.gl-mb-0 = s_("SlackIntegration|You don't have to reauthorize this application if the permission scope changes in future releases.") - - c.footer do + - c.with_footer do .gl-display-flex = form_tag profile_chat_names_path, method: :post do = hidden_field_tag :token, @chat_name_token.token diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 3c05502be57..4f3d97fb90c 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -2,9 +2,9 @@ .row.gl-mt-3 .col-md-4 = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c| - - c.header do + - c.with_header do = _('SSH Key') - - c.body do + - c.with_body do %ul.content-list %li %span.light= _('Title:') @@ -27,9 +27,9 @@ %pre.well-pre = @key.key = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c| - - c.header do + - c.with_header do = _('Fingerprints') - - c.body do + - c.with_body do %ul.content-list %li %span.light= 'MD5:' diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index a632c450eda..06d37787d2e 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -5,7 +5,7 @@ %div - if @user.errors.any? = render Pajamas::AlertComponent.new(variant: :danger) do |c| - = c.body do + - c.with_body do %ul - @user.errors.full_messages.each do |msg| %li= msg diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 7f8858411ca..a085840ee84 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -90,6 +90,8 @@ .form-text.text-muted = s_('Preferences|Choose what content you want to see on a project’s overview page.') .form-group + = f.gitlab_ui_checkbox_component :project_shortcut_buttons, s_('Preferences|Show shortcut buttons above files on project overview') + .form-group = f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE') .form-group = f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs') @@ -168,6 +170,7 @@ .form-group = f.gitlab_ui_checkbox_component :enabled_following, s_('Preferences|Enable follow users') - + = render_if_exists 'profiles/preferences/code_suggestions_settings', form: f + = render_if_exists 'profiles/preferences/zoekt_settings', form: f #js-profile-preferences-app{ data: data_attributes } diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 930f4f5c397..1a932ed7b35 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -4,195 +4,198 @@ - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host - @force_desktop_expanded_sidebar = true -= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| - .row.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_("Profiles|Public avatar") - %p - - if @user.avatar? - - if gravatar_enabled? - = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } - - else - = s_("Profiles|You can change your avatar here") - - else - - if gravatar_enabled? - = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } +- if Feature.enabled?(:edit_user_profile_vue, current_user) + .js-user-profile +- else + = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| + .row.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_("Profiles|Public avatar") + %p + - if @user.avatar? + - if gravatar_enabled? + = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can change your avatar here") - else - = s_("Profiles|You can upload your avatar here") - - if current_appearance&.profile_image_guidelines? - .md - = brand_profile_image_guidelines - .col-lg-8 - .avatar-image - = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do - = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') - %h5.gl-mt-0= s_("Profiles|Upload new avatar") - .gl-display-flex.gl-align-items-center.gl-my-3 - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do - = s_("Profiles|Choose file...") - %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") - = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' - .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") - - if @user.avatar? - = render Pajamas::ButtonComponent.new(variant: :danger, - category: :secondary, - href: profile_avatar_path, - button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, - method: :delete) do - = s_("Profiles|Remove avatar") - .col-lg-12 - %hr - .row.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0= s_("Profiles|Current status") - %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") - .col-lg-8 - #js-user-profile-set-status-form - = f.fields_for :status, @user.status do |status_form| - = status_form.hidden_field :emoji, data: { js_name: 'emoji' } - = status_form.hidden_field :message, data: { js_name: 'message' } - = status_form.hidden_field :availability, data: { js_name: 'availability' } - = status_form.hidden_field :clear_status_after, - value: user_clear_status_at(@user), - data: { js_name: 'clearStatusAfter' } - .col-lg-12 - %hr - .row.user-time-preferences.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0= s_("Profiles|Time settings") - %p= s_("Profiles|Set your local time zone.") - .col-lg-8 - = f.label :user_timezone, _("Time zone") - .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } - .col-lg-12 - %hr - .row.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_("Profiles|Main settings") - %p - = s_("Profiles|This information will appear on your profile.") - - if current_user.ldap_user? - = s_("Profiles|Some options are unavailable for LDAP accounts") - .col-lg-8 - .row - .form-group.gl-form-group.col-md-9.rspec-full-name - = render 'profiles/name', form: f, user: @user - .form-group.gl-form-group.col-md-3 - = f.label :id, s_('Profiles|User ID') - = f.text_field :id, class: 'gl-form-input form-control', readonly: true - .form-group.gl-form-group - = f.label :pronouns, s_('Profiles|Pronouns') - = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Enter your pronouns to let people know how to refer to you.") - .form-group.gl-form-group - = f.label :pronunciation, s_('Profiles|Pronunciation') - = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") - = render_if_exists 'profiles/extra_settings', form: f - = render_if_exists 'profiles/email_settings', form: f - .form-group.gl-form-group - = f.label :skype - = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username") - .form-group.gl-form-group - = f.label :linkedin - = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") - .form-group.gl-form-group - = f.label :twitter - = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username") - .form-group.gl-form-group - - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page') - - external_accounts_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: external_accounts_help_url } - - external_accounts_docs_link = s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}').html_safe % { external_accounts_link_start: external_accounts_link_start, external_accounts_link_end: '</a>'.html_safe } - - min_discord_length = 17 - - max_discord_length = 20 - = f.label :discord - = f.text_field :discord, - class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length', - placeholder: s_("Profiles|User ID"), - data: { min_length: min_discord_length, - min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length }, - max_length: max_discord_length, - max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length }, - allow_empty: true} - %small.form-text.text-gl-muted - = external_accounts_docs_link + - if gravatar_enabled? + = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can upload your avatar here") + - if current_appearance&.profile_image_guidelines? + .md + = brand_profile_image_guidelines + .col-lg-8 + .avatar-image + = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do + = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') + %h5.gl-mt-0= s_("Profiles|Upload new avatar") + .gl-display-flex.gl-align-items-center.gl-my-3 + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do + = s_("Profiles|Choose file...") + %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") + = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' + .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") + - if @user.avatar? + = render Pajamas::ButtonComponent.new(variant: :danger, + category: :secondary, + href: profile_avatar_path, + button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, + method: :delete) do + = s_("Profiles|Remove avatar") + .col-lg-12 + %hr + .row.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0= s_("Profiles|Current status") + %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") + .col-lg-8 + #js-user-profile-set-status-form + = f.fields_for :status, @user.status do |status_form| + = status_form.hidden_field :emoji, data: { js_name: 'emoji' } + = status_form.hidden_field :message, data: { js_name: 'message' } + = status_form.hidden_field :availability, data: { js_name: 'availability' } + = status_form.hidden_field :clear_status_after, + value: user_clear_status_at(@user), + data: { js_name: 'clearStatusAfter' } + .col-lg-12 + %hr + .row.user-time-preferences.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0= s_("Profiles|Time settings") + %p= s_("Profiles|Set your local time zone.") + .col-lg-8 + = f.label :user_timezone, _("Time zone") + .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } + .col-lg-12 + %hr + .row.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_("Profiles|Main settings") + %p + = s_("Profiles|This information will appear on your profile.") + - if current_user.ldap_user? + = s_("Profiles|Some options are unavailable for LDAP accounts") + .col-lg-8 + .row + .form-group.gl-form-group.col-md-9.rspec-full-name + = render 'profiles/name', form: f, user: @user + .form-group.gl-form-group.col-md-3 + = f.label :id, s_('Profiles|User ID') + = f.text_field :id, class: 'gl-form-input form-control', readonly: true + .form-group.gl-form-group + = f.label :pronouns, s_('Profiles|Pronouns') + = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Enter your pronouns to let people know how to refer to you.") + .form-group.gl-form-group + = f.label :pronunciation, s_('Profiles|Pronunciation') + = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") + = render_if_exists 'profiles/extra_settings', form: f + = render_if_exists 'profiles/email_settings', form: f + .form-group.gl-form-group + = f.label :skype + = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username") + .form-group.gl-form-group + = f.label :linkedin + = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") + .form-group.gl-form-group + = f.label :twitter + = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username") + .form-group.gl-form-group + - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page') + - external_accounts_link = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer" + - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end)) + - min_discord_length = 17 + - max_discord_length = 20 + = f.label :discord + = f.text_field :discord, + class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length', + placeholder: s_("Profiles|User ID"), + data: { min_length: min_discord_length, + min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length }, + max_length: max_discord_length, + max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length }, + allow_empty: true} + %small.form-text.text-gl-muted + = external_accounts_docs_link - .form-group.gl-form-group - = f.label :website_url, s_('Profiles|Website url') - = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com") - .form-group.gl-form-group - = f.label :location, s_('Profiles|Location') - - if @user.read_only_attribute?(:location) - = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true + .form-group.gl-form-group + = f.label :website_url, s_('Profiles|Website url') + = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com") + .form-group.gl-form-group + = f.label :location, s_('Profiles|Location') + - if @user.read_only_attribute?(:location) + = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true + %small.form-text.text-gl-muted + = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } + - else + = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country") + .form-group.gl-form-group + = f.label :job_title, s_('Profiles|Job title') + = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg' + .form-group.gl-form-group + = f.label :organization, s_('Profiles|Organization') + = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Who you represent or work for.") + .form-group.gl-form-group + = f.label :bio, s_('Profiles|Bio') + = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 %small.form-text.text-gl-muted - = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } - - else - = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country") - .form-group.gl-form-group - = f.label :job_title, s_('Profiles|Job title') - = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg' - .form-group.gl-form-group - = f.label :organization, s_('Profiles|Organization') - = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Who you represent or work for.") - .form-group.gl-form-group - = f.label :bio, s_('Profiles|Bio') - = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 - %small.form-text.text-gl-muted - = s_("Profiles|Tell us about yourself in fewer than 250 characters.") - %hr - %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label - = _('Private profile') - - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.") - - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private') - = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe } - %fieldset.form-group.gl-form-group - %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 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 - = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true - = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do - = s_('TagsPage|Cancel') + = s_("Profiles|Tell us about yourself in fewer than 250 characters.") + %hr + %fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = _('Private profile') + - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.") + - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private') + = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe } + %fieldset.form-group.gl-form-group + %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 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 + = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true + = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do + = s_('TagsPage|Cancel') -#password-prompt-modal + #password-prompt-modal -.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } } - .modal-dialog - .modal-content - .modal-header - %h4.modal-title - = s_("Profiles|Position and size your new avatar") - = render Pajamas::ButtonComponent.new(category: :tertiary, - icon: 'close', - button_options: { class: 'close', "data-dismiss": "modal", "aria-label" => _("Close") }) - .modal-body - .profile-crop-image-container - %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } - .gl-text-center.gl-mt-4 - .btn-group - = render Pajamas::ButtonComponent.new(icon: 'search-minus', - button_options: {data: { method: 'zoom', option: '-0.1' }}) - = render Pajamas::ButtonComponent.new(icon: 'search-plus', - button_options: {data: { method: 'zoom', option: '0.1' }}) - .modal-footer - = render Pajamas::ButtonComponent.new(variant: :confirm, - button_options: { class: 'js-upload-user-avatar'}) do - = s_("Profiles|Set new profile picture") + .modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } } + .modal-dialog + .modal-content + .modal-header + %h4.modal-title + = s_("Profiles|Position and size your new avatar") + = render Pajamas::ButtonComponent.new(category: :tertiary, + icon: 'close', + button_options: { class: 'close', "data-dismiss": "modal", "aria-label" => _("Close") }) + .modal-body + .profile-crop-image-container + %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } + .gl-text-center.gl-mt-4 + .btn-group + = render Pajamas::ButtonComponent.new(icon: 'search-minus', + button_options: {data: { method: 'zoom', option: '-0.1' }}) + = render Pajamas::ButtonComponent.new(icon: 'search-plus', + button_options: {data: { method: 'zoom', option: '0.1' }}) + .modal-footer + = render Pajamas::ButtonComponent.new(variant: :confirm, + button_options: { class: 'js-upload-user-avatar'}) do + = s_("Profiles|Set new profile picture") diff --git a/app/views/profiles/slacks/edit.html.haml b/app/views/profiles/slacks/edit.html.haml new file mode 100644 index 00000000000..20274735650 --- /dev/null +++ b/app/views/profiles/slacks/edit.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs _('Profile'), profile_path +- @hide_top_links = true +- @content_class = 'limit-container-width' +- page_title s_('SlackIntegration|GitLab for Slack') + +.js-gitlab-slack-application{ data: gitlab_slack_application_data(@projects) } diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 9cc7f6bdd49..461164e1ae9 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -24,7 +24,7 @@ = raw @qr_code .col-md-8 = render Pajamas::CardComponent.new do |c| - - c.body do + - c.with_body do %p.gl-mt-0.gl-mb-3.gl-font-weight-bold = _("Can't scan the code?") %p.gl-mt-0.gl-mb-3 @@ -42,7 +42,7 @@ variant: :danger, alert_options: { class: 'gl-mb-3' }, dismissible: false) do |c| - = c.body do + - c.with_body do = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' - if current_password_required? @@ -130,7 +130,7 @@ variant: :danger, alert_options: { class: 'gl-mb-3' }, dismissible: false) do |c| - = c.body do + - c.with_body do = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' .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 diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 5c7f83fc579..b5bbb57d58f 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -2,6 +2,7 @@ - is_project_overview = local_assigns.fetch(:is_project_overview, false) - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } +- has_project_shortcut_buttons = !current_user || current_user.project_shortcut_buttons - add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0) - 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") @@ -10,7 +11,8 @@ .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{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } } + - if project.licensed_feature_available?(: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 @@ -18,7 +20,7 @@ - if project.forked? #js-fork-info{ data: vue_fork_divergence_data(project, ref) } - - if is_project_overview + - if is_project_overview && has_project_shortcut_buttons .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml index f205fe2b9bf..dd32d3f9d92 100644 --- a/app/views/projects/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/_merge_request_merge_method_settings.html.haml @@ -14,6 +14,10 @@ - ffTrains = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.') - ffTrainsHelp = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer' +- ffTrainsWithFastForward = (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + ffTrains + " " + ffTrainsHelp).html_safe +- ffTrainsWithoutFastForward = (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase).html_safe +- ffTrainsHelpFullHelpText = Feature.enabled?(:fast_forward_merge_trains_support) ? ffTrainsWithFastForward : ffTrainsWithoutFastForward + .form-group %b= s_('ProjectSettings|Merge method') %p.text-secondary @@ -30,5 +34,5 @@ = form.gitlab_ui_radio_component :merge_method, :ff, labelFastForward, - help_text: (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + ffTrains + " " + ffTrainsHelp).html_safe, + help_text: ffTrainsHelpFullHelpText, radio_options: { data: { qa_selector: 'merge_ff_radio' } } diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 70a2476c8e5..6049d1cc110 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -48,7 +48,7 @@ variant: :success) do |c| - c.with_body do - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') } - = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe } + = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more%{help_link_end}.')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe } - if include_description .form-group diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 7654677d8a8..14991ce3824 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -10,6 +10,7 @@ - if ::Gitlab::ServiceDesk.supported? .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project), enabled: "#{@project.service_desk_enabled}", + issue_tracker_enabled: "#{@project.project_feature.issues_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::Email::ServiceDeskEmail.enabled?}", diff --git a/app/views/projects/blame/_page.html.haml b/app/views/projects/blame/_page.html.haml index 92fb99c30a6..10f27c3f620 100644 --- a/app/views/projects/blame/_page.html.haml +++ b/app/views/projects/blame/_page.html.haml @@ -7,7 +7,7 @@ - line_count = blame_group[:lines].count .tr{ class: ('last-row' if groups_length == index) } - .td.blame-commit.commit{ class: commit_data.age_map_class } + .td.blame-commit.commit.gl-py-3.gl-px-4{ class: commit_data.age_map_class } = commit_data.author_avatar .commit-row-title diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 453a60a62f4..e5566882371 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -2,7 +2,8 @@ - project = @project.present(current_user: current_user) - ref = local_assigns[:ref] || @ref - expanded = params[:expanded].present? -- if blob.rich_viewer +-# If the blob has a RichViewer we preload the content except for GeoJSON since it is handled by Vue +- if blob.rich_viewer && blob.extension != 'geojson' - add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) } .info-well.d-none.d-sm-block @@ -10,7 +11,8 @@ %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, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } } + - if project.licensed_feature_available?(:code_owners) + #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 - if project.forked? diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 621cd251bdf..68520d36858 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -26,7 +26,10 @@ dismiss_key: @project.id, human_access: human_access } } - - unless Feature.enabled?(:source_editor_toolbar, current_user) + - if Feature.enabled?(:source_editor_toolbar, current_user) + #editor-toolbar + + - else .file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end - if is_markdown .md-header.gl-display-flex.gl-px-2.gl-rounded-base.gl-mx-2.gl-mt-2 @@ -40,8 +43,6 @@ = _("Soft wrap") .file-editor.code - - if Feature.enabled?(:source_editor_toolbar, current_user) - #editor-toolbar .js-edit-mode-pane#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }< %pre.editor-loading-content= params[:content] || local_assigns[:blob_data] - if local_assigns[:path] diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml index 1ff68cd2d11..d5dd24a6995 100644 --- a/app/views/projects/blob/_render_error.html.haml +++ b/app/views/projects/blob/_render_error.html.haml @@ -3,5 +3,5 @@ The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}. You can - = Gitlab::Utils.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe + = Gitlab::Sentence.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe instead. diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index c1f4633f69f..0bd29ceb563 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -4,8 +4,6 @@ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } }) .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } }) - .metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } }) #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } }) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml index eac8c17b7ff..efc12a31603 100644 --- a/app/views/projects/blob/viewers/_contributing.html.haml +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -4,6 +4,6 @@ - options = contribution_options(viewer.project) - if options.any? = succeed '.' do - = Gitlab::Utils.to_exclusive_sentence(options).html_safe + = Gitlab::Sentence.to_exclusive_sentence(options).html_safe - else = _("contribute to this project.") diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index dbc1fe24d96..adff64fad5a 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -1,51 +1,62 @@ - merged = local_assigns.fetch(:merged, false) - commit = @repository.commit(branch.dereferenced_target) -- merge_project = merge_request_source_project_for_project(@project) -%li{ class: "branch-item gl-py-3! js-branch-item js-branch-#{branch.name}", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } } - .branch-item-content.gl-display-flex.gl-align-items-center.gl-px-3.gl-py-2 - .branch-info - .gl-display-flex.gl-align-items-center - = sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0') - = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3', data: { qa_selector: 'branch_link' } do - = branch.name - = clipboard_button(text: branch.name, title: _("Copy branch name")) - - if branch.name == @repository.root_ref - = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :info, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } } - - elsif merged - = gl_badge_tag s_('Branches|merged'), { variant: :info, size: :sm }, { class: 'gl-ml-2', title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref }, data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } } - - if protected_branch?(@project, branch) - = gl_badge_tag s_('Branches|protected'), { variant: :success, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } } - - = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch - - .block-truncated - - if commit - = render 'projects/branches/commit', commit: commit, project: @project - - else - = s_('Branches|Can’t find HEAD commit for this branch') - - - if branch.name != @repository.root_ref - .js-branch-divergence-graph - - .controls.d-none.d-md-block< - - if commit_status - = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5' - - elsif show_commit_status - .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5 - %svg.s24 - - - if merge_project && create_mr_button?(from: branch.name, source_project: @project) - = render Pajamas::ButtonComponent.new(href: create_mr_path(from: branch.name, source_project: @project)) do - = _('Merge request') - - - if branch.name != @repository.root_ref - = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), - class: "gl-button btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}", - method: :post, - title: s_('Branches|Compare') do - = s_('Branches|Compare') - - = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], class: 'gl-vertical-align-top' - - - if can?(current_user, :push_code, @project) - = render 'projects/branches/delete_branch_modal_button', project: @project, branch: branch, merged: merged +- related_merge_request = @related_merge_requests[branch.name]&.first +- mr_status = merge_request_status(related_merge_request) +- is_default_branch = branch.name == @repository.root_ref + +%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-3!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } } + .branch-info + .gl-display-flex.gl-align-items-center + = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do + = branch.name + = clipboard_button(text: branch.name, title: _("Copy branch name")) + - if is_default_branch + = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :neutral, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } } + - if protected_branch?(@project, branch) + = gl_badge_tag s_('Branches|protected'), { variant: :muted, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } } + + = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch + + .block-truncated + - if commit + = render 'projects/branches/commit', commit: commit, project: @project + - else + = s_('Branches|Can’t find HEAD commit for this branch') + + - if branch.name != @repository.root_ref + .js-branch-divergence-graph + + .pipeline-status.d-none.d-md-block< + - if commit_status + = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5' + - elsif show_commit_status + .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5 + %svg.s16 + + + - if mr_status.present? + .issuable-reference.gl-display-flex.gl-justify-content-end.gl-min-w-10.gl-ml-5.gl-mr-4 + = gl_badge_tag issuable_reference(related_merge_request), + { icon: mr_status[:icon], variant: mr_status[:variant], size: :md, href: merge_request_path(related_merge_request) }, + { class: 'gl-mr-2', title: mr_status[:title], data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } } + + .controls.d-none.d-md-block< + - if mr_status.nil? && create_mr_button?(from: branch.name, source_project: @project) + = render Pajamas::ButtonComponent.new(icon: 'merge-request', href: create_mr_path(from: branch.name, source_project: @project), button_options: { class: 'has-tooltip gl-mr-2!', title: _('New merge request') }) do + = _('New') + + = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], css_class: 'gl-mr-1!' + + - if !is_default_branch + .js-branch-more-actions{ data: { + branch_name: branch.name, + default_branch_name: @repository.root_ref, + can_delete_branch: user_access(@project).can_delete_branch?(branch.name).to_s, + is_protected_branch: protected_branch?(@project, branch).to_s, + merged: merged.to_s, + compare_path: project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), + delete_path: project_branch_path(@project, branch.name), + } } + - else + .gl-display-inline-flex.gl-w-7 + diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml index cfa0cf6d07b..6bbd0617598 100644 --- a/app/views/projects/branches/_commit.html.haml +++ b/app/views/projects/branches/_commit.html.haml @@ -1,9 +1,7 @@ -.branch-commit.cgray - .icon-container.commit-icon - = custom_icon("icon_commit") +.branch-commit.gl-font-sm.gl-text-gray-500 = link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha" · %span.str-truncated - = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message cgray" + = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message gl-text-gray-500!" · %span.gl-text-secondary= time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/branches/_delete_branch_modal_button.html.haml b/app/views/projects/branches/_delete_branch_modal_button.html.haml deleted file mode 100644 index 829a459ad2c..00000000000 --- a/app/views/projects/branches/_delete_branch_modal_button.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- if branch.name == @project.repository.root_ref - .js-delete-branch-button{ data: { tooltip: s_('Branches|The default branch cannot be deleted'), - disabled: true.to_s } } -- elsif protected_branch?(@project, branch) - - if can?(current_user, :push_to_delete_protected_branch, @project) - .js-delete-branch-button{ data: { branch_name: branch.name, - is_protected_branch: true.to_s, - merged: merged.to_s, - default_branch_name: @project.repository.root_ref, - delete_path: project_branch_path(@project, branch.name) } } - - else - .js-delete-branch-button{ data: { is_protected_branch: true.to_s, - disabled: true.to_s } } -- else - .js-delete-branch-button{ data: { branch_name: branch.name, - merged: merged.to_s, - default_branch_name: @project.repository.root_ref, - delete_path: project_branch_path(@project, branch.name) } } diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 64adf97b1b5..8992753c676 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -1,7 +1,7 @@ - add_page_specific_style 'page_bundles/branches' - page_title _('Branches') - add_to_breadcrumbs(_('Repository'), project_tree_path(@project)) -- is_branch_rules_available = (can? current_user, :maintainer_access, @project) && Feature.enabled?(:branch_rules, @project) +- can_access_branch_rules = can?(current_user, :maintainer_access, @project) - can_push_code = (can? current_user, :push_code, @project) -# Possible values for variables passed down from the projects/branches_controller.rb @@ -24,7 +24,7 @@ sorted_by: @sort } } - - if is_branch_rules_available + - if can_access_branch_rules = link_to project_settings_repository_path(@project, anchor: 'js-branch-rules'), class: 'gl-button btn btn-default' do = s_('Branches|View branch rules') @@ -38,7 +38,7 @@ = render_if_exists 'projects/commits/mirror_status' -- if is_branch_rules_available +- if can_access_branch_rules = render 'branch_rules_info' .js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } } diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 1fbc399c3ff..bbee7d66dcb 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,10 +1,11 @@ - project = local_assigns.fetch(:project) - ref = local_assigns.fetch(:ref) - pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) } +- css_class = local_assigns.fetch(:css_class, '') - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" - .project-action-button.dropdown.gl-dropdown.inline> + .project-action-button.dropdown.gl-dropdown.inline{ class: css_class }> %button.gl-button.btn.btn-default.dropdown-toggle.gl-dropdown-toggle.dropdown-icon-only.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } = sprite_icon('download', css_class: 'gl-icon dropdown-icon') %span.sr-only= _('Select Archive Format') diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 079e24c6389..c161e1c9d2a 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -29,15 +29,14 @@ %pre.commit-description< = preserve(markdown_field(@commit, :description)) -.info-well.js-commit-box-info{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) } +.info-well .well-segment .icon-container.commit-icon = custom_icon("icon_commit") %span.cgray= n_('parent', 'parents', @commit.parents.count) - @commit.parents.each do |parent| = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha" - .commit-info.branches - = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle') + #js-commit-branches-and-tags{ data: { full_path: @project.full_path, commit_sha: @commit.short_id } } .well-segment.merge-request-info .icon-container diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 9cca928e794..5b99a88f29e 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -29,5 +29,5 @@ = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3') -%a.signature-badge.gl-display-inline-block{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } +%a.signature-badge.gl-display-inline-block.gl-ml-4{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = gl_badge_tag label, variant: variant diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 9cbabaee774..a0f47f375f7 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -8,12 +8,10 @@ - hidden = @hidden_commit_count - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits| - %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b{ data: { day: day } } - %span.day.font-weight-bold= l(day, format: '%d %b, %Y') - %span - - %span.commits-count= n_("%d commit", "%d commits", daily_commits.size) % daily_commits.size + %li.js-commit-header.gl-py-2.gl-border-b{ data: { day: day } } + %span.day.font-weight-bold= l(day, format: '%b %d, %Y') - %li.commits-row{ data: { day: day } } + %li.commits-row.gl-mb-6{ data: { day: day } } %ul.content-list.commit-list.flex-list - if Feature.enabled?(:cached_commits, project) = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: ->(commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } @@ -21,13 +19,13 @@ = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request } - if context_commits.present? - %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b + %li.js-commit-header.gl-py-2.gl-border-b %span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count - if can_update_merge_request = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-ml-3 add-review-item-modal-trigger', data: { context_commits_empty: 'false' } }) do = _('Add/remove') - %li.commits-row + %li.commits-row.gl-mb-6 %ul.content-list.commit-list.flex-list - if Feature.enabled?(:cached_commits, project) = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: ->(commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 64bd1bf32f0..5ec95c3095d 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -17,9 +17,10 @@ .file-actions.gl-display-none.gl-sm-display-flex #js-diff-stats{ data: diff_file_stats_data(diff_file) } - if diff_file.blob&.readable_text? - %span.has-tooltip{ title: _("Toggle comments for this file") } - = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected', disabled: @diff_notes_disabled do - = sprite_icon('comment') + - unless @diff_notes_disabled + %span.has-tooltip{ title: _("Toggle comments for this file") } + = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected' do + = sprite_icon('comment') \ - if editable_diff?(diff_file) - link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {} diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 9b64afa8c60..08aeb3d4b07 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -4,40 +4,33 @@ %a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.") %table.text-file.diff-wrap-lines.code.code-commit.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' } - - if Feature.enabled?(:inline_haml_diff_line_rendering, @project) - - diff_file.highlighted_diff_lines.each do |line| - - line_code = diff_file.line_code(line) + - diff_file.highlighted_diff_lines.each do |line| + - line_code = diff_file.line_code(line) - %tr.line_holder{ class: line.type, id: line_code } - - case line.type - - when 'match' - = diff_match_line line.old_pos, line.new_pos, text: line.text - - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw' - = diff_nomappinginraw_line line, %w[old_line diff-line-num], %w[new_line diff-line-num], %w[line_content] - - when 'old-nonewline', 'new-nonewline' - %td.old_line.diff-line-num - %td.new_line.diff-line-num - %td.line_content.match= line.text - - else - %td.old_line.diff-line-num{ class: "#{line.type} js-avatar-container", data: { linenumber: line.old_pos } } - = add_diff_note_button(line_code, diff_file.position(line), line.type) - %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "new", line.old_pos) } } + %tr.line_holder{ class: line.type, id: line_code } + - case line.type + - when 'match' + = diff_match_line line.old_pos, line.new_pos, text: line.text + - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw' + = diff_nomappinginraw_line line, %w[old_line diff-line-num], %w[new_line diff-line-num], %w[line_content] + - when 'old-nonewline', 'new-nonewline' + %td.old_line.diff-line-num + %td.new_line.diff-line-num + %td.line_content.match= line.text + - else + %td.old_line.diff-line-num{ class: "#{line.type} js-avatar-container", data: { linenumber: line.old_pos } } + = add_diff_note_button(line_code, diff_file.position(line), line.type) + %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "new", line.old_pos) } } - %td.new_line.diff-line-num{ class: line.type, data: { linenumber: line.new_pos } } - %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "old", line.new_pos) } } + %td.new_line.diff-line-num{ class: line.type, data: { linenumber: line.new_pos } } + %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "old", line.new_pos) } } - %td.line_content{ class: line.type }< - = diff_line_content(line.rich_text) + %td.line_content{ class: line.type }< + = diff_line_content(line.rich_text) - - if line.discussable? && @grouped_diff_discussions.present? && @grouped_diff_discussions[line_code] - - line_discussions = @grouped_diff_discussions[line_code] - = render "discussions/diff_discussion", discussions: line_discussions, expanded: line_discussions.any?(&:expanded?) - - - else - = render partial: "projects/diffs/line", - collection: diff_file.highlighted_diff_lines, - as: :line, - locals: { diff_file: diff_file, discussions: @grouped_diff_discussions } + - if line.discussable? && @grouped_diff_discussions.present? && @grouped_diff_discussions[line_code] + - line_discussions = @grouped_diff_discussions[line_code] + = render "discussions/diff_discussion", discussions: line_discussions, expanded: line_discussions.any?(&:expanded?) - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.highlighted_diff_lines.any? - last_line = diff_file.highlighted_diff_lines.last diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 02a69f25985..a5224db1be9 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -91,7 +91,7 @@ .sub-section.rename-repository %h4.warning-title= _('Change path') = render 'projects/errors' - = form_for @project do |f| + = gitlab_ui_form_for @project do |f| .form-group - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') } %p= _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } @@ -106,8 +106,8 @@ .input-group-prepend .input-group-text #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ - = f.text_field :path, class: 'form-control h-auto', data: { qa_selector: 'project_path_field' } - = f.submit _('Change path'), class: "gl-button btn btn-danger", data: { qa_selector: 'change_path_button' } + = f.text_field :path, class: 'form-control', data: { qa_selector: 'project_path_field' } + = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true = render 'transfer', project: @project diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index a51d1080d96..deb3c33f733 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -43,13 +43,13 @@ :preserve git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} cd #{h @project.path} - git switch -c #{h escaped_default_branch_name} + git switch --create #{h escaped_default_branch_name} touch README.md git add README.md git commit -m "add README" - if @project.can_current_user_push_to_default_branch? %span>< - git push -u origin #{h escaped_default_branch_name } + git push --set-upstream origin #{h escaped_default_branch_name } %h5= _('Push an existing folder') %pre.bg-light @@ -61,7 +61,7 @@ git commit -m "Initial commit" - if @project.can_current_user_push_to_default_branch? %span>< - git push -u origin #{h escaped_default_branch_name } + git push --set-upstream origin #{h escaped_default_branch_name } %h5= _('Push an existing Git repository') %pre.bg-light @@ -71,5 +71,5 @@ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - if @project.can_current_user_push_to_default_branch? %span>< - git push -u origin --all - git push -u origin --tags + git push --set-upstream origin --all + git push --set-upstream origin --tags diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml index 7a275b51c74..c7752a45c63 100644 --- a/app/views/projects/environments/edit.html.haml +++ b/app/views/projects/environments/edit.html.haml @@ -4,4 +4,5 @@ #js-edit-environment{ data: { project_environments_path: project_environments_path(@project), update_environment_path: project_environment_path(@project, @environment), protected_environment_settings_path: (project_settings_ci_cd_path(@project, anchor: 'js-protected-environments-settings') if @project.licensed_feature_available?(:protected_environments)), - environment: environment_data(@environment)} } + project_path: @project.full_path, + environment: environment_data(@environment) } } diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index 11c36b5ea6d..9e8484b88b9 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -3,4 +3,4 @@ - page_title s_("Environments|New Environment") - add_page_specific_style 'page_bundles/environments' -#js-new-environment{ data: { project_environments_path: project_environments_path(@project) } } +#js-new-environment{ data: { project_environments_path: project_environments_path(@project), project_path: @project.full_path, } } diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index 09b0b7a4d9b..6fd5802213a 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,7 +1,7 @@ = render 'shared/alerts/positioning_disabled' if @sort == 'relative_position' %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class } - = render partial: "projects/issues/issue", collection: @issues + = render partial: 'projects/issues/service_desk/issue', collection: @issues - if @issues.blank? - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') = render empty_state_path diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index 981021c97e6..bf23fdc761b 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,4 +1,5 @@ .js-work-item-links-root{ data: { issuable_id: @issue.id, + issuable_iid: @issue.iid, full_path: @project.full_path, wi: work_items_index_data(@project), register_path: new_user_registration_path(redirect_to_referer: 'yes'), diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/service_desk/_issue.html.haml index fc6ef2ea153..04ea6103b83 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/service_desk/_issue.html.haml @@ -23,7 +23,6 @@ #{_('created %{timeAgoString} by %{email} via %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), email: issue.present(current_user: current_user).service_desk_reply_to, user: link_to_member(@project, issue.author, avatar: false) }} - else #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), user: link_to_member(@project, issue.author, avatar: false) }} - = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author - if issue.milestone %span.issuable-milestone.d-none.d-sm-inline-block @@ -44,7 +43,7 @@ - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| = link_to_label(label, small: true) - = render "projects/issues/issue_estimate", issue: issue + = render 'projects/issues/service_desk/issue_estimate', issue: issue .issuable-meta %ul.controls diff --git a/app/views/projects/issues/_issue_estimate.html.haml b/app/views/projects/issues/service_desk/_issue_estimate.html.haml index c49bf626f4e..c49bf626f4e 100644 --- a/app/views/projects/issues/_issue_estimate.html.haml +++ b/app/views/projects/issues/service_desk/_issue_estimate.html.haml diff --git a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml index 1c9143c633d..855625368a9 100644 --- a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml @@ -6,7 +6,7 @@ - if Gitlab::ServiceDesk.supported? .empty-state .svg-content - = render partial: 'shared/empty_states/icons/service_desk_empty_state', formats: :svg + = render partial: 'projects/issues/service_desk/icons/service_desk_empty_state', formats: :svg .text-content %h4= title_text @@ -25,7 +25,7 @@ - else .empty-state .svg-content - = render partial: 'shared/empty_states/icons/service_desk_setup', formats: :svg + = render partial: 'projects/issues/service_desk/icons/service_desk_setup', formats: :svg .text-content - if can_edit_project_settings %h4= s_('ServiceDesk|Service Desk is not supported') diff --git a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml index 2ed5675c0ad..95837748c7f 100644 --- a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml @@ -6,7 +6,7 @@ .media.gl-border-b.gl-pb-3.gl-text-left .svg-content - = render partial: 'shared/empty_states/icons/service_desk_callout', formats: :svg + = render partial: 'projects/issues/service_desk/icons/service_desk_callout', formats: :svg .gl-mt-3.gl-ml-3 %h5= title_text diff --git a/app/views/shared/empty_states/icons/_service_desk_callout.svg b/app/views/projects/issues/service_desk/icons/_service_desk_callout.svg index 2886388279e..2886388279e 100644 --- a/app/views/shared/empty_states/icons/_service_desk_callout.svg +++ b/app/views/projects/issues/service_desk/icons/_service_desk_callout.svg diff --git a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg b/app/views/projects/issues/service_desk/icons/_service_desk_empty_state.svg index 04c4870be07..04c4870be07 100644 --- a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg +++ b/app/views/projects/issues/service_desk/icons/_service_desk_empty_state.svg diff --git a/app/views/shared/empty_states/icons/_service_desk_setup.svg b/app/views/projects/issues/service_desk/icons/_service_desk_setup.svg index bb791b58593..bb791b58593 100644 --- a/app/views/shared/empty_states/icons/_service_desk_setup.svg +++ b/app/views/projects/issues/service_desk/icons/_service_desk_setup.svg diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index ce7006001c7..7a4ae409ee2 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -17,7 +17,7 @@ -# Only show it in the first page - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') .prioritized-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] } - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' } + .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24 = _('Prioritized labels') .gl-font-sm.gl-font-weight-semibold.gl-text-gray-500 @@ -33,7 +33,7 @@ - if @labels.any? .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4 - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' } + .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24{ class: ('hide' if hide) }= _('Other labels') .js-other-labels.gl-px-3.gl-rounded-base.manage-labels-list = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project } 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 9bfa0e7a309..a3536ead240 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,53 +1,15 @@ -- 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 - %span.js-sidebar-header-popover - = button_tag type: 'button', id: "new-actions-header-dropdown", 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') - = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon" - .dropdown-menu.dropdown-menu-right - .gl-dropdown-inner - .gl-dropdown-contents - %ul - - if current_user && moved_mr_sidebar_enabled? - %li.gl-dropdown-item.js-sidebar-subscriptions-widget-root - %li.gl-dropdown-divider - %hr.dropdown-divider - - if can?(current_user, :update_merge_request, @merge_request) - %li.gl-dropdown-item{ class: "gl-md-display-none!" } - = link_to edit_project_merge_request_path(@project, @merge_request), class: 'dropdown-item' do - .gl-dropdown-item-text-wrapper - = _('Edit') - - if @merge_request.open? - %li.gl-dropdown-item - = link_to toggle_draft_merge_request_path(@merge_request), method: :put, class: 'dropdown-item js-draft-toggle-button' do - .gl-dropdown-item-text-wrapper - = @merge_request.draft? ? _('Mark as ready') : _('Mark as draft') - %li.gl-dropdown-item.js-close-item - = link_to close_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do - .gl-dropdown-item-text-wrapper - = _('Close') - = display_issuable_type - - elsif !@merge_request.source_project_missing? && @merge_request.closed? - %li.gl-dropdown-item - = link_to reopen_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do - .gl-dropdown-item-text-wrapper - = _('Reopen') - = display_issuable_type - - if moved_mr_sidebar_enabled? - %li.gl-dropdown-item.js-sidebar-lock-root - %li.gl-dropdown-item - %button.dropdown-item.js-copy-reference{ type: "button", data: { 'clipboard-text': @merge_request.to_reference(full: true) } } - .gl-dropdown-item-text-wrapper - = _('Copy reference') - - - unless current_controller?('conflicts') - - unless issuable_author_is_current_user(@merge_request) - - if moved_mr_sidebar_enabled? - %li.gl-dropdown-divider - %hr.dropdown-divider - .js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @merge_request.author.id, reported_from_url: merge_request_url(@merge_request) } } - -#js-report-abuse-drawer +.js-mr-more-dropdown{ data: { + merge_request: @merge_request.to_json, + project_path: @project.full_path, + edit_url: edit_project_merge_request_path(@project, @merge_request), + is_current_user: issuable_author_is_current_user(@merge_request), + is_logged_in: current_user, + can_update_merge_request: can?(current_user, :update_merge_request, @merge_request), + open: @merge_request.open?, + merged: @merge_request.merged?, + source_project_missing: @merge_request.source_project_missing?, + clipboard_text: @merge_request.to_reference(full: true), + report_abuse_path: add_category_abuse_reports_path, + reported_user_id: @merge_request.author.id, + reported_from_url: merge_request_url(@merge_request), +} } diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 9142893d400..7b815d996e0 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -25,7 +25,6 @@ %span.issuable-authored.d-none.d-sm-inline-block.gl-text-gray-500! · #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(merge_request.created_at, placement: 'bottom'), user: link_to_member(@project, merge_request.author, avatar: false, extra_class: 'gl-text-gray-500!') }} - = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author - if merge_request.milestone %span.issuable-milestone.d-none.d-sm-inline-block.gl-text-truncate.gl-max-w-26.gl-vertical-align-bottom diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 15339becb74..dfa582f4c60 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -26,7 +26,7 @@ .detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex - if can_update_merge_request - = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_button" }}) do + = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_title_button" }}) do = _('Edit') - if @merge_request.source_project diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 3e56148f777..5ea67376a86 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -16,7 +16,7 @@ - add_page_specific_style 'page_bundles/ci_status' - add_page_startup_api_call @endpoint_metadata_url -- if mr_action == 'diffs' && (!@file_by_file_default || !single_file_file_by_file?) +- if mr_action == 'diffs' && !@file_by_file_default - 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 } } diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 0570d22529b..07bae4d2396 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -1,6 +1,16 @@ %h1.page-title.gl-font-size-h-display = _('New merge request') +- if @saml_groups.present? + = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false) do |c| + - c.with_body do + = s_('GroupSAML|Some branches are inaccessible because your SAML session has expired. To access the branches, select the group’s path to reauthenticate.') + - c.with_actions do + .gl-display-flex.gl-flex-wrap + - @saml_groups.each do |group| + = render Pajamas::ButtonComponent.new(href: sso_group_saml_providers_path(group, { token: group.saml_discovery_token, redirect: project_new_merge_request_branch_from_path(@source_project) }), button_options: { class: "gl-mr-3 gl-mb-3" }) do + = group.path + = gitlab_ui_form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| - if params[:nav_source].present? = hidden_field_tag(:nav_source, params[:nav_source]) 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 35e8b30e6e9..bec7cb3fd34 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -15,8 +15,10 @@ .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) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll 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 @@ -32,8 +34,10 @@ .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) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll 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 diff --git a/app/views/projects/mirrors/_branch_filter.html.haml b/app/views/projects/mirrors/_branch_filter.html.haml index b9db9898d49..7d90906bfe8 100644 --- a/app/views/projects/mirrors/_branch_filter.html.haml +++ b/app/views/projects/mirrors/_branch_filter.html.haml @@ -1,6 +1,9 @@ -.form-check.gl-mb-3 - = check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input' - = label_tag :only_protected_branches, _('Mirror only protected branches'), class: 'form-check-label' - .form-text.text-muted - = _('If enabled, only protected branches will be mirrored.') - = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' +.form-group + = render Pajamas::CheckboxTagComponent.new(name: :only_protected_branches, + checkbox_options: { class: 'js-mirror-protected' }, + label_options: { class: 'gl-mb-0!' }) do |c| + - c.with_label do + = _('Mirror only protected branches') + - c.with_help_text do + = _('If enabled, only protected branches will be mirrored.') + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index 136f504084e..5b02d650989 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -8,9 +8,12 @@ = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden' = render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f } = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f } - .form-check.gl-mb-3 - = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input' - = label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label' - .form-text.text-muted - - link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe } + .form-group + = render Pajamas::CheckboxTagComponent.new(name: :keep_divergent_refs, + checkbox_options: { class: 'js-mirror-keep-divergent-refs' }, + label_options: { class: 'gl-mb-0!' }) do |c| + - c.with_label do + = _('Keep divergent refs') + - c.with_help_text do + - link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe + = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe } diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index c8d4f02274b..c4630eec168 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -8,11 +8,10 @@ = form_tag network_path, method: :get, class: 'form-inline network-form' do |f| = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control gl-form-input input-mx-250 search-sha gl-mr-2' = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'search') - .inline.gl-ml-5 - .form-check.light - = check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input' - = label_tag :filter_ref, class: 'form-check-label' do - %span= _("Begin with the selected commit") + .form-group{ class: 'gl-ml-5 gl-mb-n3!' } + = render Pajamas::CheckboxTagComponent.new(name: :filter_ref, checked: @options[:filter_ref]) do |c| + - c.with_label do + = _("Begin with the selected commit") - if @commit .network-graph.gl-bg-white.gl-overflow-scroll.gl-overflow-x-hidden{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } } diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb index 93b3c9911e2..7b5119d92e4 100644 --- a/app/views/projects/network/show.json.erb +++ b/app/views/projects/network/show.json.erb @@ -9,7 +9,7 @@ author: { name: c.author_name, email: c.author_email, - icon: image_path(avatar_icon_for_email(c.author_email, 20)) + icon: image_path(avatar_icon_for_email(c.author_email, 20, by_commit_email: true)) }, time: c.time, space: c.spaces.first, diff --git a/app/views/projects/pages/_waiting.html.haml b/app/views/projects/pages/_waiting.html.haml index e8acadbabe3..0613ffc4809 100644 --- a/app/views/projects/pages/_waiting.html.haml +++ b/app/views/projects/pages/_waiting.html.haml @@ -1,7 +1,7 @@ .empty-state .row.gl-align-items-center.gl-justify-content-center .order-md-2 - = image_tag 'illustrations/pipelines_pending.svg' + = image_tag 'illustrations/empty-state/empty-pipeline-md.svg' .row.gl-align-items-center.gl-justify-content-center .text-content.gl-text-center.order-md-1 %h4= s_("GitLabPages|Waiting for the Pages Pipeline to complete...") diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 89e64d607a6..b8de364babc 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -2,15 +2,6 @@ - breadcrumb_title domain_presenter.domain - page_title domain_presenter.domain -- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - -- if verification_enabled && domain_presenter.unverified? - = content_for :flash_message do - = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false) do |c| - - c.with_body do - .container-fluid.container-limited - = _("This domain is not verified. You will need to verify ownership before access is enabled.") - %h1.page-title.gl-font-size-h-display = _('Pages Domain') = render 'projects/pages_domains/helper_text' diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 3ff370dfaa4..753bb77e755 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -16,7 +16,7 @@ .icon-container = sprite_icon('clock', css_class: 'gl-top-0!') = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size - = @pipeline.ref_text + = @pipeline.ref_text_legacy - if @pipeline.finished_at - duration = time_interval_in_words(@pipeline.duration) - queued_duration = time_interval_in_words(@pipeline.queued_duration) diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index a7d670f8475..46e1cd07a17 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -9,11 +9,14 @@ - add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid }) .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } - #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } + - if Feature.enabled?(:pipeline_details_header_vue, @project) + #js-pipeline-details-header-vue{ data: js_pipeline_details_header_data(@project, @pipeline) } + - else + #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } = render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline - - if @pipeline.commit.present? + - if @pipeline.commit.present? && !Feature.enabled?(:pipeline_details_header_vue, @project) = render "projects/pipelines/info", commit: @pipeline.commit - if pipeline_has_errors diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index a0a90fbe204..6b6aaaad802 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -15,7 +15,10 @@ - invite_group_top_margin = '' - if can_admin_project_member?(@project) .js-import-project-members-trigger{ data: { classes: 'gl-md-w-auto gl-w-full' } } - .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name, reload_page_on_submit: true.to_s } } + .js-import-project-members-modal{ data: { project_id: @project.id, + project_name: @project.name, + reload_page_on_submit: true.to_s, + users_limit_dataset: common_invite_modal_dataset(@project)[:users_limit_dataset] } } - invite_group_top_margin = 'gl-md-mt-0 gl-mt-3' - if @project.allowed_to_share_with_group? .js-invite-group-trigger{ data: { classes: "gl-md-w-auto gl-w-full gl-md-ml-3 #{invite_group_top_margin}", display_text: _('Invite a group') } } diff --git a/app/views/projects/readme_templates/default.md.tt b/app/views/projects/readme_templates/default.md.tt index ad1d6cce08d..779b87336ea 100644 --- a/app/views/projects/readme_templates/default.md.tt +++ b/app/views/projects/readme_templates/default.md.tt @@ -31,7 +31,7 @@ git push -uf origin <%= params[:default_branch] %> - [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) - [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) - [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) +- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) ## Test and Deploy diff --git a/app/views/projects/runners/_project_runners.html.haml b/app/views/projects/runners/_project_runners.html.haml index 1d4e45c71b5..af8f39ce0ad 100644 --- a/app/views/projects/runners/_project_runners.html.haml +++ b/app/views/projects/runners/_project_runners.html.haml @@ -7,7 +7,8 @@ - if can?(current_user, :create_runner, @project) = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do = s_('Runners|New project runner') - #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } } + .gl-display-inline + #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } } - else = _('Please contact an admin to create runners.') = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/settings/access_tokens/_form.html.haml b/app/views/projects/settings/access_tokens/_form.html.haml new file mode 100644 index 00000000000..919462a0f62 --- /dev/null +++ b/app/views/projects/settings/access_tokens/_form.html.haml @@ -0,0 +1,14 @@ +- type = local_assigns.fetch(:type) + += render 'shared/access_tokens/form', + ajax: true, + type: type, + path: project_settings_access_tokens_path(@project), + resource: @project, + token: @resource_access_token, + scopes: @scopes, + access_levels: ProjectMember.permissible_access_level_roles(current_user, @project), + default_access_level: Gitlab::Access::GUEST, + prefix: :resource_access_token, + description_prefix: :project_access_token, + help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token') diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 26c08fcdfe4..df517b5d642 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -27,18 +27,8 @@ #js-new-access-token-app{ data: { access_token_type: type } } - if current_user.can?(:create_resource_access_tokens, @project) - = render 'shared/access_tokens/form', - ajax: true, - type: type, - path: project_settings_access_tokens_path(@project), - resource: @project, - token: @resource_access_token, - scopes: @scopes, - access_levels: ProjectMember.permissible_access_level_roles(current_user, @project), - default_access_level: Gitlab::Access::GUEST, - prefix: :resource_access_token, - description_prefix: :project_access_token, - help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token') + = render_if_exists 'projects/settings/access_tokens/form', + type: type #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } } diff --git a/app/views/projects/settings/operations/_grafana_integration.html.haml b/app/views/projects/settings/operations/_grafana_integration.html.haml deleted file mode 100644 index 69e42a6c4fb..00000000000 --- a/app/views/projects/settings/operations/_grafana_integration.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.js-grafana-integration{ data: { operations_settings_endpoint: project_settings_operations_path(@project), - grafana_integration: { url: grafana_integration_url, token: grafana_integration_masked_token, enabled: grafana_integration_enabled?.to_s } } } diff --git a/app/views/projects/settings/operations/_metrics_dashboard.html.haml b/app/views/projects/settings/operations/_metrics_dashboard.html.haml deleted file mode 100644 index 056d3e8102b..00000000000 --- a/app/views/projects/settings/operations/_metrics_dashboard.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project), - help_page: help_page_path('operations/metrics/dashboards/settings'), - external_dashboard: { url: metrics_external_dashboard_url, - help_page: help_page_path('operations/metrics/dashboards/settings') }, - dashboard_timezone: { setting: metrics_dashboard_timezone.upcase } } } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index d44ebf1eb83..93ab98c1472 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -2,14 +2,7 @@ - breadcrumb_title _('Monitor Settings') - @force_desktop_expanded_sidebar = true -- if Feature.disabled?(:remove_monitor_metrics) - = render 'projects/settings/operations/metrics_dashboard' - = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/alert_management' = render 'projects/settings/operations/incidents' - -- if Feature.disabled?(:remove_monitor_metrics) - = render 'projects/settings/operations/grafana_integration' - = render_if_exists 'projects/settings/operations/status_page' diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 12404180362..36ace52df13 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -4,8 +4,7 @@ - @force_desktop_expanded_sidebar = true = render "projects/branch_defaults/show" -- if Feature.enabled?(:branch_rules, @project) - = render "projects/branch_rules/show" += render "projects/branch_rules/show" = render_if_exists "projects/push_rules/index" = render "projects/mirrors/mirror_repos" diff --git a/app/views/projects/settings/slacks/edit.html.haml b/app/views/projects/settings/slacks/edit.html.haml new file mode 100644 index 00000000000..867b90655e3 --- /dev/null +++ b/app/views/projects/settings/slacks/edit.html.haml @@ -0,0 +1,20 @@ +- page_title _('Edit Slack integration') + +.row.gl-mt-3.gl-mb-3 + .col-lg-3 + %h4.gl-mt-0 + = s_('Integrations|Edit project alias') + + %p= s_('Integrations|You can use this alias in your Slack commands') + .col-lg-9 + = form_errors(@slack_integration) + = form_for(@slack_integration, url: project_settings_slack_path(@project), method: :put, html: { class: 'gl-show-field-errors js-integration-settings-form'}) do |form| + .form-group.row + = form.label :alias, s_('Integrations|Enter your alias'), class: 'col-form-label' + .col-sm-10 + = form.text_field :alias, class: 'form-control', placeholder: @slack_integration.alias, required: true + + .footer-block.row-content-block + = form.submit _('Save changes'), pajamas_button: true + + = link_to _('Cancel'), edit_project_settings_integration_path(@project, @service), class: 'btn gl-button btn-cancel' diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 1df323e7451..53c3d16ee64 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -18,17 +18,17 @@ = form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "common-note-form tag-form js-quick-submit js-requires-input" do .form-group.row .col-sm-12 - = label_tag :tag_name, nil + = label_tag :tag_name, _('Tag name') = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" } .form-group.row .col-sm-auto.create-from - = label_tag :ref, 'Create from' + = label_tag :ref, _('Create from') .js-new-tag-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } } .form-text.text-muted = s_('TagsPage|Existing branch name, tag, or commit SHA') .form-group.row .col-sm-12 - = label_tag :message, nil + = label_tag :message, _('Message') = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" } .form-text.text-muted = tag_description_help_text diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 3124f47c832..5127972c406 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -1,6 +1,6 @@ - user = user_email = nil -- if @tag.tagger - - user_email = @tag.tagger.email +- if @tag.user_email + - user_email = @tag.user_email - user = User.find_by_any_email(user_email) - add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project) - breadcrumb_title @tag.name diff --git a/app/views/protected_branches/_create_protected_branch.html.haml b/app/views/protected_branches/_create_protected_branch.html.haml index b4765ab49c2..799f6aa6031 100644 --- a/app/views/protected_branches/_create_protected_branch.html.haml +++ b/app/views/protected_branches/_create_protected_branch.html.haml @@ -3,12 +3,12 @@ = dropdown_tag(_('Select'), options: { toggle_class: 'js-allowed-to-merge wide', dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown', - data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'allowed_to_merge_dropdown' }}) + data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'select_allowed_to_merge_dropdown' }}) - content_for :push_access_levels do .push_access_levels-container = dropdown_tag(_('Select'), options: { toggle_class: "js-allowed-to-push js-multiselect wide", dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown', - data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }}) + data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'select_allowed_to_push_dropdown' }}) = render 'protected_branches/shared/create_protected_branch', 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 9bc224b2e78..d97347b89de 100644 --- a/app/views/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -1,9 +1,9 @@ = gitlab_ui_form_for [protected_branch_entity, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } = render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c| - - c.header do + - c.with_header do = s_("ProtectedBranch|Protect a branch") - - c.body do + - c.with_body do = form_errors(@protected_branch) .form-group.row = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' @@ -13,7 +13,7 @@ - else = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } .form-text.text-muted - - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard') + - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules') - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' } - if protected_branch_entity.is_a?(Group) @@ -38,7 +38,7 @@ - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity - - c.footer do + - c.with_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 6c8ab5654a0..986bc53fd81 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -11,7 +11,6 @@ .row.gl-flex-grow-1 .d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-px-5.gl-pb-5 .edit-profile.login-page.d-flex.flex-column.gl-align-items-center - = render_if_exists "registrations/welcome/progress_bar" %h2.gl-text-center= html_escape(_('Welcome to GitLab,%{br_tag}%{name}!')) % { name: html_escape(current_user.first_name), br_tag: '<br/>'.html_safe } - if Gitlab.com? %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. We won\'t share this information with anyone.')) % { gitlab_experience_text: gitlab_experience_text } @@ -23,7 +22,7 @@ 'aria-live' => 'assertive', data: { testid: 'welcome-form' } }) do |f| = render Pajamas::CardComponent.new do |c| - - c.body do + - c.with_body do .devise-errors = render 'devise/shared/error_messages', resource: current_user .row diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 99558f61b25..7399f51d7f8 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,5 +1,3 @@ -= render_if_exists 'shared/promotions/promote_advanced_search' - .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 fcbf0ba4452..ff79f003e7d 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -1,3 +1,6 @@ +.advanced-search-promote + = render_if_exists 'shared/promotions/promote_advanced_search' + - if @timeout = render partial: "search/results/timeout" - elsif @search_results.respond_to?(:failed?) && @search_results.failed? diff --git a/app/views/shared/_alert_info.html.haml b/app/views/shared/_alert_info.html.haml index 30dfc87b9bf..e6dbcfbff1c 100644 --- a/app/views/shared/_alert_info.html.haml +++ b/app/views/shared/_alert_info.html.haml @@ -1,3 +1,3 @@ = render Pajamas::AlertComponent.new(variant: :info, dismissible: true) do |c| - = c.body do + - c.with_body do = body diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index c468b3a2001..6d8d4f4cab9 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -6,7 +6,7 @@ svg_path: 'illustrations/autodevops.svg', banner_options: { class: 'js-autodevops-banner auto-devops-callout', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }, close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c| - - c.title do + - c.with_title do = s_('AutoDevOps|Auto DevOps') %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index ac7d56520f7..f4af3ea70d4 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -2,11 +2,11 @@ = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner', data: { qa_selector: 'auto_devops_banner_content' } }, close_button_options: { class: 'hide-auto-devops-implicitly-enabled-banner', data: { project_id: project.id }}) do |c| - = c.body do + - c.with_body do = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found.") - unless Gitlab.config.registry.enabled %div = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.') - = c.actions do + - c.with_actions do = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-confirm' = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-3' diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml index a2fed883739..2f470d5ef53 100644 --- a/app/views/shared/_broadcast_message.html.haml +++ b/app/views/shared/_broadcast_message.html.haml @@ -3,7 +3,8 @@ - preview = local_assigns.fetch(:preview, false) - unless message.notification? - .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert", class: "js-broadcast-notification-#{message.id} #{message.theme}" } + .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert", + class: "js-broadcast-notification-#{message.id} #{message.theme}", data: { testid: 'banner-broadcast-message' } } .gl-broadcast-message-content .gl-broadcast-message-icon = sprite_icon(icon_name) @@ -19,21 +20,22 @@ icon: 'close', size: :small, button_options: { class: 'gl-close-btn-color-inherit gl-broadcast-message-dismiss js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601 } }, - icon_classes: 'gl-mx-3! gl-text-white') + icon_classes: 'gl-text-white') - else - notification_class = "js-broadcast-notification-#{message.id}" - notification_class << ' preview' if preview - .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } } - = sprite_icon(icon_name, css_class: 'vertical-align-text-top') - - if message.message.present? - %h2.gl-sr-only - = s_("Admin message") - = render_broadcast_message(message) - - else - = yield + .gl-broadcast-message.broadcast-notification-message.gl-mt-3{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } } + .gl-broadcast-message-content + .gl-broadcast-message-icon + = sprite_icon(icon_name, css_class: 'vertical-align-text-top') + - if message.message.present? + %h2.gl-sr-only + = s_("Admin message") + = render_broadcast_message(message) + - else + = yield - if !preview - = render Pajamas::ButtonComponent.new(variant: :link, + = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'close', size: :small, - button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } }, - icon_classes: 'gl-mx-3! gl-text-gray-700') + button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } }) diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml index e3f2e1aa436..657fef1f74d 100644 --- a/app/views/shared/_choose_avatar_button.html.haml +++ b/app/views/shared/_choose_avatar_button.html.haml @@ -1 +1 @@ -= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("Max file size is 200 KB.") += render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("Max file size is 200 KiB.") diff --git a/app/views/shared/_custom_attributes.html.haml b/app/views/shared/_custom_attributes.html.haml index 6e5f1cb063c..be96e77dbd4 100644 --- a/app/views/shared/_custom_attributes.html.haml +++ b/app/views/shared/_custom_attributes.html.haml @@ -1,9 +1,9 @@ - return unless custom_attributes.present? = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c| - - c.header do + - c.with_header do = link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md')) - - c.body do + - c.with_body do %ul.content-list - custom_attributes.each do |custom_attribute| %li diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml index 03534bf78d1..82b4a314b59 100644 --- a/app/views/shared/_event_filter.html.haml +++ b/app/views/shared/_event_filter.html.haml @@ -1,8 +1,10 @@ - show_group_events = local_assigns.fetch(:show_group_events, false) .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } + = sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.event-filter.scrolling-tabs.nav.nav-tabs = event_filter_link EventFilter::ALL, _('All'), s_('EventFilterBy|Filter by all') - if event_filter_visible(:repository) diff --git a/app/views/shared/_ide_root.html.haml b/app/views/shared/_ide_root.html.haml index 848ff1e5728..db3e76e188c 100644 --- a/app/views/shared/_ide_root.html.haml +++ b/app/views/shared/_ide_root.html.haml @@ -3,9 +3,8 @@ -# Fix for iOS 13+, the height of the page is actually less than -# 100vh because of the presence of the bottom bar -- @body_class = 'gl-max-h-full gl-fixed' -#ide.gl--flex-center.gl-h-full{ data: data } - .gl-text-center - = gl_loading_icon(size: 'md') - %h2.clgray= loading_text +#ide.gl-h-full{ data: data } + .web-ide-loader.gl-display-flex.gl-justify-content-center.gl-align-items-center.gl-flex-direction-column.gl-h-full.gl-mr-auto.gl-ml-auto + = brand_header_logo + %h3.clblack.gl-mt-6= loading_text diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index d10f514dc58..668ac908703 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -25,7 +25,7 @@ alert_options: { class: 'gl-mt-3 js-import-url-error hide' }, dismissible: false, close_button_options: { class: 'js-close-2fa-enabled-success-alert' }) do |c| - = c.body do + - c.with_body do = s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.') = render_if_exists 'shared/ee/import_form', f: f, ci_cd_only: ci_cd_only .row diff --git a/app/views/shared/_model_version_conflict.html.haml b/app/views/shared/_model_version_conflict.html.haml index 134dcf8db7f..8ab821c0435 100644 --- a/app/views/shared/_model_version_conflict.html.haml +++ b/app/views/shared/_model_version_conflict.html.haml @@ -1,6 +1,6 @@ = render Pajamas::AlertComponent.new(variant: :danger, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - = c.body do + - c.with_body do - link_to_model = link_to(model_name, link_path, target: '_blank', rel: 'noopener noreferrer') = _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs.").html_safe % { model_name: model_name, link_to_model: link_to_model } diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml index 6bc6d0943c9..fb3dfba2691 100644 --- a/app/views/shared/_new_merge_request_checkbox.html.haml +++ b/app/views/shared/_new_merge_request_checkbox.html.haml @@ -1,8 +1,9 @@ -.form-check.gl-mt-3 +.form-group.gl-mt-3 - nonce = SecureRandom.hex - = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request form-check-input', id: "create_merge_request-#{nonce}" - = label_tag "create_merge_request-#{nonce}", class: 'form-check-label' do - - translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" } - - translation = _('Start a %{new_merge_request} with these changes') % translation_variables - #{ translation.html_safe } - + = render Pajamas::CheckboxTagComponent.new(name: 'create_merge_request', + checked: true, + checkbox_options: { class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" }) do |c| + - c.with_label do + - translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" } + - translation = _('Start a %{new_merge_request} with these changes') % translation_variables + #{ translation.html_safe } diff --git a/app/views/shared/_new_nav_announcement.html.haml b/app/views/shared/_new_nav_announcement.html.haml new file mode 100644 index 00000000000..8cabab09ec2 --- /dev/null +++ b/app/views/shared/_new_nav_announcement.html.haml @@ -0,0 +1,33 @@ +- return unless show_new_navigation_callout? + +- changes_url = 'https://gitlab.com/groups/gitlab-org/-/epics/9044#whats-different' +- vision_url = 'https://about.gitlab.com/blog/2023/05/01/gitlab-product-navigation/' +- design_url = 'https://about.gitlab.com/blog/2023/05/15/overhauling-the-navigation-is-like-building-a-dream-home/' +- feedback_url = 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005' +- docs_url = help_page_path('tutorials/left_sidebar/index') + +- changes_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: changes_url } +- vision_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: vision_url } +- design_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: design_url } +- link_end = '</a>'.html_safe + +- welcome_text = _('For the next few releases, you can go to your avatar at any time to turn the new navigation on and off.') +- cta_text = _('Read more about the %{changes_link_start}changes%{link_end}, the %{vision_link_start}vision%{link_end}, and the %{design_link_start}design%{link_end}.' % { changes_link_start: changes_link_start, + vision_link_start: vision_link_start, + design_link_start: design_link_start, + link_end: link_end}).html_safe # rubocop:disable Gettext/StaticIdentifier + += render Pajamas::AlertComponent.new(dismissible: true, title: _('Welcome to a new navigation experience'), + alert_options: { class: 'js-new-navigation-callout', data: { feature_id: "new_navigation_callout", dismiss_endpoint: callouts_path }}) do |c| + - c.with_body do + %p + = welcome_text + = cta_text + - c.with_actions do + = render Pajamas::ButtonComponent.new(variant: :confirm, + href: docs_url, + button_options: { class: 'gl-alert-action', data: { track_action: 'click_button', track_label: 'banner_nav_learn_more' } }) do |c| + = _('Learn more') + = render Pajamas::ButtonComponent.new(href: feedback_url, + button_options: { data: { track_action: 'click_button', track_label: 'banner_nav_provide_feedback' } }) do |c| + = _('Provide feedback') diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index 76830230cf6..e0d385024cd 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -2,8 +2,8 @@ = render Pajamas::AlertComponent.new(variant: :warning, alert_options: { class: 'js-no-password-message' }, close_button_options: { class: 'js-hide-no-password-message' }) do |c| - = c.body do + - c.with_body do = no_password_message - = c.actions do + - c.with_actions do = link_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action btn btn-confirm btn-md gl-button' = link_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index be1df54a432..e9c0858e090 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -2,8 +2,8 @@ = render Pajamas::AlertComponent.new(variant: :warning, alert_options: { class: 'js-no-ssh-message' }, close_button_options: { class: 'js-hide-no-ssh-message'}) do |c| - = c.body do + - c.with_body do = s_("MissingSSHKeyWarningLink|You can't push or pull repositories using SSH until you add an SSH key to your profile.") - = c.actions do + - c.with_actions do = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "gl-alert-action btn btn-confirm btn-md gl-button" = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button' diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml index 0af378cb883..79d0231536b 100644 --- a/app/views/shared/_outdated_browser.html.haml +++ b/app/views/shared/_outdated_browser.html.haml @@ -1,6 +1,6 @@ - if outdated_browser? = render Pajamas::AlertComponent.new(variant: :danger, dismissible: false) do |c| - = c.body do + - c.with_body do = s_('OutdatedBrowser|GitLab may not work properly, because you are using an outdated web browser.') %br - browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('install/requirements', anchor: 'supported-web-browsers') } diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml index 60be03c6631..ce49193e27b 100644 --- a/app/views/shared/_project_limit.html.haml +++ b/app/views/shared/_project_limit.html.haml @@ -2,8 +2,8 @@ = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'project-limit-message' }) do |c| - = c.body do + - c.with_body do = _("You won't be able to create new projects because you have reached your project limit.") - = c.actions do + - c.with_actions do = link_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message btn gl-button btn-confirm' = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link btn gl-button btn-default gl-ml-3' diff --git a/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml b/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml index 9fe1e3087f6..0d084a99528 100644 --- a/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml +++ b/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml @@ -3,7 +3,7 @@ .row .form-group.col-md-9 = form.label :disabled_repository_size_limit, class: 'label-bold' do - = _('Repository size limit (MB)') + = _('Repository size limit (MiB)') = form.number_field :disabled_repository_size_limit, value: '', class: 'form-control', disabled: true %span.form-text.text-muted = render 'shared/registration_features_discovery_message' diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml index 700ffa7aa12..108d846e3ee 100644 --- a/app/views/shared/_service_ping_consent.html.haml +++ b/app/views/shared/_service_ping_consent.html.haml @@ -1,10 +1,10 @@ - if session[:ask_for_usage_stats_consent] = render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c| - = c.body do + - c.with_body do - docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link' - settings_link = link_to _('your settings'), metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link' = s_('To help improve GitLab, we would like to periodically %{docs_link}. This can be changed at any time in %{settings_link}.').html_safe % { docs_link: docs_link, settings_link: settings_link } - = c.actions do + - c.with_actions do - send_service_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 }) - not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 }) = link_to _("Send service data"), send_service_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link btn gl-button btn-confirm' diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml index ff4b2de2286..290152d5803 100644 --- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml +++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml @@ -4,9 +4,9 @@ dismiss_endpoint: callouts_path, defer_links: 'true' }}, close_button_options: { data: { testid: 'close-account-recovery-regular-check-callout' }}) do |c| - = c.body do + - c.with_body do = s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.') = link_to _('Learn more.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'recovery-codes'), target: '_blank', rel: 'noopener noreferrer' - = c.actions do + - c.with_actions do = link_to profile_two_factor_auth_path, class: 'deferred-link btn gl-alert-action btn-confirm btn-md gl-button' do = s_('Profiles|Manage two-factor authentication') diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml index aeaccdfa54b..803f6f9efce 100644 --- a/app/views/shared/_web_ide_button.html.haml +++ b/app/views/shared/_web_ide_button.html.haml @@ -1,5 +1,5 @@ - type = blob ? 'blob' : 'tree' - button_data = web_ide_button_data({ blob: blob }) -- fork_options = fork_modal_options(@project, @ref, @path, blob) +- fork_options = fork_modal_options(@project, blob) .gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json, web_ide_promo_popover_img: image_path('web-ide-promo-popover.svg') }, id: "js-#{type}-web-ide-link" } diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index eada58091b7..ac359d37c49 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -1,5 +1,5 @@ - ajax = local_assigns.fetch(:ajax, false) -- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type }) +- title = local_assigns.fetch(:title, s_('AccessTokens|Add a %{type}') % { type: type }) - prefix = local_assigns.fetch(:prefix, :personal_access_token) - description_prefix = local_assigns.fetch(:description_prefix, prefix) - help_path = local_assigns.fetch(:help_path) @@ -10,7 +10,7 @@ %h5.gl-mt-0 = title %p.profile-settings-content - = _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type } + = s_("AccessTokens|Enter the name of your application, and we'll return a unique %{type}.") % { type: type } = gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f| @@ -19,11 +19,11 @@ .row .form-group.col .row - = f.label :name, _('Token name'), class: 'label-bold col-md-12' + = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold col-md-12' .col-md-6 - resource_type = resource.is_a?(Group) ? "group" : "project" = f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text' - %span.form-text.text-muted.col-md-12#access_token_help_text= _("For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type } + %span.form-text.text-muted.col-md-12#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type } .row .col @@ -33,18 +33,18 @@ - if resource .row .form-group.col-md-6 - = label_tag :access_level, _("Select a role"), class: "label-bold" + = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold" .select-wrapper = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control", data: { qa_selector: 'access_token_access_level' } = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") .form-group %b{ :'aria-describedby' => 'select_scope_help_text' } - = s_('Tokens|Select scopes') + = s_('AccessTokens|Select scopes') %p.text-secondary#select_scope_help_text - = s_('Tokens|Scopes set the permission levels granted to the token.') + = s_('AccessTokens|Scopes set the permission levels granted to the token.') = link_to _("Learn more."), help_path, target: '_blank', rel: 'noopener noreferrer' = render 'shared/tokens/scopes_form', prefix: prefix, description_prefix: description_prefix, token: token, scopes: scopes, f: f .gl-mt-3 - = f.submit _('Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true + = f.submit s_('AccessTokens|Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml index 2bf6baaf608..77854439bbb 100644 --- a/app/views/shared/admin/_admin_note.html.haml +++ b/app/views/shared/admin/_admin_note.html.haml @@ -1,7 +1,7 @@ - if @group.admin_note&.note? - text = @group.admin_note.note = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500 gl-mb-5' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c| - - c.header do + - c.with_header do = s_('Admin|Admin notes') - - c.body do + - c.with_body do %p= text diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 1ab9e288a9e..387a83873b5 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -8,8 +8,8 @@ .row.empty-state .col-12 - .svg-content - = image_tag 'illustrations/issues.svg' + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-issues-md.svg' .col-12 .text-content - if has_filter_bar_param? diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml index ba3293a3f75..9b60071cb91 100644 --- a/app/views/shared/errors/_gitaly_unavailable.html.haml +++ b/app/views/shared/errors/_gitaly_unavailable.html.haml @@ -2,5 +2,5 @@ variant: :danger, dismissible: false, title: reason) do |c| - = c.body do + - c.with_body do = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.') diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index 16e89463a4b..ba968c6b2d2 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -12,9 +12,9 @@ .col-lg-8.gl-mb-3 - if file_hooks.any? = render Pajamas::CardComponent.new do |c| - - c.header do + - c.with_header do = _('File Hooks (%{count})') % { count: file_hooks.count } - - c.body do + - c.with_body do %ul.content-list - file_hooks.each do |file| %li @@ -22,5 +22,5 @@ = File.basename(file) - else = render Pajamas::CardComponent.new do |c| - - c.body do + - c.with_body do .nothing-here-block= _('No file hooks found.') diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml index 8b5b4b6e5fa..1971c2da913 100644 --- a/app/views/shared/hook_logs/_content.html.haml +++ b/app/views/shared/hook_logs/_content.html.haml @@ -13,7 +13,7 @@ = render Pajamas::AlertComponent.new(title: _('Internal error occurred while delivering this webhook.'), variant: :danger, dismissible: false) do |c| - = c.body do + - c.with_body do = _('Error: %{error}') % { error: hook_log.internal_error_message } %h4= _('Response') @@ -41,6 +41,3 @@ - hook_log.request_headers.each do |k, v| <span class="gl-font-weight-bold">#{k}:</span> #{v} %br - - - diff --git a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml index de4439a8fde..c77cc687e4f 100644 --- a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml +++ b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml @@ -3,7 +3,7 @@ variant: :warning, dismissible: false, alert_options: { class: 'gl-mt-5', data: { testid: "slack-notifications-deprecation" } }) do |c| - = c.body do + - c.with_body do - help_page_link = help_page_url('user/project/integrations/gitlab_slack_application') - learn_more_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_link } @@ -13,7 +13,7 @@ variant: :warning, dismissible: false, alert_options: { class: 'gl-mt-5', data: { testid: "slack-notifications-deprecation" } }) do |c| - = c.body do + - c.with_body do - help_page_link = help_page_url('user/project/integrations/gitlab_slack_application') - learn_more_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_link } diff --git a/app/views/shared/integrations/gitlab_slack_application/_help.html.haml b/app/views/shared/integrations/gitlab_slack_application/_help.html.haml new file mode 100644 index 00000000000..0956f1183cb --- /dev/null +++ b/app/views/shared/integrations/gitlab_slack_application/_help.html.haml @@ -0,0 +1,10 @@ +.info-well + .well-segment + %p + = s_("SlackIntegration|This integration allows users to perform common operations on this project by entering slash commands in Slack.") + = link_to _('Learn more'), help_page_path('user/project/integrations/gitlab_slack_application.md') + %p + = s_("SlackIntegration|See the list of available commands in Slack after setting up this integration by entering") + %kbd.inline /gitlab help +- if integration.project_level? + = render "shared/integrations/#{integration.to_param}/slack_integration_form", integration: integration diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml new file mode 100644 index 00000000000..b22a6eeca90 --- /dev/null +++ b/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml @@ -0,0 +1,4 @@ += link_to add_to_slack_link(project, slack_app_id), class: 'btn btn-default gl-button gl-pr-6!' do + = image_tag 'illustrations/slack_logo.svg', class: 'gl-icon gl-button-icon gl-w-9! gl-h-9! gl-my-n3! gl-mr-0!' + %strong.gl-button-text + = label diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml new file mode 100644 index 00000000000..5c9f77f8c12 --- /dev/null +++ b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml @@ -0,0 +1,32 @@ +- slack_integration = integration.slack_integration +- if slack_integration + %table.gl-table.gl-w-full + %colgroup + %col{ width: "25%" } + %col{ width: "35%" } + %col{ width: "20%" } + %col + %thead + %tr + %th= s_('SlackIntegration|Team name') + %th= s_('SlackIntegration|Project alias') + %th= _('Created') + %th + %tr + %td{ class: 'gl-py-3!' } + = slack_integration.team_name + %td{ class: 'gl-py-3!' } + = slack_integration.alias + %td{ class: 'gl-py-3!' } + = time_ago_with_tooltip(slack_integration.created_at) + %td{ class: 'gl-py-3!' } + .controls + - project = integration.project + = link_to _('Edit'), edit_project_settings_slack_path(project), class: 'btn gl-button btn-default' + = link_to sprite_icon('remove', css_class: 'gl-icon'), project_settings_slack_path(project), method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary', aria: { label: s_('SlackIntegration|Remove project') }, data: { confirm_btn_variant: "danger", confirm: s_('SlackIntegration|Are you sure you want to remove this project from the GitLab for Slack app?') } + .gl-my-5 + = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Reinstall GitLab for Slack app') + %p + = html_escape(s_('SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}.')) % { linkStart: %(<a href="#{help_page_path('user/project/integrations/gitlab_slack_application.md', anchor: 'update-the-gitlab-for-slack-app')}">).html_safe, linkEnd: '</a>'.html_safe} +- else + = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Install GitLab for Slack app') diff --git a/app/views/shared/integrations/gitlab_slack_application/_top.html.haml b/app/views/shared/integrations/gitlab_slack_application/_top.html.haml new file mode 100644 index 00000000000..56200deac6d --- /dev/null +++ b/app/views/shared/integrations/gitlab_slack_application/_top.html.haml @@ -0,0 +1,5 @@ +- if session.delete(:slack_install_success) + = render Pajamas::AlertComponent.new(title: s_('SlackIntegration|GitLab for Slack was successfully installed.'), + variant: :success) do |c| + - c.with_body do + = s_('SlackIntegration|You can now close this window and go to your Slack workspace.') diff --git a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml index beeb328aedf..0264196f60c 100644 --- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml @@ -7,12 +7,12 @@ .col-lg-9 = render Pajamas::CardComponent.new(header_options: { class: 'gl-display-flex gl-align-items-center' }, body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 custom-monitored-metrics js-panel-custom-monitored-metrics', data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } }) do |c| - - c.header do + - c.with_header do %strong = s_('PrometheusService|Custom metrics') = gl_badge_tag 0, nil, class: 'gl-ml-2 js-custom-monitored-count' = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden' - - c.body do + - c.with_body do .flash-container.hidden .flash-warning .flash-text diff --git a/app/views/shared/integrations/prometheus/_metrics.html.haml b/app/views/shared/integrations/prometheus/_metrics.html.haml index 7cd4eeee5f8..cb78faa383a 100644 --- a/app/views/shared/integrations/prometheus/_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_metrics.html.haml @@ -9,11 +9,11 @@ .col-lg-9 = render Pajamas::CardComponent.new(body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 js-panel-monitored-metrics', data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') }}) do |c| - - c.header do + - c.with_header do %strong = s_('PrometheusService|Common metrics') = gl_badge_tag 0, nil, class: 'js-monitored-count' - - c.body do + - c.with_body do .loading-metrics.js-loading-metrics %p.m-3 = gl_loading_icon(inline: true, css_class: 'metrics-load-spinner') @@ -24,12 +24,12 @@ %ul.list-unstyled.metrics-list.hidden.js-metrics-list = render Pajamas::CardComponent.new(body_options: { class: 'hidden gl-p-0' }, card_options: { class: 'hidden js-panel-missing-env-vars' }) do |c| - - c.header do + - c.with_header do = sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right') = sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden') = s_('PrometheusService|Missing environment variable') = gl_badge_tag 0, nil, class: 'js-env-var-count' - - c.body do + - c.with_body do .flash-container .flash-notice .flash-text diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 76678c48a86..b8f98c28574 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -13,7 +13,7 @@ - if @can_bulk_update .check-all-holder.gl-display-none.gl-sm-display-block.hidden.gl-float-left.gl-mr-3.gl-line-height-36 = render Pajamas::CheckboxTagComponent.new(name: 'check-all-issues', value: nil) do |c| - = c.label do + - c.with_label do %span.gl-sr-only = _('Select all') .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 09162e6a349..ee1ca364b07 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -46,7 +46,7 @@ .js-sidebar-milestone-widget-root{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } - if in_group_context_with_iterations - .block.gl-collapse-empty{ data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }< + .block.gl-collapse-empty{ data: { testid: 'iteration_container' } }< = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:show_crm_contacts] diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 634e927f891..01f1dbdb3cf 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -42,7 +42,7 @@ - if source_level < target_level = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| - = c.body do + - c.with_body do = visibilityMismatchString %br = _('Review the target project before submitting to avoid exposing %{source} changes.') % { source: source_visibility } diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 8e9793cdba5..051a1a75f2b 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -12,7 +12,7 @@ .form-check.gl-pl-0 = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil = 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 + - c.with_label do = _("Delete source branch when merge request is accepted.") - if !project.squash_never? @@ -20,14 +20,14 @@ - if project.squash_always? = hidden_field_tag 'merge_request[squash]', '1', id: nil = 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 + - c.with_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 + - c.with_help_text do = _('Required in this project.') - else = hidden_field_tag 'merge_request[squash]', '0', id: nil = 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 + - c.with_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/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index b27fd8ab7d2..1da0b82b634 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -6,12 +6,12 @@ - if @add_related_issue .form-group - .form-check - = check_box_tag :add_related_issue, @add_related_issue.iid, true, class: 'form-check-input' - = label_tag :add_related_issue, class: 'form-check-label' do + = render Pajamas::CheckboxTagComponent.new(name: :add_related_issue, value: @add_related_issue.iid, checked: true) do |c| + - c.with_label do - add_related_issue_link = link_to "\##{@add_related_issue.iid}", issue_path(@add_related_issue), class: ['has-tooltip'], title: @add_related_issue.title #{_('Relate to %{issuable_type} %{add_related_issue_link}').html_safe % { issuable_type: @add_related_issue.issue_type, add_related_issue_link: add_related_issue_link }} - %p.text-muted= _('Adds this %{issuable_type} as related to the %{issuable_type} it was created from') % { issuable_type: @add_related_issue.issue_type } + - c.with_help_text do + = _('Adds this %{issuable_type} as related to the %{issuable_type} it was created from') % { issuable_type: @add_related_issue.issue_type } - if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable) .form-group diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index be836f4b8a9..36000f3cc67 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -8,7 +8,7 @@ - if issuable.respond_to?(:draft?) .gl-pt-3 = render Pajamas::CheckboxTagComponent.new(name: 'mark_as_draft', checkbox_options: { class: 'js-toggle-draft' }) do |c| - = c.label do + - c.with_label do = s_('MergeRequests|Mark as draft') - = c.help_text do + - c.with_help_text do = s_('MergeRequests|Drafts cannot be merged until marked ready.') diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index fdbe247c6ba..40a02fddbf3 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-4.gl-pb-0.gl-border-none + .detail-page-description.content-block.js-detail-page-description.gl-pt-3.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, diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 31625c22a94..45e34a63f91 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -6,12 +6,12 @@ - return if requesters.empty? = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c| - - c.header do + - c.with_header do = _('Users requesting access to') %strong= membership_source.name = gl_badge_tag requesters.size = render 'shared/members/manage_access_button', path: membership_source.is_a?(Project) ? project_project_members_path(@project, tab: 'access_requests') : group_group_members_path(@group, tab: 'access_requests') - - c.body do + - c.with_body do %ul.content-list.members-list = render partial: 'shared/members/member', collection: requesters, as: :member, diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml index a63702661d0..3774fb0869f 100644 --- a/app/views/shared/milestones/_description.html.haml +++ b/app/views/shared/milestones/_description.html.haml @@ -9,5 +9,7 @@ - if milestone.try(:description).present? %div{ data: { qa_selector: "milestone_description_content" } } - .description.md.gl-px-0.gl-pt-4 + .description.md.gl-px-0.gl-pt-4{ class: ('js-task-list-container' if can?(current_user, :admin_milestone, milestone)), data: { lock_version: @milestone.lock_version } } = markdown_field(milestone, :description) + -# This textarea is necessary for `task_list.js` to work. + %textarea.hidden.js-task-list-field{ data: { value: milestone.description, update_url: milestone_path(milestone, format: :json)} } diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml index 2502f7fca62..d56e24a070a 100644 --- a/app/views/shared/milestones/_issuables.html.haml +++ b/app/views/shared/milestones/_issuables.html.haml @@ -2,7 +2,7 @@ - primary = local_assigns.fetch(:primary, false) = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }, header_options: { class: milestone_header_class(primary, issuables) }) do |c| - - c.header do + - c.with_header do .gl-flex-grow-2 = title .gl-ml-3.gl-flex-shrink-0.gl-font-weight-bold.gl-white-space-nowrap{ class: milestone_counter_class(primary) } @@ -11,7 +11,7 @@ = sprite_icon('issues', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(issuables.length) = render_if_exists "shared/milestones/issuables_weight", issuables: issuables - = c.body do + - c.with_body do - class_prefix = dom_class(issuables).pluralize %ul{ class: "content-list milestone-#{class_prefix}-list", id: "#{class_prefix}-list-#{id}" } = render partial: 'shared/milestones/issuable', diff --git a/app/views/shared/milestones/_milestone_complete_alert.html.haml b/app/views/shared/milestones/_milestone_complete_alert.html.haml index bde8a0b91b0..dc923465c2f 100644 --- a/app/views/shared/milestones/_milestone_complete_alert.html.haml +++ b/app/views/shared/milestones/_milestone_complete_alert.html.haml @@ -4,5 +4,5 @@ = render Pajamas::AlertComponent.new(variant: :success, alert_options: { data: { testid: 'all-issues-closed-alert' }}, dismissible: false) do |c| - = c.body do + - c.with_body do = yield diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 8c49977fe82..cd1667cb3b3 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,8 +1,10 @@ - show_project_name = local_assigns.fetch(:show_project_name, false) .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) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } + = sprite_icon('chevron-lg-right', size: 12) = gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do = gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do = _('Issues') diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index 72081856da6..40a71aa53dc 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -2,6 +2,7 @@ = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' + .flash-container = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', qa_selector: 'edit_note_field', placeholder: _("Write a comment or drag your files here…") = render 'shared/notes/hints' diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index eb36de8167c..0fed59aaff3 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,7 +1,7 @@ - issuable = @issue || @merge_request - discussion_locked = issuable&.discussion_locked? -%ul#notes-list.notes.main-notes-list.timeline +%ul#notes-list.notes.main-notes-list.timeline{ data: { 'qa_selector': 'notes_list' } } = render "shared/notes/notes" = render 'shared/notes/edit_form', project: @project diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 79a33316b1a..e09736cad6c 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -9,7 +9,6 @@ - compact_mode = false unless local_assigns[:compact_mode] == true - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project) - css_class = "gl-sm-display-flex gl-align-items-center gl-vertical-align-middle!" if project.description.blank? && !show_last_commit_as_description -- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) - 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 = last_pipeline_from_status_cache(project) if show_pipeline_status_icon @@ -17,14 +16,13 @@ - 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 - - if avatar - .project-cell.gl-w-11 - = link_to project_path(project), class: dom_class(project) do - - if project.creator && use_creator_avatar - = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5') - - else - = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5') + - if avatar + .project-cell.gl-w-11 + = link_to project_path(project), class: dom_class(project) do + - if project.creator && use_creator_avatar + = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5') + - else + = 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 @@ -83,7 +81,7 @@ = _('Updated') = updated_tooltip - .project-cell{ class: "#{css_class} gl-xs-display-none!" } + .project-cell{ class: "#{css_class} gl-display-none! gl-sm-display-table-cell!" } .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? diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index 12246d1dcfa..c4524125a21 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -5,7 +5,7 @@ %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]) + - explore_project_topic_path = topic_explore_projects_cleaned_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) @@ -18,7 +18,7 @@ - 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]) + - explore_project_topic_path = topic_explore_projects_cleaned_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) diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml index 57ac1370f8d..24071ed0da4 100644 --- a/app/views/shared/promotions/_promote_servicedesk.html.haml +++ b/app/views/shared/promotions/_promote_servicedesk.html.haml @@ -2,7 +2,7 @@ close_options: {'aria-label' => s_('Promotions|Dismiss Service Desk promotion'), class: 'js-close-callout'}, svg_path: 'illustrations/service_desk_callout.svg', button_text: s_('Promotions|Configure Service Desk'), button_link: help_page_path('user/project/service_desk.html', anchor: 'configuring-service-desk')) do |c| - - c.title do + - c.with_title do = _('Improve customer support with Service Desk') %p = _('Service Desk allows people to create issues in your GitLab instance without their own user account. It provides a unique email address for end users to create issues in a project. Replies can be sent either through the GitLab interface or by email. End users only see threads through email.') diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index f4b6c3c3a50..216aaad443f 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -42,12 +42,12 @@ - if local_assigns[:in_gitlab_com_admin_context] .form-group.row = label_tag :public_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do - = _('Public projects Minutes cost factor') + = _('Public projects compute cost factor') .col-sm-10 = f.text_field :public_projects_minutes_cost_factor, class: 'form-control' .form-group.row = label_tag :private_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do - = _('Private projects Minutes cost factor') + = _('Private projects compute cost factor') .col-sm-10 = f.text_field :private_projects_minutes_cost_factor, class: 'form-control' .form-actions diff --git a/app/views/shared/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml index 686cd1a081b..30e5587c413 100644 --- a/app/views/shared/runners/_runner_details.html.haml +++ b/app/views/shared/runners/_runner_details.html.haml @@ -1,4 +1,4 @@ -%h1.page-title.gl-font-size-h-display +%h1.page-title.gl-font-size-h-display.gl-display-flex.gl-align-items-center = s_('Runners|Runner #%{runner_id}') % { runner_id: runner.id } = render 'shared/runners/runner_type_badge', runner: runner diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml index 63ecdaf4149..26c3501e3d9 100644 --- a/app/views/shared/runners/_runner_type_alert.html.haml +++ b/app/views/shared/runners/_runner_type_alert.html.haml @@ -4,13 +4,13 @@ = render Pajamas::AlertComponent.new(alert_options: alert_options, title: s_('Runners|This runner is available to all projects and subgroups in a group.'), dismissible: false) do |c| - = c.body do + - c.with_body do = s_('Runners|Use Group runners when you want all projects in a group to have access to a set of runners.') = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'group-runners'), target: '_blank', rel: 'noopener noreferrer' - else = render Pajamas::AlertComponent.new(alert_options: alert_options, title: s_('Runners|This runner is associated with specific projects.'), dismissible: false) do |c| - = c.body do + - c.with_body do = s_('Runners|You can set up a project runner to be used by multiple projects but you cannot make this a shared or group runner.') = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'project-runners'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml index a8a93f3dd76..9930f60b755 100644 --- a/app/views/shared/runners/_runner_type_badge.html.haml +++ b/app/views/shared/runners/_runner_type_badge.html.haml @@ -1,7 +1,7 @@ - -- if runner.instance_type? - = gl_badge_tag s_('Runners|shared'), variant: :success -- elsif runner.group_type? - = gl_badge_tag s_('Runners|group'), variant: :success -- else - = gl_badge_tag s_('Runners|project'), variant: :info +.gl-ml-2 + - if runner.instance_type? + = gl_badge_tag s_('Runners|shared'), variant: :success + - elsif runner.group_type? + = gl_badge_tag s_('Runners|group'), variant: :success + - else + = gl_badge_tag s_('Runners|project'), variant: :info diff --git a/app/views/shared/topics/_topic.html.haml b/app/views/shared/topics/_topic.html.haml index 9b9630733fd..de2682d9d40 100644 --- a/app/views/shared/topics/_topic.html.haml +++ b/app/views/shared/topics/_topic.html.haml @@ -1,10 +1,10 @@ - max_topic_title_length = 30 -- detail_page_link = topic_explore_projects_path(topic_name: topic.name) +- detail_page_link = topic_explore_projects_cleaned_path(topic_name: topic.name) .col-lg-3.col-md-4.col-sm-12 = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-display-flex gl-align-items-center' }) do |c| - = c.body do + - c.with_body do = link_to detail_page_link do = render Pajamas::AvatarComponent.new(topic, size: 48, alt: '', class: 'gl-mr-3') = link_to detail_page_link do diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml index 51eb24f6d4a..e3c1ca4d9cf 100644 --- a/app/views/shared/users/_user.html.haml +++ b/app/views/shared/users/_user.html.haml @@ -2,7 +2,7 @@ .col-lg-3.col-md-4.col-sm-12 = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c| - = c.body do + - c.with_body do = render Pajamas::AvatarComponent.new(user, size: 48, alt: "", class: 'gl-float-left gl-mr-3') .user-info diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml index 098cc19c435..0e5f6d844cd 100644 --- a/app/views/shared/web_hooks/_hook_errors.html.haml +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -8,12 +8,12 @@ root_namespace: hook.parent.root_namespace.path } = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook rate limit has been reached'), variant: :danger) do |c| - = c.body do + - c.with_body do = s_("Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute.").html_safe % placeholders - elsif hook.permanently_disabled? = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'), variant: :danger) do |c| - = c.body do + - c.with_body do = s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end } - elsif hook.temporarily_disabled? - help_path = help_page_path('user/project/integrations/webhooks', anchor: 'webhook-fails-or-multiple-webhook-requests-are-triggered') @@ -24,5 +24,5 @@ help_link_end: link_end } = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook fails to connect'), variant: :warning) do |c| - = c.body do + - c.with_body do = s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 868633143cd..8a81e697a59 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -1,9 +1,9 @@ %hr = render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index' }, body_options: { class: 'gl-py-0'}) do |c| - - c.header do + - c.with_header do = hook_class.underscore.humanize.titleize.pluralize (#{hooks.size}) - - c.body do + - c.with_body do - if hooks.any? %ul.content-list - hooks.each do |hook| 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 f8e2dc3d8dd..cbbb2f51fd5 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 @@ -5,9 +5,9 @@ title: s_('Webhooks|Webhook disabled'), alert_options: { class: 'gl-my-4 js-web-hook-disabled-callout', data: { feature_id: Users::CalloutsHelper::WEB_HOOK_DISABLED, dismiss_endpoint: project_callouts_path, project_id: @project.id, defer_links: 'true'} }) do |c| - = c.body do + - c.with_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: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' - = c.actions do + - c.with_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/empty.html.haml b/app/views/shared/wikis/empty.html.haml index d30a37aaa3e..e83cad0d42f 100644 --- a/app/views/shared/wikis/empty.html.haml +++ b/app/views/shared/wikis/empty.html.haml @@ -6,7 +6,7 @@ = render Pajamas::AlertComponent.new(alert_options: { id: 'error_explanation', class: 'gl-mb-3'}, dismissible: false, variant: :danger) do |c| - = c.body do + - c.with_body do %ul.gl-pl-4 = @error diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index c916b6c3d45..7c50031598c 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -6,4 +6,4 @@ = s_('UserProfile|User ID: %{id}') % { id: @user.id } = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) = render 'middle_dot_divider', stacking: true do - = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) } + = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) } diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 1ebf02ffd39..4113a276416 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -131,8 +131,10 @@ - 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) + %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } + = sprite_icon('chevron-lg-left', size: 12) + %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } + = sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs.gl-border-b-0 - if profile_tab?(:overview) %li.js-overview-tab diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 17cc7bb73c2..f8aa06943ee 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -417,6 +417,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:database_monitor_locked_tables + :worker_name: Database::MonitorLockedTablesWorker + :feature_category: :cell + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:database_partition_management :worker_name: Database::PartitionManagementWorker :feature_category: :database @@ -545,7 +554,7 @@ :tags: [] - :name: cronjob:member_invitation_reminder_emails :worker_name: MemberInvitationReminderEmailsWorker - :feature_category: :subgroups + :feature_category: :groups_and_projects :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -588,6 +597,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:object_storage_delete_stale_direct_uploads + :worker_name: ObjectStorage::DeleteStaleDirectUploadsWorker + :feature_category: :build_artifacts + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:packages_cleanup_delete_orphaned_dependencies :worker_name: Packages::Cleanup::DeleteOrphanedDependenciesWorker :feature_category: :package_registry @@ -1245,33 +1263,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: github_importer:github_import_import_pull_request_merged_by - :worker_name: Gitlab::GithubImport::ImportPullRequestMergedByWorker - :feature_category: :importers - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :cpu - :weight: 1 - :idempotent: false - :tags: [] -- :name: github_importer:github_import_import_pull_request_review - :worker_name: Gitlab::GithubImport::ImportPullRequestReviewWorker - :feature_category: :importers - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :cpu - :weight: 1 - :idempotent: false - :tags: [] -- :name: github_importer:github_import_import_release_attachments - :worker_name: Gitlab::GithubImport::ImportReleaseAttachmentsWorker - :feature_category: :importers - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: github_importer:github_import_pull_requests_import_merged_by :worker_name: Gitlab::GithubImport::PullRequests::ImportMergedByWorker :feature_category: :importers @@ -1794,6 +1785,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: package_repositories:packages_npm_create_metadata_cache + :worker_name: Packages::Npm::CreateMetadataCacheWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_repositories:packages_npm_deprecate_package :worker_name: Packages::Npm::DeprecatePackageWorker :feature_category: :package_registry @@ -2633,7 +2633,7 @@ :tags: [] - :name: disallow_two_factor_for_group :worker_name: DisallowTwoFactorForGroupWorker - :feature_category: :subgroups + :feature_category: :groups_and_projects :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2642,7 +2642,7 @@ :tags: [] - :name: disallow_two_factor_for_subgroups :worker_name: DisallowTwoFactorForSubgroupsWorker - :feature_category: :subgroups + :feature_category: :groups_and_projects :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2714,7 +2714,7 @@ :tags: [] - :name: file_hook :worker_name: FileHookWorker - :feature_category: :integrations + :feature_category: :webhooks :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2777,7 +2777,7 @@ :tags: [] - :name: group_destroy :worker_name: GroupDestroyWorker - :feature_category: :subgroups + :feature_category: :groups_and_projects :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -3027,6 +3027,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: merge_requests_mergeability_check_batch + :worker_name: MergeRequests::MergeabilityCheckBatchWorker + :feature_category: :code_review_workflow + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge_requests_resolve_todos :worker_name: MergeRequests::ResolveTodosWorker :feature_category: :code_review_workflow @@ -3380,7 +3389,7 @@ :tags: [] - :name: projects_record_target_platforms :worker_name: Projects::RecordTargetPlatformsWorker - :feature_category: :projects + :feature_category: :groups_and_projects :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -3632,7 +3641,7 @@ :tags: [] - :name: web_hook :worker_name: WebHookWorker - :feature_category: :integrations + :feature_category: :webhooks :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown @@ -3641,7 +3650,7 @@ :tags: [] - :name: web_hooks_log_destroy :worker_name: WebHooks::LogDestroyWorker - :feature_category: :integrations + :feature_category: :webhooks :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -3650,7 +3659,7 @@ :tags: [] - :name: web_hooks_log_execution :worker_name: WebHooks::LogExecutionWorker - :feature_category: :integrations + :feature_category: :webhooks :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown diff --git a/app/workers/ci/cancel_pipeline_worker.rb b/app/workers/ci/cancel_pipeline_worker.rb index 2735498b6bb..0b2c96e7ace 100644 --- a/app/workers/ci/cancel_pipeline_worker.rb +++ b/app/workers/ci/cancel_pipeline_worker.rb @@ -14,12 +14,14 @@ module Ci def perform(pipeline_id, auto_canceled_by_pipeline_id) ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - pipeline.cancel_running( - # cascade_to_children is false because we iterate through children - # we also cancel bridges prior to prevent more children + # cascade_to_children is false because we iterate through children + # we also cancel bridges prior to prevent more children + ::Ci::CancelPipelineService.new( + pipeline: pipeline, + current_user: nil, cascade_to_children: false, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id - ) + ).force_execute end end end diff --git a/app/workers/ci/update_locked_unknown_artifacts_worker.rb b/app/workers/ci/update_locked_unknown_artifacts_worker.rb index 2d37ebb3c93..c796b7a28a8 100644 --- a/app/workers/ci/update_locked_unknown_artifacts_worker.rb +++ b/app/workers/ci/update_locked_unknown_artifacts_worker.rb @@ -15,8 +15,6 @@ module Ci feature_category :build_artifacts def perform - return unless ::Feature.enabled?(:ci_job_artifacts_backlog_work) - artifact_counts = Ci::JobArtifacts::UpdateUnknownLockedStatusService.new.execute log_extra_metadata_on_done(:removed_count, artifact_counts[:removed]) diff --git a/app/workers/clusters/integrations/check_prometheus_health_worker.rb b/app/workers/clusters/integrations/check_prometheus_health_worker.rb index 0c0d86e975c..b65b3424c3a 100644 --- a/app/workers/clusters/integrations/check_prometheus_health_worker.rb +++ b/app/workers/clusters/integrations/check_prometheus_health_worker.rb @@ -18,15 +18,7 @@ module Clusters idempotent! worker_has_external_dependencies! - def perform - demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys - - clusters = Clusters::Cluster.with_integration_prometheus - .with_project_http_integrations(demo_project_ids) - - # Move to a seperate worker with scoped context if expanded to do work on customer projects - clusters.each { |cluster| Clusters::Integrations::PrometheusHealthCheckService.new(cluster).execute } - end + def perform; end end end end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index 408354d5caa..6cb9bd34969 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -38,8 +38,12 @@ module Gitlab # client - An instance of `Gitlab::GithubImport::Client` # hash - A Hash containing the details of the object to import. def import(project, client, hash) - if project.import_state&.canceled? - info(project.id, message: 'project import canceled') + unless project.import_state&.in_progress? + info( + project.id, + message: 'Project import is no longer running. Stopping worker.', + import_status: project.import_state.status + ) return end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 1feaaf917b2..a5287fcfbe2 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -9,8 +9,12 @@ module Gitlab return unless (project = find_project(project_id)) - if project.import_state&.canceled? - info(project_id, message: 'project import canceled') + unless project.import_state&.in_progress? + info( + project_id, + message: 'Project import is no longer running. Stopping worker.', + import_status: project.import_state.status + ) return end diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index 1674ed1483a..c260e06607c 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -33,6 +33,8 @@ module WorkerAttributes security_scans: 2 }.stringify_keys.freeze + DEFAULT_DEFER_DELAY = 5.seconds + class_methods do def feature_category(value, *extras) set_class_attribute(:feature_category, value) @@ -190,5 +192,20 @@ module WorkerAttributes def big_payload? !!get_class_attribute(:big_payload) end + + def defer_on_database_health_signal(gitlab_schema, delay_by = DEFAULT_DEFER_DELAY, tables = []) + set_class_attribute( + :database_health_check_attrs, + { gitlab_schema: gitlab_schema, delay_by: delay_by, tables: tables } + ) + end + + def defer_on_database_health_signal? + database_health_check_attrs.present? + end + + def database_health_check_attrs + get_class_attribute(:database_health_check_attrs) + end end end diff --git a/app/workers/container_registry/record_data_repair_detail_worker.rb b/app/workers/container_registry/record_data_repair_detail_worker.rb index f400568a3ef..390481f8e01 100644 --- a/app/workers/container_registry/record_data_repair_detail_worker.rb +++ b/app/workers/container_registry/record_data_repair_detail_worker.rb @@ -14,7 +14,6 @@ module ContainerRegistry worker_resource_boundary :unknown idempotent! - MAX_CAPACITY = 2 LEASE_TIMEOUT = 1.hour.to_i def perform_work @@ -60,11 +59,15 @@ module ContainerRegistry end def max_running_jobs - MAX_CAPACITY + current_application_settings.container_registry_data_repair_detail_worker_max_concurrency.to_i end private + def current_application_settings + ::Gitlab::CurrentSettings.current_application_settings + end + def next_project Project.pending_data_repair_analysis.first end diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb index 53c92ab8969..1bdc829418a 100644 --- a/app/workers/database/batched_background_migration/execution_worker.rb +++ b/app/workers/database/batched_background_migration/execution_worker.rb @@ -15,6 +15,7 @@ module Database included do data_consistency :always feature_category :database + prefer_calling_context_feature_category true queue_namespace :batched_background_migrations end diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb index b7b46937db2..ebf63d34cbf 100644 --- a/app/workers/database/batched_background_migration/single_database_worker.rb +++ b/app/workers/database/batched_background_migration/single_database_worker.rb @@ -16,7 +16,6 @@ module Database included do data_consistency :always feature_category :database - prefer_calling_context_feature_category true idempotent! end @@ -58,39 +57,19 @@ module Database Gitlab::Database::SharedModel.using_connection(base_model.connection) do break unless self.class.enabled? - if parallel_execution_enabled? - migrations = Gitlab::Database::BackgroundMigration::BatchedMigration - .active_migrations_distinct_on_table(connection: base_model.connection, limit: max_running_migrations).to_a + migrations = Gitlab::Database::BackgroundMigration::BatchedMigration + .active_migrations_distinct_on_table(connection: base_model.connection, limit: max_running_migrations).to_a - queue_migrations_for_execution(migrations) if migrations.any? - else - break unless active_migration - - with_exclusive_lease(active_migration.interval) do - run_active_migration - end - end + queue_migrations_for_execution(migrations) if migrations.any? end end private - def parallel_execution_enabled? - Feature.enabled?(:batched_migrations_parallel_execution) - end - def max_running_migrations execution_worker_class.max_running_jobs end - def active_migration - @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration(connection: base_model.connection) - end - - def run_active_migration - execution_worker_class.new.perform_work(tracking_database, active_migration.id) - end - def tracking_database self.class.tracking_database end diff --git a/app/workers/database/monitor_locked_tables_worker.rb b/app/workers/database/monitor_locked_tables_worker.rb new file mode 100644 index 00000000000..66296ea1c0d --- /dev/null +++ b/app/workers/database/monitor_locked_tables_worker.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Database + class MonitorLockedTablesWorker + include ApplicationWorker + include CronjobQueue # rubocop: disable Scalability/CronWorkerContext + + sidekiq_options retry: false + feature_category :cell + data_consistency :sticky + idempotent! + + version 1 + + INITIAL_DATABASE_RESULT = { + tables_need_lock: [], + tables_need_lock_count: 0, + tables_need_unlock: [], + tables_need_unlock_count: 0 + }.freeze + + def perform + return unless Gitlab::Database.database_mode == Gitlab::Database::MODE_MULTIPLE_DATABASES + return if Feature.disabled?(:monitor_database_locked_tables, type: :ops) + + lock_writes_results = ::Gitlab::Database::TablesLocker.new(dry_run: true, include_partitions: false).lock_writes + + tables_lock_info_per_db = ::Gitlab::Database.database_base_models_with_gitlab_shared.keys.to_h do |db_name, _| + [db_name, INITIAL_DATABASE_RESULT.deep_dup] + end + + lock_writes_results.each do |result| + handle_lock_writes_result(tables_lock_info_per_db, result) + end + + log_extra_metadata_on_done(:results, tables_lock_info_per_db) + end + + private + + def handle_lock_writes_result(results, result) + case result[:action] + when "needs_lock" + results[result[:database]][:tables_need_lock] << result[:table] + results[result[:database]][:tables_need_lock_count] += 1 + when "needs_unlock" + results[result[:database]][:tables_need_unlock] << result[:table] + results[result[:database]][:tables_need_unlock_count] += 1 + end + end + end +end diff --git a/app/workers/disallow_two_factor_for_group_worker.rb b/app/workers/disallow_two_factor_for_group_worker.rb index 5b958f9f31f..1ee2585a718 100644 --- a/app/workers/disallow_two_factor_for_group_worker.rb +++ b/app/workers/disallow_two_factor_for_group_worker.rb @@ -8,7 +8,7 @@ class DisallowTwoFactorForGroupWorker sidekiq_options retry: 3 include ExceptionBacktrace - feature_category :subgroups + feature_category :groups_and_projects idempotent! def perform(group_id) diff --git a/app/workers/disallow_two_factor_for_subgroups_worker.rb b/app/workers/disallow_two_factor_for_subgroups_worker.rb index 500c13deed2..02dceee2488 100644 --- a/app/workers/disallow_two_factor_for_subgroups_worker.rb +++ b/app/workers/disallow_two_factor_for_subgroups_worker.rb @@ -10,7 +10,7 @@ class DisallowTwoFactorForSubgroupsWorker INTERVAL = 2.seconds.to_i - feature_category :subgroups + feature_category :groups_and_projects idempotent! def perform(group_id) diff --git a/app/workers/file_hook_worker.rb b/app/workers/file_hook_worker.rb index 77aaf957254..703e0c9add7 100644 --- a/app/workers/file_hook_worker.rb +++ b/app/workers/file_hook_worker.rb @@ -5,7 +5,7 @@ class FileHookWorker # rubocop:disable Scalability/IdempotentWorker data_consistency :always sidekiq_options retry: false - feature_category :integrations + feature_category :webhooks loggable_arguments 0 urgency :low 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 8cbbe35dd30..1f17c98dff9 100644 --- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb +++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb @@ -4,7 +4,6 @@ module Gitlab module GithubGistsImport class ImportGistWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - include Gitlab::NotifyUponDeath GISTS_ERRORS_BY_ID = 'gitlab:github-gists-import:%{user_id}:errors' @@ -14,40 +13,55 @@ module Gitlab sidekiq_options dead: false, retry: 5 - sidekiq_retries_exhausted do |msg, _| - new.track_gist_import('failed', msg['args'][0]) + sidekiq_retries_exhausted do |msg| + args = msg['args'] + user_id = args[0] + gist_hash = args[1] + jid = msg['jid'] + + new.perform_failure(user_id, gist_hash, msg['error_class'], msg['error_message'], msg['correlation_id']) + + # If a job is being exhausted we still want to notify the + # Gitlab::GithubGistsImport::FinishImportWorker to prevent + # the entire import from getting stuck + if args.length == 3 && (key = args.last) && key.is_a?(String) + JobWaiter.notify(key, jid) + end end def perform(user_id, gist_hash, notify_key) - gist = ::Gitlab::GithubGistsImport::Representation::Gist.from_json_hash(gist_hash) + gist = representation_class.from_json_hash(gist_hash) + github_identifiers = gist.github_identifiers - with_logging(user_id, gist.github_identifiers) do + with_logging(user_id, github_identifiers) do result = importer_class.new(gist, user_id).execute if result.success? track_gist_import('success', user_id) else - error(user_id, result.errors, gist.github_identifiers) - track_gist_import('failed', user_id) + error(user_id, result.errors, github_identifiers) + + perform_failure( + user_id, + gist_hash, + importer_class::FileCountLimitError.name, + importer_class::FILE_COUNT_LIMIT_MESSAGE + ) end JobWaiter.notify(notify_key, jid) end rescue StandardError => e - log_and_track_error(user_id, e, gist.github_identifiers) + log_and_track_error(user_id, e, github_identifiers) raise end - def track_gist_import(status, user_id) - user = User.find(user_id) + def perform_failure(user_id, gist_hash, exception_class, exception_message, correlation_id = nil) + track_gist_import('failed', user_id) - Gitlab::Tracking.event( - self.class.name, - 'create', - label: 'github_gist_import', - user: user, - status: status - ) + github_identifiers = representation_class.from_json_hash(gist_hash).github_identifiers + + persist_failure(user_id, exception_class, exception_message, github_identifiers, correlation_id) end private @@ -56,6 +70,10 @@ module Gitlab ::Gitlab::GithubGistsImport::Importer::GistImporter end + def representation_class + ::Gitlab::GithubGistsImport::Representation::Gist + end + def with_logging(user_id, github_identifiers) info(user_id, 'start importer', github_identifiers) @@ -64,6 +82,18 @@ module Gitlab info(user_id, 'importer finished', github_identifiers) 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 + def log_and_track_error(user_id, exception, github_identifiers) error(user_id, exception.message, github_identifiers) @@ -101,6 +131,17 @@ module Gitlab ::Gitlab::Cache::Import::Caching.hash_add(key, gist_id, error_message) end + + def persist_failure(user_id, exception_class, exception_message, github_identifiers, correlation_id = nil) + ImportFailure.create!( + source: importer_class.name, + exception_class: exception_class, + exception_message: exception_message.truncate(255), + correlation_id_value: correlation_id || Labkit::Correlation::CorrelationId.current_or_new_id, + user_id: user_id, + external_identifiers: github_identifiers + ) + end end end end diff --git a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb deleted file mode 100644 index 94472fdf6db..00000000000 --- a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# TODO: remove in 16.1 milestone -# https://gitlab.com/gitlab-org/gitlab/-/issues/409706 -module Gitlab - module GithubImport - class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker - include ObjectImporter - - worker_resource_boundary :cpu - - def representation_class - Gitlab::GithubImport::Representation::PullRequest - end - - def importer_class - Importer::PullRequests::MergedByImporter - end - - def object_type - :pull_request_merged_by - end - end - end -end diff --git a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb deleted file mode 100644 index 6b7d19010ec..00000000000 --- a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# TODO: remove in 16.1 milestone -# https://gitlab.com/gitlab-org/gitlab/-/issues/409706 -module Gitlab - module GithubImport - class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker - include ObjectImporter - - worker_resource_boundary :cpu - - def representation_class - Gitlab::GithubImport::Representation::PullRequestReview - end - - def importer_class - Importer::PullRequests::ReviewImporter - end - - def object_type - :pull_request_review - end - end - end -end diff --git a/app/workers/gitlab/github_import/import_release_attachments_worker.rb b/app/workers/gitlab/github_import/import_release_attachments_worker.rb deleted file mode 100644 index 0d3831789bf..00000000000 --- a/app/workers/gitlab/github_import/import_release_attachments_worker.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -# TODO: remove in 16.1 milestone -# https://gitlab.com/gitlab-org/gitlab/-/issues/409706 -module Gitlab - module GithubImport - class ImportReleaseAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker - include ObjectImporter - - def representation_class - Representation::NoteText - end - - def importer_class - Importer::NoteAttachmentsImporter - end - - def object_type - :release_attachment - end - end - end -end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index e7eee0915d5..b2dfded0280 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -16,6 +16,12 @@ module Gitlab # project - An instance of Project. def import(client, project) info(project.id, message: "starting importer", importer: 'Importer::PullRequestsImporter') + + # If a user creates a new merge request while the import is in progress, GitLab can assign an IID + # to this merge request that already exists for a GitHub Pull Request. + # The workaround is to allocate IIDs before starting the importer. + allocate_merge_requests_internal_id!(project, client) + waiter = Importer::PullRequestsImporter .new(project, client) .execute @@ -41,6 +47,17 @@ module Gitlab private + def allocate_merge_requests_internal_id!(project, client) + return if InternalId.exists?(project: project, usage: :merge_requests) # rubocop: disable CodeReuse/ActiveRecord + + options = { state: 'all', sort: 'number', direction: 'desc', per_page: '1' } + last_github_pull_request = client.each_object(:pulls, project.import_source, options).first + + return unless last_github_pull_request + + MergeRequest.track_target_project_iid!(project, last_github_pull_request[:number]) + end + def abort_on_failure true end diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index a116944feb9..d8dd0b6d7b3 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -8,7 +8,7 @@ class GroupDestroyWorker sidekiq_options retry: 3 include ExceptionBacktrace - feature_category :subgroups + feature_category :groups_and_projects idempotent! deduplicate :until_executed, ttl: 2.hours diff --git a/app/workers/incident_management/close_incident_worker.rb b/app/workers/incident_management/close_incident_worker.rb index 6b3e1c5321b..c820a8a97bf 100644 --- a/app/workers/incident_management/close_incident_worker.rb +++ b/app/workers/incident_management/close_incident_worker.rb @@ -14,7 +14,7 @@ module IncidentManagement worker_has_external_dependencies! def perform(issue_id) - incident = Issue.incident.opened.find_by_id(issue_id) + incident = Issue.with_issue_type(:incident).opened.find_by_id(issue_id) return unless incident diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb index a7614db30f6..d36ec2611d5 100644 --- a/app/workers/member_invitation_reminder_emails_worker.rb +++ b/app/workers/member_invitation_reminder_emails_worker.rb @@ -7,7 +7,7 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :subgroups + feature_category :groups_and_projects urgency :low def perform diff --git a/app/workers/merge_requests/mergeability_check_batch_worker.rb b/app/workers/merge_requests/mergeability_check_batch_worker.rb new file mode 100644 index 00000000000..cbe34ac3790 --- /dev/null +++ b/app/workers/merge_requests/mergeability_check_batch_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeabilityCheckBatchWorker + include ApplicationWorker + + data_consistency :sticky + + sidekiq_options retry: 3 + + feature_category :code_review_workflow + idempotent! + + def logger + @logger ||= Sidekiq.logger + end + + def perform(merge_request_ids) + merge_requests = MergeRequest.id_in(merge_request_ids) + + merge_requests.each do |merge_request| + result = merge_request.check_mergeability + + next unless result&.error? + + logger.error( + worker: self.class.name, + message: "Failed to check mergeability of merge request: #{result.message}", + merge_request_id: merge_request.id + ) + end + end + end +end diff --git a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb index 5c117486da2..5b34f85606d 100644 --- a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb +++ b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb @@ -16,12 +16,7 @@ module Metrics idempotent! # in the scope of 24 hours - def perform - stale_annotations = ::Metrics::Dashboard::Annotation.ending_before(DEFAULT_CUT_OFF_PERIOD.ago.beginning_of_day) - stale_annotations.delete_with_limit(DELETE_LIMIT) - - self.class.perform_async if stale_annotations.exists? - end + def perform; end end end end diff --git a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb index 62cf35a669f..fe002ffa4a0 100644 --- a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb +++ b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb @@ -16,11 +16,7 @@ module Metrics idempotent! # PruneOldAnnotationsWorker worker is idempotent in the scope of 24 hours - def perform - # Process is split into two jobs to avoid long running jobs, which are more prone to be disrupted - # mid work, which may cause some data not be delete, especially because cronjobs has retry option disabled - PruneOldAnnotationsWorker.perform_async - end + def perform; end end end end diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb index 63ca27d9c44..668542e51a5 100644 --- a/app/workers/metrics/dashboard/sync_dashboards_worker.rb +++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb @@ -13,14 +13,7 @@ module Metrics idempotent! - def perform(project_id) - project = Project.find(project_id) - dashboard_paths = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project) - - dashboard_paths.each do |dashboard_path| - ::Gitlab::Metrics::Dashboard::Importer.new(dashboard_path, project).execute - end - end + def perform(project_id); end end end end diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 07699a50e36..0e7f11debd2 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -28,5 +28,15 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker Issues::AfterCreateService .new(container: issuable.project, current_user: user) .execute(issuable) + + log_audit_event if user.project_bot? + end + + private + + def log_audit_event + # defined in EE end end + +NewIssueWorker.prepend_mod diff --git a/app/workers/object_storage/delete_stale_direct_uploads_worker.rb b/app/workers/object_storage/delete_stale_direct_uploads_worker.rb new file mode 100644 index 00000000000..0d4c9e12cb9 --- /dev/null +++ b/app/workers/object_storage/delete_stale_direct_uploads_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ObjectStorage + class DeleteStaleDirectUploadsWorker + include ApplicationWorker + + data_consistency :sticky + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + # TODO: Determine proper feature category for this, as object storage is a shared feature. + # For now, only build artifacts use this worker. + feature_category :build_artifacts + idempotent! + deduplicate :until_executed + + def perform + result = ObjectStorage::DeleteStaleDirectUploadsService.new.execute + + log_extra_metadata_on_done(:total_pending_entries, result[:total_pending_entries]) + log_extra_metadata_on_done(:total_deleted_stale_entries, result[:total_deleted_stale_entries]) + log_extra_metadata_on_done(:execution_timeout, result[:execution_timeout]) + end + end +end diff --git a/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb index 0b3d3c98742..4ace9a0e42e 100644 --- a/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb +++ b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb @@ -20,8 +20,6 @@ module Packages REDIS_EXPIRATION_TIME = 2.hours.to_i def perform - return unless enabled? - start_time dependency_id = last_processed_dependency_id @@ -44,10 +42,6 @@ module Packages private - def enabled? - Feature.enabled?(:packages_delete_orphaned_dependencies_worker) - end - def start_time @start_time ||= ::Gitlab::Metrics::System.monotonic_time end diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb index 8e7f0b3b987..0e21e98d182 100644 --- a/app/workers/packages/debian/process_package_file_worker.rb +++ b/app/workers/packages/debian/process_package_file_worker.rb @@ -19,7 +19,7 @@ module Packages @distribution_name = distribution_name @component_name = component_name - return unless package_file && distribution_name && component_name + return unless package_file # return if file has already been processed return unless package_file.debian_file_metadatum&.unknown? diff --git a/app/workers/packages/npm/create_metadata_cache_worker.rb b/app/workers/packages/npm/create_metadata_cache_worker.rb new file mode 100644 index 00000000000..0b6e34b13eb --- /dev/null +++ b/app/workers/packages/npm/create_metadata_cache_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Packages + module Npm + class CreateMetadataCacheWorker + include ApplicationWorker + + data_consistency :sticky + + queue_namespace :package_repositories + feature_category :package_registry + + deduplicate :until_executing + idempotent! + + def perform(project_id, package_name) + project = Project.find_by_id(project_id) + + return unless project && Feature.enabled?(:npm_metadata_cache, project) + + ::Packages::Npm::CreateMetadataCacheService + .new(project, package_name) + .execute + rescue StandardError => e + Gitlab::ErrorTracking.log_exception(e, project_id: project_id, package_name: package_name) + end + end + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 676a834d79d..4971dc3775f 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -36,6 +36,8 @@ class PostReceive process_project_changes(post_received, container) elsif repo_type.snippet? process_snippet_changes(post_received, container) + elsif repo_type.design? + process_design_management_repository_changes(post_received, container) else # Other repos don't have hooks for now end @@ -89,10 +91,23 @@ class PostReceive Snippets::UpdateStatisticsService.new(snippet).execute end + def process_design_management_repository_changes(post_received, design_management_repository) + user = identify_user(post_received) + + return false unless user + + replicate_design_management_repository_changes(design_management_repository) + expire_caches(post_received, design_management_repository.repository) + end + def replicate_snippet_changes(snippet) # Used by Gitlab Geo end + def replicate_design_management_repository_changes(design_management_repository) + # Used by GitLab Geo + end + # Expire the repository status, branch, and tag cache once per push. def expire_caches(post_received, repository) repository.expire_status_cache if repository.empty? diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb index 2e523ccc992..9ebc52f77d3 100644 --- a/app/workers/projects/record_target_platforms_worker.rb +++ b/app/workers/projects/record_target_platforms_worker.rb @@ -8,7 +8,7 @@ module Projects LEASE_TIMEOUT = 1.hour.to_i APPLE_PLATFORM_LANGUAGES = %w(swift objective-c).freeze - feature_category :projects + feature_category :groups_and_projects data_consistency :always deduplicate :until_executed urgency :low diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index 301f3720991..043a16e3527 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -5,7 +5,7 @@ class WebHookWorker include ApplicationWorker - feature_category :integrations + feature_category :webhooks loggable_arguments 2, 3 data_consistency :delayed sidekiq_options retry: 4, dead: false diff --git a/app/workers/web_hooks/log_destroy_worker.rb b/app/workers/web_hooks/log_destroy_worker.rb index 9ea5c70e416..d678b5536e7 100644 --- a/app/workers/web_hooks/log_destroy_worker.rb +++ b/app/workers/web_hooks/log_destroy_worker.rb @@ -7,7 +7,7 @@ module WebHooks DestroyError = Class.new(StandardError) data_consistency :always - feature_category :integrations + feature_category :webhooks urgency :low idempotent! diff --git a/app/workers/web_hooks/log_execution_worker.rb b/app/workers/web_hooks/log_execution_worker.rb index 280d987fa77..443cb6c0855 100644 --- a/app/workers/web_hooks/log_execution_worker.rb +++ b/app/workers/web_hooks/log_execution_worker.rb @@ -5,7 +5,7 @@ module WebHooks include ApplicationWorker data_consistency :always - feature_category :integrations + feature_category :webhooks urgency :low sidekiq_options retry: 3 loggable_arguments 0, 2, 3 |