diff options
Diffstat (limited to 'app')
1339 files changed, 16851 insertions, 9737 deletions
diff --git a/app/assets/images/aws-cloud-formation.png b/app/assets/images/aws-cloud-formation.png Binary files differdeleted file mode 100644 index 1d078309d86..00000000000 --- a/app/assets/images/aws-cloud-formation.png +++ /dev/null diff --git a/app/assets/images/vulnerability/kontra-logo.svg b/app/assets/images/vulnerability/kontra-logo.svg new file mode 100644 index 00000000000..e12e2545e77 --- /dev/null +++ b/app/assets/images/vulnerability/kontra-logo.svg @@ -0,0 +1 @@ +<svg enable-background="new 0 0 50 50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="10.2941" x2="50" y1="17.8247" y2="17.8247"><stop offset="0" stop-color="#f39c63"/><stop offset="1" stop-color="#ef5d4f"/></linearGradient><linearGradient id="b"><stop offset="0" stop-color="#231c4f"/><stop offset="1" stop-color="#0a0430"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="16.9118" x2="25" xlink:href="#b" y1="27.2046" y2="27.2046"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="27.9412" x2="33.0882" xlink:href="#b" y1="22.0575" y2="22.0575"/><g clip-rule="evenodd" fill-rule="evenodd"><path d="m31.94 41.71-31.94 8.41v-21.23c0-4.73 3.19-8.87 7.77-10.07l19.29-5.08c6.4-1.68 12.65 3.14 12.65 9.75v8.14c0 4.74-3.19 8.88-7.77 10.08z" fill="#2b6af9"/><path d="m50 0v21.23c0 4.73-3.19 8.87-7.77 10.07l-14.79 3.89c-8.67 2.28-17.15-4.26-17.15-13.22v-3.49c0-4.73 3.19-8.87 7.77-10.07z" fill="url(#a)"/><path d="m23.36 36.33 16.35-4.3v-8.16c0-6.83-6.46-11.81-13.06-10.07l-16.35 4.3v8.16c-.01 6.82 6.45 11.8 13.06 10.07z" fill="#fff"/><circle cx="20.96" cy="27.2" fill="url(#c)" r="4.04"/><circle cx="30.51" cy="22.06" fill="url(#d)" r="2.57"/></g></svg>
\ No newline at end of file diff --git a/app/assets/images/vulnerability/scw-logo.svg b/app/assets/images/vulnerability/scw-logo.svg new file mode 100644 index 00000000000..6d160ddc495 --- /dev/null +++ b/app/assets/images/vulnerability/scw-logo.svg @@ -0,0 +1 @@ +<svg enable-background="new 0 0 0 0" viewBox="0 0 800 780" xmlns="http://www.w3.org/2000/svg"><path d="m594.4 737.87c-.75-1.93-1.86-3.7-3.34-5.29-1.48-1.6-3.29-2.86-5.44-3.8-2.15-.93-4.62-1.4-7.41-1.4s-5.26.47-7.41 1.4c-2.14.94-3.96 2.21-5.43 3.8-1.48 1.59-2.59 3.36-3.34 5.29s-1.13 3.91-1.13 5.95v.96c0 1.96.36 3.91 1.1 5.86.73 1.96 1.82 3.74 3.28 5.35s3.27 2.91 5.43 3.89c2.17.98 4.67 1.46 7.5 1.46s5.33-.49 7.5-1.46c2.16-.98 3.98-2.28 5.43-3.89 1.46-1.61 2.55-3.4 3.28-5.35s1.1-3.91 1.1-5.86v-.96c.01-2.04-.37-4.02-1.12-5.95zm-4.01 13.45c-1.15 2.1-2.78 3.77-4.86 5.02-2.09 1.25-4.52 1.89-7.32 1.89-2.79 0-5.24-.63-7.32-1.89-2.09-1.25-3.71-2.93-4.86-5.02s-1.73-4.42-1.73-6.97c0-2.63.58-4.99 1.73-7.09 1.15-2.09 2.77-3.74 4.86-4.96 2.08-1.21 4.52-1.82 7.32-1.82 2.79 0 5.23.61 7.32 1.82 2.08 1.22 3.71 2.87 4.86 4.96 1.15 2.1 1.73 4.46 1.73 7.09 0 2.56-.58 4.88-1.73 6.97z" fill="#f79200"/><path d="m584.11 746.03c1.42-1.12 2.12-2.73 2.12-4.84s-.71-3.73-2.12-4.85c-1.42-1.11-3.4-1.67-5.95-1.67h-2.55-2.37-2.12v18.66h4.49v-5.62h2.55c.09 0 .17-.01.25-.01l3.81 5.64h5.1l-4.69-6.45c.54-.25 1.04-.52 1.48-.86zm-8.5-7.6h2.85c1.1 0 1.9.23 2.43.69s.79 1.15.79 2.07-.26 1.6-.79 2.06-1.33.68-2.43.68h-2.85z" fill="#f79200"/><path d="m333.8 753.41-17.62 7.9v-88.45s76.18-50.47 93.76-64.21c46.25-36.15 101.39-92.54 101.39-155.86v-22.33l40.76-32.62h-235.91v-83.73h280.18v138.69c0 180.72-251.84 295.81-262.56 300.61zm-17.53-638.54c21.22 2.88 121.7 20.25 195.07 97.32v62.85h85.03v-94.02l-10-11.68c-104.42-122.2-259.7-137.72-266.27-138.32l-3.92-.36v84.23c.03-.01.06-.01.09-.02z" fill="#ffbe12"/><path d="m316.18 397.85h-280.18v-216.82l9.99-11.68c104.42-122.21 259.7-137.73 266.25-138.33l3.94-.36v84.23c-21.06 2.97-122.24 20.87-195.17 97.32v74.53l-36.5 27.39h231.66v83.72zm-93.77 210.81c-46.27-36.15-101.4-87.26-101.4-143.31l20.24-28.41h-105.25v28.41c0 171.17 251.84 283.27 262.56 288.07l17.63 7.9v-88.45c-.01 0-76.2-50.47-93.78-64.21z" fill="#f79200"/></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue index 1fec186f2fa..561b2617c5f 100644 --- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue +++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue @@ -1,15 +1,31 @@ <script> -import { GlDatepicker, GlFormInput } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup } from '@gitlab/ui'; + +import { __ } from '~/locale'; export default { name: 'ExpiresAtField', - components: { GlDatepicker, GlFormInput }, + i18n: { + label: __('Expiration date'), + }, + components: { + GlDatepicker, + GlFormInput, + GlFormGroup, + MaxExpirationDateMessage: () => + import('ee_component/access_tokens/components/max_expiration_date_message.vue'), + }, props: { inputAttrs: { type: Object, required: false, default: () => ({}), }, + maxDate: { + type: Date, + required: false, + default: () => null, + }, }, data() { return { @@ -20,13 +36,18 @@ export default { </script> <template> - <gl-datepicker :target="null" :min-date="minDate"> - <gl-form-input - v-bind="inputAttrs" - class="datepicker gl-datepicker-input" - autocomplete="off" - inputmode="none" - data-qa-selector="expiry_date_field" - /> - </gl-datepicker> + <gl-form-group :label="$options.i18n.label" :label-for="inputAttrs.id"> + <gl-datepicker :target="null" :min-date="minDate" :max-date="maxDate"> + <gl-form-input + v-bind="inputAttrs" + class="datepicker gl-datepicker-input" + autocomplete="off" + inputmode="none" + data-qa-selector="expiry_date_field" + /> + </gl-datepicker> + <template #description> + <max-expiration-date-message :max-date="maxDate" /> + </template> + </gl-form-group> </template> diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue index 755991f64e0..10d4d62d803 100644 --- a/app/assets/javascripts/access_tokens/components/tokens_app.vue +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -100,6 +100,7 @@ export default { <gl-link :href="tokenData.resetPath" :data-confirm="$options.i18n[tokenType].resetConfirmMessage" + data-confirm-btn-variant="danger" data-method="put" >{{ content }}</gl-link > diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 9a1e7d877f8..c59bd445539 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -17,6 +17,7 @@ export const initExpiresAtField = () => { } const { expiresAt: inputAttrs } = parseRailsFormFields(el); + const { maxDate } = el.dataset; return new Vue({ el, @@ -24,6 +25,7 @@ export const initExpiresAtField = () => { return h(ExpiresAtField, { props: { inputAttrs, + maxDate: maxDate ? new Date(maxDate) : undefined, }, }); }, diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index 74e0e1b6225..7a78ccdb0cd 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -33,7 +33,7 @@ export default class Activities { errorCallback: () => createFlash({ message: s__( - 'Activity|An error occured while retrieving activity. Reload the page to try again.', + 'Activity|An error occurred while retrieving activity. Reload the page to try again.', ), parent: this.containerEl, }), diff --git a/app/assets/javascripts/admin/applications/components/delete_application.vue b/app/assets/javascripts/admin/applications/components/delete_application.vue new file mode 100644 index 00000000000..77694296b0a --- /dev/null +++ b/app/assets/javascripts/admin/applications/components/delete_application.vue @@ -0,0 +1,84 @@ +<script> +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlModal, + GlSprintf, + }, + data() { + return { + name: '', + path: '', + buttons: [], + }; + }, + mounted() { + this.buttons = document.querySelectorAll('.js-application-delete-button'); + + this.buttons.forEach((button) => button.addEventListener('click', this.buttonEvent)); + }, + destroy() { + this.buttons.forEach((button) => button.removeEventListener('click', this.buttonEvent)); + }, + methods: { + buttonEvent(e) { + e.preventDefault(); + this.show(e.target.dataset); + }, + show(dataset) { + const { name, path } = dataset; + + this.name = name; + this.path = path; + + this.$refs.deleteModal.show(); + }, + deleteApplication() { + this.$refs.deleteForm.submit(); + }, + }, + i18n: { + destroy: __('Destroy'), + title: __('Confirm destroy application'), + body: __('Are you sure that you want to destroy %{application}'), + }, + modal: { + actionPrimary: { + text: __('Destroy'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, + csrf, +}; +</script> +<template> + <gl-modal + ref="deleteModal" + :title="$options.i18n.title" + :action-primary="$options.modal.actionPrimary" + :action-secondary="$options.modal.actionSecondary" + modal-id="delete-application-modal" + size="sm" + @primary="deleteApplication" + ><gl-sprintf :message="$options.i18n.body"> + <template #application> + <strong>{{ name }}</strong> + </template></gl-sprintf + > + <form ref="deleteForm" method="post" :action="path"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/admin/applications/index.js b/app/assets/javascripts/admin/applications/index.js new file mode 100644 index 00000000000..5875fd18729 --- /dev/null +++ b/app/assets/javascripts/admin/applications/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import DeleteApplication from './components/delete_application.vue'; + +export default () => { + const el = document.querySelector('.js-application-delete-modal'); + + if (!el) return false; + + return new Vue({ + el, + render(h) { + return h(DeleteApplication); + }, + }); +}; diff --git a/app/assets/javascripts/admin/topics/components/remove_avatar.vue b/app/assets/javascripts/admin/topics/components/remove_avatar.vue new file mode 100644 index 00000000000..5e94d6185e0 --- /dev/null +++ b/app/assets/javascripts/admin/topics/components/remove_avatar.vue @@ -0,0 +1,67 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlButton, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['path'], + data() { + return { + modalId: uniqueId('remove-topic-avatar-'), + }; + }, + methods: { + deleteApplication() { + this.$refs.deleteForm.submit(); + }, + }, + i18n: { + remove: __('Remove avatar'), + title: __('Confirm remove avatar'), + body: __('Avatar will be removed. Are you sure?'), + }, + modal: { + actionPrimary: { + text: __('Remove'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, + csrf, +}; +</script> +<template> + <div> + <gl-button v-gl-modal="modalId" variant="danger" category="secondary" class="gl-mt-2">{{ + $options.i18n.remove + }}</gl-button> + <gl-modal + :title="$options.i18n.title" + :action-primary="$options.modal.actionPrimary" + :action-secondary="$options.modal.actionSecondary" + :modal-id="modalId" + size="sm" + @primary="deleteApplication" + >{{ $options.i18n.body }} + <form ref="deleteForm" method="post" :action="path"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + </form> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js new file mode 100644 index 00000000000..8fbcadf3369 --- /dev/null +++ b/app/assets/javascripts/admin/topics/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import RemoveAvatar from './components/remove_avatar.vue'; + +export default () => { + const el = document.querySelector('.js-remove-topic-avatar'); + + if (!el) { + return false; + } + + const { path } = el.dataset; + + return new Vue({ + el, + provide: { + path, + }, + render(h) { + return h(RemoveAvatar); + }, + }); +}; diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index f5d21ece138..829174d7593 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -69,7 +69,6 @@ export default { editButtonAttrs() { return { 'data-testid': 'edit', - icon: 'pencil-square', href: this.userPaths.edit, }; }, @@ -101,6 +100,7 @@ export default { <gl-button v-else v-gl-tooltip="$options.i18n.edit" + icon="pencil-square" v-bind="editButtonAttrs" :aria-label="$options.i18n.edit" /> @@ -108,10 +108,9 @@ export default { <div v-if="hasDropdownActions" class="gl-p-2"> <gl-dropdown + v-gl-tooltip="$options.i18n.userAdministration" data-testid="dropdown-toggle" - :text="$options.i18n.userAdministration" - :text-sr-only="!showButtonLabels" - icon="ellipsis_h" + icon="ellipsis_v" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" no-caret diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/admin/users/components/user_avatar.vue index ce22595609d..dd354794cf3 100644 --- a/app/assets/javascripts/admin/users/components/user_avatar.vue +++ b/app/assets/javascripts/admin/users/components/user_avatar.vue @@ -27,8 +27,6 @@ export default { return this.adminUserPath.replace('id', this.user.username); }, adminUserMailto() { - // NOTE: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives - // eslint-disable-next-line @gitlab/require-i18n-strings return `mailto:${this.user.email}`; }, userNoteShort() { diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 84c2b216859..929f5d10956 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -12,8 +12,8 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql'; +import { sortObjectToString } from '~/lib/utils/table_utility'; import { fetchPolicies } from '~/lib/graphql'; -import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; @@ -213,11 +213,8 @@ export default { }, methods: { fetchSortedData({ sortBy, sortDesc }) { - const sortingDirection = sortDesc ? 'DESC' : 'ASC'; - const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); - this.pagination = initialPaginationState; - this.sort = `${sortingColumn}_${sortingDirection}`; + this.sort = sortObjectToString({ sortBy, sortDesc }); }, navigateToAlertDetails({ iid }, index, { metaKey }) { return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey); diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 8c996b448aa..35fc64d43e5 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -92,6 +92,7 @@ const Api = { groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', notificationSettingsPath: '/api/:version/notification_settings', deployKeysPath: '/api/:version/deploy_keys', + secureFilesPath: '/api/:version/projects/:project_id/secure_files', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -957,6 +958,13 @@ const Api = { return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } }); }, + // TODO: replace this when GraphQL support has been added https://gitlab.com/gitlab-org/gitlab/-/issues/352184 + projectSecureFiles(projectId, options = {}) { + const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId); + + return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } }); + }, + async updateNotificationSettings(projectId, groupId, data = {}) { let url = Api.buildUrl(this.notificationSettingsPath); diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue new file mode 100644 index 00000000000..1542bc9a7e9 --- /dev/null +++ b/app/assets/javascripts/attention_requests/components/navigation_popover.vue @@ -0,0 +1,120 @@ +<script> +import { GlPopover, GlSprintf, GlButton, GlLink, GlIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; + +export default { + components: { + GlPopover, + GlSprintf, + GlButton, + GlLink, + GlIcon, + UserCalloutDismisser, + }, + inject: { + message: { + default: '', + }, + observerElSelector: { + default: '', + }, + observerElToggledClass: { + default: '', + }, + featureName: { + default: '', + }, + popoverTarget: { + default: '', + }, + showAttentionIcon: { + default: false, + }, + delay: { + default: 0, + }, + popoverCssClass: { + default: '', + }, + }, + data() { + return { + showPopover: false, + popoverPlacement: this.popoverPosition(), + }; + }, + mounted() { + this.observeEl = document.querySelector(this.observerElSelector); + this.observer = new MutationObserver(this.callback); + this.observer.observe(this.observeEl, { + attributes: true, + }); + this.callback(); + + window.addEventListener('resize', () => { + this.popoverPlacement = this.popoverPosition(); + }); + }, + beforeDestroy() { + this.observer.disconnect(); + }, + methods: { + callback() { + if (this.showPopover) { + this.$root.$emit('bv::hide::popover'); + } + + setTimeout(() => this.toggleShowPopover(), this.delay); + }, + toggleShowPopover() { + this.showPopover = this.observeEl.classList.contains(this.observerElToggledClass); + }, + getPopoverTarget() { + return document.querySelector(this.popoverTarget); + }, + popoverPosition() { + if (bp.isDesktop()) { + return 'left'; + } + + return 'bottom'; + }, + }, + docsPage: helpPagePath('development/code_review.html'), +}; +</script> + +<template> + <user-callout-dismisser :feature-name="featureName"> + <template #default="{ shouldShowCallout, dismiss }"> + <gl-popover + v-if="shouldShowCallout" + :show-close-button="false" + :target="() => getPopoverTarget()" + :show="showPopover" + :delay="0" + triggers="manual" + :placement="popoverPlacement" + boundary="window" + no-fade + :css-classes="[popoverCssClass]" + > + <p v-for="(m, index) in message" :key="index" class="gl-mb-5"> + <gl-sprintf :message="m"> + <template #strong="{ content }"> + <strong><gl-icon v-if="showAttentionIcon" name="attention" /> {{ content }}</strong> + </template> + </gl-sprintf> + </p> + <div class="gl-display-flex gl-align-items-center"> + <gl-button size="small" variant="confirm" class="gl-mr-5" @click.prevent.stop="dismiss"> + {{ __('Got it!') }} + </gl-button> + <gl-link :href="$options.docsPage" target="_blank">{{ __('Learn more') }}</gl-link> + </div> + </gl-popover> + </template> + </user-callout-dismisser> +</template> diff --git a/app/assets/javascripts/attention_requests/index.js b/app/assets/javascripts/attention_requests/index.js new file mode 100644 index 00000000000..2a142ab46e5 --- /dev/null +++ b/app/assets/javascripts/attention_requests/index.js @@ -0,0 +1,73 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { __ } from '~/locale'; +import createDefaultClient from '~/lib/graphql'; +import NavigationPopover from './components/navigation_popover.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initTopNavPopover = () => { + const el = document.getElementById('js-need-attention-nav-onboarding'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + provide: { + observerElSelector: '.user-counter.dropdown', + observerElToggledClass: 'show', + message: [ + __( + '%{strongStart}Need your attention%{strongEnd} are the merge requests that need your help to move forward, as an assignee or reviewer.', + ), + ], + featureName: 'attention_requests_top_nav', + popoverTarget: '#js-need-attention-nav', + }, + render(h) { + return h(NavigationPopover); + }, + }); +}; + +export const initSideNavPopover = () => { + const el = document.getElementById('js-need-attention-sidebar-onboarding'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + provide: { + observerElSelector: '.js-right-sidebar', + observerElToggledClass: 'right-sidebar-expanded', + message: [ + __( + 'To ask someone to look at a merge request, select %{strongStart}Request attention%{strongEnd}. Select again to remove the request.', + ), + __( + 'Some actions remove attention requests, like a reviewer approving or anyone merging the merge request.', + ), + ], + featureName: 'attention_requests_side_nav', + popoverTarget: '.js-attention-request-toggle', + showAttentionIcon: true, + delay: 500, + popoverCssClass: 'attention-request-sidebar-popover', + }, + render(h) { + return h(NavigationPopover); + }, + }); +}; + +export default () => { + initTopNavPopover(); +}; diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js index eeda2bfaeaf..2a0740cf488 100644 --- a/app/assets/javascripts/authentication/webauthn/util.js +++ b/app/assets/javascripts/authentication/webauthn/util.js @@ -46,6 +46,17 @@ export const bufferToBase64 = (input) => { }; /** + * Return a URL-safe base64 string. + * + * RFC: https://datatracker.ietf.org/doc/html/rfc4648#section-5 + * @param {String} base64Str + * @returns {String} + */ +export const base64ToBase64Url = (base64Str) => { + return base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +/** * Returns a copy of the given object with the id property converted to buffer * * @param {Object} param diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js index b512e4dbc8b..165031c3e7d 100644 --- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -48,54 +48,48 @@ import Video from './nodes/video'; // from GFM should have a node or mark here. // The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb. -export default [ - new Doc(), - new Paragraph(), - new Text(), +export default { + nodes: [ + Doc(), + Paragraph(), + Text(), - new Blockquote(), - new CodeBlock(), - new HardBreak(), - new Heading({ maxLevel: 6 }), - new HorizontalRule(), - new Image(), + Blockquote(), + CodeBlock(), + HardBreak(), + Heading(), + HorizontalRule(), + Image(), - new Table(), - new TableHead(), - new TableBody(), - new TableHeaderRow(), - new TableRow(), - new TableCell(), + Table(), + TableHead(), + TableBody(), + TableHeaderRow(), + TableRow(), + TableCell(), - new Emoji(), - new Reference(), + Emoji(), + Reference(), - new TableOfContents(), - new Video(), - new Audio(), + TableOfContents(), + Video(), + Audio(), - new BulletList(), - new OrderedList(), - new ListItem(), + BulletList(), + OrderedList(), + ListItem(), - new DescriptionList(), - new DescriptionTerm(), - new DescriptionDetails(), + DescriptionList(), + DescriptionTerm(), + DescriptionDetails(), - new TaskList(), - new OrderedTaskList(), - new TaskListItem(), + TaskList(), + OrderedTaskList(), + TaskListItem(), - new Summary(), - new Details(), + Summary(), + Details(), + ], - new Bold(), - new Italic(), - new Strike(), - new InlineDiff(), - - new Link(), - new Code(), - new MathMark(), - new InlineHTML(), -]; + marks: [Bold(), Italic(), Strike(), InlineDiff(), Link(), Code(), MathMark(), InlineHTML()], +}; diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js index 89e373220af..dd730947a5f 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/bold.js +++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js @@ -1,11 +1,17 @@ -/* eslint-disable class-methods-use-this */ - -import { Bold as BaseBold } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Bold extends BaseBold { - get toMarkdown() { - return defaultMarkdownSerializer.marks.strong; - } -} +export default () => { + return { + name: 'bold', + schema: { + parseDOM: [ + { + tag: 'strong', + }, + ], + toDOM: () => ['strong', 0], + }, + toMarkdown: defaultMarkdownSerializer.marks.strong, + }; +}; diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js index 68368dec676..ea5af8b4a1f 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/code.js +++ b/app/assets/javascripts/behaviors/markdown/marks/code.js @@ -1,11 +1,12 @@ -/* eslint-disable class-methods-use-this */ - -import { Code as BaseCode } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Code extends BaseCode { - get toMarkdown() { - return defaultMarkdownSerializer.marks.code; - } -} +export default () => ({ + name: 'code', + schema: { + excludes: '_', + parseDOM: [{ tag: 'code' }], + toDOM: () => ['code', 0], + }, + toMarkdown: defaultMarkdownSerializer.marks.code, +}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js index 7f1506cd5d9..69d345c81e4 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js @@ -1,41 +1,29 @@ -/* eslint-disable class-methods-use-this */ - -import { Mark } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter -export default class InlineDiff extends Mark { - get name() { - return 'inline_diff'; - } - - get schema() { - return { - attrs: { - addition: { - default: true, - }, +export default () => ({ + name: 'inline_diff', + schema: { + attrs: { + addition: { + default: true, }, - parseDOM: [ - { tag: 'span.idiff.addition', attrs: { addition: true } }, - { tag: 'span.idiff.deletion', attrs: { addition: false } }, - ], - toDOM: (node) => [ - 'span', - { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` }, - 0, - ], - }; - } - - get toMarkdown() { - return { - mixable: true, - open(state, mark) { - return mark.attrs.addition ? '{+' : '{-'; - }, - close(state, mark) { - return mark.attrs.addition ? '+}' : '-}'; - }, - }; - } -} + }, + parseDOM: [ + { tag: 'span.idiff.addition', attrs: { addition: true } }, + { tag: 'span.idiff.deletion', attrs: { addition: false } }, + ], + toDOM: (node) => [ + 'span', + { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` }, + 0, + ], + }, + toMarkdown: { + mixable: true, + open(_, mark) { + return mark.attrs.addition ? '{+' : '{-'; + }, + close(_, mark) { + return mark.attrs.addition ? '+}' : '-}'; + }, + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js index e957f81b774..4520598e0ab 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js @@ -1,46 +1,35 @@ -/* eslint-disable class-methods-use-this */ - import { escape } from 'lodash'; -import { Mark } from 'tiptap'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class InlineHTML extends Mark { - get name() { - return 'inline_html'; - } - - get schema() { - return { - excludes: '', - attrs: { - tag: {}, - title: { default: null }, - }, - parseDOM: [ - { - tag: 'sup, sub, kbd, q, samp, var', - getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }), - }, - { - tag: 'abbr', - getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }), - }, - ], - toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0], - }; - } - - get toMarkdown() { - return { - mixable: true, - open(state, mark) { - return `<${mark.attrs.tag}${ - mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : '' - }>`; +export default () => ({ + name: 'inline_html', + schema: { + excludes: '', + attrs: { + tag: {}, + title: { default: null }, + }, + parseDOM: [ + { + tag: 'sup, sub, kbd, q, samp, var', + getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }), }, - close(state, mark) { - return `</${mark.attrs.tag}>`; + { + tag: 'abbr', + getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }), }, - }; - } -} + ], + toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0], + }, + toMarkdown: { + mixable: true, + open(state, mark) { + return `<${mark.attrs.tag}${ + mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : '' + }>`; + }, + close(_, mark) { + return `</${mark.attrs.tag}>`; + }, + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js index 7dc86102f18..3ec8f0071e9 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/italic.js +++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js @@ -1,11 +1,11 @@ -/* eslint-disable class-methods-use-this */ - -import { Italic as BaseItalic } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Italic extends BaseItalic { - get toMarkdown() { - return defaultMarkdownSerializer.marks.em; - } -} +export default () => ({ + name: 'italic', + schema: { + parseDOM: [{ tag: 'em' }], + toDOM: () => ['em', 0], + }, + toMarkdown: defaultMarkdownSerializer.marks.em, +}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js index b5e09017d83..977453fee01 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/link.js +++ b/app/assets/javascripts/behaviors/markdown/marks/link.js @@ -1,21 +1,47 @@ -/* eslint-disable class-methods-use-this */ - -import { Link as BaseLink } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Link extends BaseLink { - get toMarkdown() { - return { - mixable: true, - open(state, mark, parent, index) { - const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index); - return open === '<' ? '' : open; +export default () => ({ + name: 'link', + schema: { + attrs: { + href: { + default: null, + }, + target: { + default: null, + }, + }, + inclusive: false, + parseDOM: [ + { + tag: 'a[href]', + getAttrs: (dom) => ({ + href: dom.getAttribute('href'), + target: dom.getAttribute('target'), + }), }, - close(state, mark, parent, index) { - const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index); - return close === '>' ? '' : close; + ], + toDOM: (node) => [ + 'a', + { + ...node.attrs, + // eslint-disable-next-line @gitlab/require-i18n-strings + rel: 'noopener noreferrer nofollow', + target: node.attrs.target, }, - }; - } -} + 0, + ], + }, + toMarkdown: { + mixable: true, + open(state, mark, parent, index) { + const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index); + return open === '<' ? '' : open; + }, + close(state, mark, parent, index) { + const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index); + return close === '>' ? '' : close; + }, + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js index ca25ff7d07d..a50a649b6eb 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/math.js +++ b/app/assets/javascripts/behaviors/markdown/marks/math.js @@ -1,42 +1,31 @@ -/* eslint-disable class-methods-use-this */ - -import { Mark } from 'tiptap'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::MathFilter -export default class MathMark extends Mark { - get name() { - return 'math'; - } - - get schema() { - return { - parseDOM: [ - // Matches HTML generated by Banzai::Filter::MathFilter - { - tag: 'code.code.math[data-math-style=inline]', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js - { - tag: 'span.katex', - contentElement: 'annotation[encoding="application/x-tex"]', - }, - ], - toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0], - }; - } - - get toMarkdown() { - return { - escape: false, - open(state, mark, parent, index) { - return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`; +export default () => ({ + name: 'math', + schema: { + parseDOM: [ + // Matches HTML generated by Banzai::Filter::MathFilter + { + tag: 'code.code.math[data-math-style=inline]', + priority: HIGHER_PARSE_RULE_PRIORITY, }, - close(state, mark, parent, index) { - return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`; + // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex', + contentElement: 'annotation[encoding="application/x-tex"]', }, - }; - } -} + ], + toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0], + }, + toMarkdown: { + escape: false, + open(state, mark, parent, index) { + return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`; + }, + close(state, mark, parent, index) { + return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`; + }, + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js index c2951a40a4b..967c0a120cd 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/strike.js +++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js @@ -1,15 +1,18 @@ -/* eslint-disable class-methods-use-this */ - -import { Strike as BaseStrike } from 'tiptap-extensions'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Strike extends BaseStrike { - get toMarkdown() { - return { - open: '~~', - close: '~~', - mixable: true, - expelEnclosingWhitespace: true, - }; - } -} +export default () => ({ + name: 'strike', + schema: { + parseDOM: [ + { + tag: 'del', + }, + ], + toDOM: () => ['s', 0], + }, + toMarkdown: { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js index 146349b118c..97ab86c6d23 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/audio.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js @@ -1,9 +1,4 @@ -import Playable from './playable'; +import playable from './playable'; // Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter -export default class Audio extends Playable { - constructor() { - super(); - this.mediaType = 'audio'; - } -} +export default () => playable({ mediaType: 'audio' }); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js index 8b14a04e2fe..6a4552d47e4 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js @@ -1,13 +1,19 @@ -/* eslint-disable class-methods-use-this */ - -import { Blockquote as BaseBlockquote } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Blockquote extends BaseBlockquote { +export default () => ({ + name: 'blockquote', + schema: { + content: 'block*', + group: 'block', + defining: true, + draggable: false, + parseDOM: [{ tag: 'blockquote' }], + toDOM: () => ['blockquote', 0], + }, toMarkdown(state, node) { if (!node.childCount) return; defaultMarkdownSerializer.nodes.blockquote(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js index ef1eafaa419..95cd3605da5 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js @@ -1,11 +1,15 @@ -/* eslint-disable class-methods-use-this */ - -import { BulletList as BaseBulletList } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class BulletList extends BaseBulletList { +export default () => ({ + name: 'bullet_list', + schema: { + content: 'list_item+', + group: 'block', + parseDOM: [{ tag: 'ul' }], + toDOM: () => ['ul', 0], + }, toMarkdown(state, node) { defaultMarkdownSerializer.nodes.bullet_list(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js index cd90d67c60d..0ff59779e7d 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js @@ -1,7 +1,3 @@ -/* eslint-disable class-methods-use-this */ - -import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions'; - const PLAINTEXT_LANG = 'plaintext'; // Transforms generated HTML back to GFM for: @@ -9,68 +5,67 @@ const PLAINTEXT_LANG = 'plaintext'; // - Banzai::Filter::MathFilter // - Banzai::Filter::MermaidFilter // - Banzai::Filter::SuggestionFilter -export default class CodeBlock extends BaseCodeBlock { - get schema() { - return { - content: 'text*', - marks: '', - group: 'block', - code: true, - defining: true, - attrs: { - lang: { default: PLAINTEXT_LANG }, - }, - parseDOM: [ - // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter - { - tag: 'pre.code.highlight', - preserveWhitespace: 'full', - getAttrs: (el) => { - const lang = el.getAttribute('lang'); - if (!lang || lang === '') return {}; +export default () => ({ + name: 'code_block', + schema: { + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + attrs: { + lang: { default: PLAINTEXT_LANG }, + }, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter + { + tag: 'pre.code.highlight', + preserveWhitespace: 'full', + getAttrs: (el) => { + const lang = el.getAttribute('lang'); + if (!lang || lang === '') return {}; - return { lang }; - }, - }, - // Matches HTML generated by Banzai::Filter::MathFilter, - // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js - { - tag: 'span.katex-display', - preserveWhitespace: 'full', - contentElement: 'annotation[encoding="application/x-tex"]', - attrs: { lang: 'math' }, - }, - // Matches HTML generated by Banzai::Filter::MermaidFilter, - // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js - { - tag: 'svg.mermaid', - preserveWhitespace: 'full', - contentElement: 'text.source', - attrs: { lang: 'mermaid' }, - }, - // Matches HTML generated by Banzai::Filter::SuggestionFilter, - // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue - { - tag: '.md-suggestion', - skip: true, - }, - { - tag: '.md-suggestion-header', - ignore: true, + return { lang }; }, - { - tag: '.md-suggestion-diff', - preserveWhitespace: 'full', - getContent: (el, schema) => - [...el.querySelectorAll('.line_content.new span')].map((span) => - schema.text(span.innerText), - ), - attrs: { lang: 'suggestion' }, - }, - ], - toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]], - }; - } + }, + // Matches HTML generated by Banzai::Filter::MathFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex-display', + preserveWhitespace: 'full', + contentElement: 'annotation[encoding="application/x-tex"]', + attrs: { lang: 'math' }, + }, + // Matches HTML generated by Banzai::Filter::MermaidFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js + { + tag: 'svg.mermaid', + preserveWhitespace: 'full', + contentElement: 'text.source', + attrs: { lang: 'mermaid' }, + }, + // Matches HTML generated by Banzai::Filter::SuggestionFilter, + // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue + { + tag: '.md-suggestion', + skip: true, + }, + { + tag: '.md-suggestion-header', + ignore: true, + }, + { + tag: '.md-suggestion-diff', + preserveWhitespace: 'full', + getContent: (el, schema) => + [...el.querySelectorAll('.line_content.new span')].map((span) => + schema.text(span.innerText), + ), + attrs: { lang: 'suggestion' }, + }, + ], + toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]], + }, toMarkdown(state, node) { if (!node.childCount) return; @@ -95,5 +90,5 @@ export default class CodeBlock extends BaseCodeBlock { state.write('```'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js index a4451d8ce8d..20760286045 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js @@ -1,22 +1,14 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class DescriptionDetails extends Node { - get name() { - return 'description_details'; - } +export default () => ({ + name: 'description_details', - get schema() { - return { - content: 'text*', - marks: '', - defining: true, - parseDOM: [{ tag: 'dd' }], - toDOM: () => ['dd', 0], - }; - } + schema: { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dd' }], + toDOM: () => ['dd', 0], + }, toMarkdown(state, node) { state.flushClose(1); @@ -24,5 +16,5 @@ export default class DescriptionDetails extends Node { state.text(node.textContent, false); state.write('</dd>'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js index 6aa1aca29d7..c5305c48423 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js @@ -1,21 +1,12 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class DescriptionList extends Node { - get name() { - return 'description_list'; - } - - get schema() { - return { - content: '(description_term+ description_details+)+', - group: 'block', - parseDOM: [{ tag: 'dl' }], - toDOM: () => ['dl', 0], - }; - } +export default () => ({ + name: 'description_list', + schema: { + content: '(description_term+ description_details+)+', + group: 'block', + parseDOM: [{ tag: 'dl' }], + toDOM: () => ['dl', 0], + }, toMarkdown(state, node) { state.write('<dl>\n'); @@ -24,5 +15,5 @@ export default class DescriptionList extends Node { state.ensureNewLine(); state.write('</dl>'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js index 89057ec6444..f78f7f13fc4 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js @@ -1,28 +1,18 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class DescriptionTerm extends Node { - get name() { - return 'description_term'; - } - - get schema() { - return { - content: 'text*', - marks: '', - defining: true, - parseDOM: [{ tag: 'dt' }], - toDOM: () => ['dt', 0], - }; - } - +export default () => ({ + name: 'description_term', + schema: { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dt' }], + toDOM: () => ['dt', 0], + }, toMarkdown(state, node) { state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2); state.write('<dt>'); state.text(node.textContent, false); state.write('</dt>'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js index 1c40dbb8168..9fb0d60b93a 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/details.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js @@ -1,22 +1,12 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Details extends Node { - get name() { - return 'details'; - } - - get schema() { - return { - content: 'summary block*', - group: 'block', - parseDOM: [{ tag: 'details' }], - toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0], - }; - } - +export default () => ({ + name: 'details', + schema: { + content: 'summary block*', + group: 'block', + parseDOM: [{ tag: 'details' }], + toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0], + }, toMarkdown(state, node) { state.write('<details>\n'); state.renderContent(node); @@ -24,5 +14,5 @@ export default class Details extends Node { state.ensureNewLine(); state.write('</details>'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js index 88b16fd85da..3101e6e0e3a 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/doc.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js @@ -1,15 +1,6 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - -export default class Doc extends Node { - get name() { - return 'doc'; - } - - get schema() { - return { - content: 'block+', - }; - } -} +export default () => ({ + name: 'doc', + schema: { + content: 'block+', + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js index 9d0890aa1b4..086c277bad4 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js @@ -1,53 +1,43 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter -export default class Emoji extends Node { - get name() { - return 'emoji'; - } - - get schema() { - return { - inline: true, - group: 'inline', - attrs: { - name: {}, - title: {}, - moji: {}, +export default () => ({ + name: 'emoji', + schema: { + inline: true, + group: 'inline', + attrs: { + name: {}, + title: {}, + moji: {}, + }, + parseDOM: [ + { + tag: 'gl-emoji', + getAttrs: (el) => ({ + name: el.dataset.name, + title: el.getAttribute('title'), + moji: el.textContent, + }), }, - parseDOM: [ - { - tag: 'gl-emoji', - getAttrs: (el) => ({ - name: el.dataset.name, - title: el.getAttribute('title'), - moji: el.textContent, - }), - }, - { - tag: 'img.emoji', - getAttrs: (el) => { - const name = el.getAttribute('title').replace(/^:|:$/g, ''); + { + tag: 'img.emoji', + getAttrs: (el) => { + const name = el.getAttribute('title').replace(/^:|:$/g, ''); - return { - name, - title: name, - moji: name, - }; - }, + return { + name, + title: name, + moji: name, + }; }, - ], - toDOM: (node) => [ - 'gl-emoji', - { 'data-name': node.attrs.name, title: node.attrs.title }, - node.attrs.moji, - ], - }; - } - + }, + ], + toDOM: (node) => [ + 'gl-emoji', + { 'data-name': node.attrs.name, title: node.attrs.title }, + node.attrs.moji, + ], + }, toMarkdown(state, node) { state.write(`:${node.attrs.name}:`); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js index 59e5d8ab3e2..1668af9c3f4 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js @@ -1,10 +1,14 @@ -/* eslint-disable class-methods-use-this */ - -import { HardBreak as BaseHardBreak } from 'tiptap-extensions'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class HardBreak extends BaseHardBreak { +export default () => ({ + name: 'hard_break', + schema: { + inline: true, + group: 'inline', + selectable: false, + parseDOM: [{ tag: 'br' }], + toDOM: () => ['br'], + }, toMarkdown(state) { if (!state.atBlank()) state.write(' \n'); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js index 29967e61ffa..21b4ec69b70 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js @@ -1,13 +1,27 @@ -/* eslint-disable class-methods-use-this */ - -import { Heading as BaseHeading } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Heading extends BaseHeading { +export default ({ levels = [1, 2, 3, 4, 5, 6] } = {}) => ({ + name: 'heading', + schema: { + attrs: { + level: { + default: 1, + }, + }, + content: 'inline*', + group: 'block', + defining: true, + draggable: false, + parseDOM: levels.map((level) => ({ + tag: `h${level}`, + attrs: { level }, + })), + toDOM: (node) => [`h${node.attrs.level}`, 0], + }, toMarkdown(state, node) { if (!node.childCount) return; defaultMarkdownSerializer.nodes.heading(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js index ee3aa145dc3..2d7074e567f 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js @@ -1,11 +1,14 @@ -/* eslint-disable class-methods-use-this */ - -import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class HorizontalRule extends BaseHorizontalRule { +export default () => ({ + name: 'horizontal_rule', + schema: { + group: 'block', + parseDOM: [{ tag: 'hr' }], + toDOM: () => ['hr'], + }, toMarkdown(state, node) { defaultMarkdownSerializer.nodes.horizontal_rule(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js index 16647d2f96e..370cc347a05 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/image.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -1,53 +1,48 @@ -/* eslint-disable class-methods-use-this */ - -import { Image as BaseImage } from 'tiptap-extensions'; import { placeholderImage } from '~/lazy_loader'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; -export default class Image extends BaseImage { - get schema() { - return { - attrs: { - src: {}, - alt: { - default: null, - }, - title: { - default: null, - }, +export default () => ({ + name: 'image', + schema: { + attrs: { + src: {}, + alt: { + default: null, }, - group: 'inline', - inline: true, - draggable: true, - parseDOM: [ - // Matches HTML generated by Banzai::Filter::ImageLinkFilter - { - tag: 'a.no-attachment-icon', - priority: HIGHER_PARSE_RULE_PRIORITY, - skip: true, - }, - // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter - { - tag: 'img[src]:not(.emoji)', - getAttrs: (el) => { - const imageSrc = el.src; - const imageUrl = - imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; + title: { + default: null, + }, + }, + group: 'inline', + inline: true, + draggable: true, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::ImageLinkFilter + { + tag: 'a.no-attachment-icon', + priority: HIGHER_PARSE_RULE_PRIORITY, + skip: true, + }, + // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter + { + tag: 'img[src]:not(.emoji)', + getAttrs: (el) => { + const imageSrc = el.src; + const imageUrl = + imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; - return { - src: imageUrl, - title: el.getAttribute('title'), - alt: el.getAttribute('alt'), - }; - }, + return { + src: imageUrl, + title: el.getAttribute('title'), + alt: el.getAttribute('alt'), + }; }, - ], - toDOM: (node) => ['img', node.attrs], - }; - } - + }, + ], + toDOM: (node) => ['img', node.attrs], + }, toMarkdown(state, node) { defaultMarkdownSerializer.nodes.image(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js index 7204b7c09ba..97c1f07427d 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js @@ -1,11 +1,16 @@ -/* eslint-disable class-methods-use-this */ - -import { ListItem as BaseListItem } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class ListItem extends BaseListItem { +export default () => ({ + name: 'list_item', + schema: { + content: 'paragraph block*', + defining: true, + draggable: false, + parseDOM: [{ tag: 'li' }], + toDOM: () => ['li', 0], + }, toMarkdown(state, node) { defaultMarkdownSerializer.nodes.list_item(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js index 4c1542d14ea..f2f3eff266a 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js @@ -1,10 +1,25 @@ -/* eslint-disable class-methods-use-this */ - -import { OrderedList as BaseOrderedList } from 'tiptap-extensions'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class OrderedList extends BaseOrderedList { +export default () => ({ + name: 'ordered_list', + schema: { + attrs: { + order: { + default: 1, + }, + }, + content: 'list_item+', + group: 'block', + parseDOM: [ + { + tag: 'ol', + getAttrs: (dom) => ({ + order: dom.hasAttribute('start') ? dom.getAttribute('start') + 1 : 1, + }), + }, + ], + toDOM: (node) => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]), + }, toMarkdown(state, node) { state.renderList(node, ' ', () => '1. '); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js index a28d7be3758..53a6a0d9e07 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js @@ -1,29 +1,21 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter -export default class OrderedTaskList extends Node { - get name() { - return 'ordered_task_list'; - } - - get schema() { - return { - group: 'block', - content: '(task_list_item|list_item)+', - parseDOM: [ - { - priority: HIGHER_PARSE_RULE_PRIORITY, - tag: 'ol.task-list', - }, - ], - toDOM: () => ['ol', { class: 'task-list' }, 0], - }; - } +export default () => ({ + name: 'ordered_task_list', + schema: { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: HIGHER_PARSE_RULE_PRIORITY, + tag: 'ol.task-list', + }, + ], + toDOM: () => ['ol', { class: 'task-list' }, 0], + }, toMarkdown(state, node) { state.renderList(node, ' ', () => '1. '); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js index 5fd098cd46f..310feebb390 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js @@ -1,24 +1,15 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Paragraph extends Node { - get name() { - return 'paragraph'; - } - - get schema() { - return { - content: 'inline*', - group: 'block', - parseDOM: [{ tag: 'p' }], - toDOM: () => ['p', 0], - }; - } - +export default () => ({ + name: 'paragraph', + schema: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }, toMarkdown(state, node) { defaultMarkdownSerializer.nodes.paragraph(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js index 90cbaf9ef4c..7559c2a6a8a 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js @@ -1,7 +1,3 @@ -/* eslint-disable class-methods-use-this */ -/* eslint-disable @gitlab/require-i18n-strings */ - -import { Node } from 'tiptap'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; /** @@ -10,62 +6,51 @@ import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer * the `mediaType` property in their constructors. * @abstract */ -export default class Playable extends Node { - constructor() { - super(); - this.mediaType = ''; - this.extraElementAttrs = {}; - } - - get name() { - return this.mediaType; - } - - get schema() { - const attrs = { - src: {}, - alt: { - default: null, - }, - }; - - const parseDOM = [ +export default ({ mediaType, extraElementAttrs = {} }) => { + const attrs = { + src: {}, + alt: { + default: null, + }, + }; + const parseDOM = [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + tag: `.${mediaType}-container`, + getAttrs: (el) => ({ + src: el.querySelector(mediaType).src, + alt: el.querySelector(mediaType).dataset.title, + }), + }, + ]; + const toDOM = (node) => [ + 'span', + { class: `media-container ${mediaType}-container` }, + [ + mediaType, { - tag: `.${this.mediaType}-container`, - getAttrs: (el) => ({ - src: el.querySelector(this.mediaType).src, - alt: el.querySelector(this.mediaType).dataset.title, - }), + src: node.attrs.src, + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + ...extraElementAttrs, }, - ]; - - const toDOM = (node) => [ - 'span', - { class: `media-container ${this.mediaType}-container` }, - [ - this.mediaType, - { - src: node.attrs.src, - controls: true, - 'data-setup': '{}', - 'data-title': node.attrs.alt, - ...this.extraElementAttrs, - }, - ], - ['a', { href: node.attrs.src }, node.attrs.alt], - ]; + ], + ['a', { href: node.attrs.src }, node.attrs.alt], + ]; - return { + return { + name: mediaType, + schema: { attrs, group: 'inline', inline: true, draggable: true, parseDOM, toDOM, - }; - } - - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.image(state, node); - } -} + }, + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + }, + }; +}; diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js index dd82ea58ea5..9ae6ab07004 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/reference.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js @@ -1,53 +1,44 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses -export default class Reference extends Node { - get name() { - return 'reference'; - } - - get schema() { - return { - inline: true, - group: 'inline', - atom: true, - attrs: { - className: {}, - referenceType: {}, - originalText: { default: null }, - href: {}, - text: {}, +export default () => ({ + name: 'reference', + schema: { + inline: true, + group: 'inline', + atom: true, + attrs: { + className: {}, + referenceType: {}, + originalText: { default: null }, + href: {}, + text: {}, + }, + parseDOM: [ + { + tag: 'a.gfm:not([data-link=true])', + priority: HIGHER_PARSE_RULE_PRIORITY, + getAttrs: (el) => ({ + className: el.className, + referenceType: el.dataset.referenceType, + originalText: el.dataset.original, + href: el.getAttribute('href'), + text: el.textContent, + }), }, - parseDOM: [ - { - tag: 'a.gfm:not([data-link=true])', - priority: HIGHER_PARSE_RULE_PRIORITY, - getAttrs: (el) => ({ - className: el.className, - referenceType: el.dataset.referenceType, - originalText: el.dataset.original, - href: el.getAttribute('href'), - text: el.textContent, - }), - }, - ], - toDOM: (node) => [ - 'a', - { - class: node.attrs.className, - href: node.attrs.href, - 'data-reference-type': node.attrs.referenceType, - 'data-original': node.attrs.originalText, - }, - node.attrs.text, - ], - }; - } - + ], + toDOM: (node) => [ + 'a', + { + class: node.attrs.className, + href: node.attrs.href, + 'data-reference-type': node.attrs.referenceType, + 'data-original': node.attrs.originalText, + }, + node.attrs.text, + ], + }, toMarkdown(state, node) { state.write(node.attrs.originalText || node.attrs.text); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js index 2e36e316d71..eb91b3c981e 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/summary.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js @@ -1,27 +1,17 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Summary extends Node { - get name() { - return 'summary'; - } - - get schema() { - return { - content: 'text*', - marks: '', - defining: true, - parseDOM: [{ tag: 'summary' }], - toDOM: () => ['summary', 0], - }; - } - +export default () => ({ + name: 'summary', + schema: { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'summary' }], + toDOM: () => ['summary', 0], + }, toMarkdown(state, node) { state.write('<summary>'); state.text(node.textContent, false); state.write('</summary>'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js index a7fcb9227cd..c766f7f1fba 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js @@ -1,25 +1,15 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class Table extends Node { - get name() { - return 'table'; - } - - get schema() { - return { - content: 'table_head table_body', - group: 'block', - isolating: true, - parseDOM: [{ tag: 'table' }], - toDOM: () => ['table', 0], - }; - } - +export default () => ({ + name: 'table', + schema: { + content: 'table_head table_body', + group: 'block', + isolating: true, + parseDOM: [{ tag: 'table' }], + toDOM: () => ['table', 0], + }, toMarkdown(state, node) { state.renderContent(node); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js index 403556dc0c8..0a49fb558ae 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js @@ -1,24 +1,14 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class TableBody extends Node { - get name() { - return 'table_body'; - } - - get schema() { - return { - content: 'table_row+', - parseDOM: [{ tag: 'tbody' }], - toDOM: () => ['tbody', 0], - }; - } - - toMarkdown(state, node) { +export default () => ({ + name: 'table_body', + schema: { + content: 'table_row+', + parseDOM: [{ tag: 'tbody' }], + toDOM: () => ['tbody', 0], + }, + toMarkdown: (state, node) => { state.flushClose(1); state.renderContent(node); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js index ebb66cd4da5..f46344ba43c 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js @@ -1,35 +1,25 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class TableCell extends Node { - get name() { - return 'table_cell'; - } - - get schema() { - return { - attrs: { - header: { default: false }, - align: { default: null }, +export default () => ({ + name: 'table_cell', + schema: { + attrs: { + header: { default: false }, + align: { default: null }, + }, + content: 'inline*', + isolating: true, + parseDOM: [ + { + tag: 'td, th', + getAttrs: (el) => ({ + header: el.tagName === 'TH', + align: el.getAttribute('align') || el.style.textAlign, + }), }, - content: 'inline*', - isolating: true, - parseDOM: [ - { - tag: 'td, th', - getAttrs: (el) => ({ - header: el.tagName === 'TH', - align: el.getAttribute('align') || el.style.textAlign, - }), - }, - ], - toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0], - }; - } - - toMarkdown(state, node) { + ], + toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0], + }, + toMarkdown: (state, node) => { state.renderInline(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js index 4cb94bf088c..2e9b53ee0ac 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js @@ -1,24 +1,14 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class TableHead extends Node { - get name() { - return 'table_head'; - } - - get schema() { - return { - content: 'table_header_row', - parseDOM: [{ tag: 'thead' }], - toDOM: () => ['thead', 0], - }; - } - - toMarkdown(state, node) { +export default () => ({ + name: 'table_head', + schema: { + content: 'table_header_row', + parseDOM: [{ tag: 'thead' }], + toDOM: () => ['thead', 0], + }, + toMarkdown: (state, node) => { state.flushClose(1); state.renderContent(node); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js index 2cb2bb9e7fe..d8aa497066c 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js @@ -1,31 +1,23 @@ -/* eslint-disable class-methods-use-this */ - import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; import TableRow from './table_row'; const CENTER_ALIGN = 'center'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class TableHeaderRow extends TableRow { - get name() { - return 'table_header_row'; - } - - get schema() { - return { - content: 'table_cell+', - parseDOM: [ - { - tag: 'thead tr', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - ], - toDOM: () => ['tr', 0], - }; - } - - toMarkdown(state, node) { - const cellWidths = super.toMarkdown(state, node); +export default () => ({ + name: 'table_header_row', + schema: { + content: 'table_cell+', + parseDOM: [ + { + tag: 'thead tr', + priority: HIGHER_PARSE_RULE_PRIORITY, + }, + ], + toDOM: () => ['tr', 0], + }, + toMarkdown: (state, node) => { + const cellWidths = TableRow().toMarkdown(state, node); state.flushClose(1); @@ -40,5 +32,5 @@ export default class TableHeaderRow extends TableRow { state.write('|'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js index db9072acc3a..4a0256c4644 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js @@ -1,35 +1,26 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { __ } from '~/locale'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter -export default class TableOfContents extends Node { - get name() { - return 'table_of_contents'; - } - - get schema() { - return { - group: 'block', - atom: true, - parseDOM: [ - { - tag: 'ul.section-nav', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - { - tag: 'p.table-of-contents', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - ], - toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')], - }; - } - - toMarkdown(state, node) { +export default () => ({ + name: 'table_of_contents', + schema: { + group: 'block', + atom: true, + parseDOM: [ + { + tag: 'ul.section-nav', + priority: HIGHER_PARSE_RULE_PRIORITY, + }, + { + tag: 'p.table-of-contents', + priority: HIGHER_PARSE_RULE_PRIORITY, + }, + ], + toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')], + }, + toMarkdown: (state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js index 5852502773a..3830dae4f0d 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js @@ -1,22 +1,12 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; - // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default class TableRow extends Node { - get name() { - return 'table_row'; - } - - get schema() { - return { - content: 'table_cell+', - parseDOM: [{ tag: 'tr' }], - toDOM: () => ['tr', 0], - }; - } - - toMarkdown(state, node) { +export default () => ({ + name: 'table_row', + schema: { + content: 'table_cell+', + parseDOM: [{ tag: 'tr' }], + toDOM: () => ['tr', 0], + }, + toMarkdown: (state, node) => { const cellWidths = []; state.flushClose(1); @@ -34,5 +24,5 @@ export default class TableRow extends Node { state.closeBlock(node); return cellWidths; - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js index 35ba2eb0674..3c3812ad8f7 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js @@ -1,29 +1,20 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter -export default class TaskList extends Node { - get name() { - return 'task_list'; - } - - get schema() { - return { - group: 'block', - content: '(task_list_item|list_item)+', - parseDOM: [ - { - priority: HIGHER_PARSE_RULE_PRIORITY, - tag: 'ul.task-list', - }, - ], - toDOM: () => ['ul', { class: 'task-list' }, 0], - }; - } - +export default () => ({ + name: 'task_list', + schema: { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: HIGHER_PARSE_RULE_PRIORITY, + tag: 'ul.task-list', + }, + ], + toDOM: () => ['ul', { class: 'task-list' }, 0], + }, toMarkdown(state, node) { state.renderList(node, ' ', () => '* '); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js index 56c2b17286d..10ffce9b1b8 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -1,50 +1,38 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter -export default class TaskListItem extends Node { - get name() { - return 'task_list_item'; - } - - get schema() { - return { - attrs: { - done: { - default: false, - }, +export default () => ({ + name: 'task_list_item', + schema: { + attrs: { + done: { + default: false, }, - defining: true, - draggable: false, - content: 'paragraph block*', - parseDOM: [ - { - priority: HIGHER_PARSE_RULE_PRIORITY, - tag: 'li.task-list-item', - getAttrs: (el) => { - const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); - return { done: checkbox && checkbox.checked }; - }, + }, + defining: true, + draggable: false, + content: 'paragraph block*', + parseDOM: [ + { + priority: HIGHER_PARSE_RULE_PRIORITY, + tag: 'li.task-list-item', + getAttrs: (el) => { + const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); + return { done: checkbox && checkbox.checked }; }, - ], - toDOM(node) { - return [ - 'li', - { class: 'task-list-item' }, - [ - 'input', - { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }, - ], - ['div', { class: 'todo-content' }, 0], - ]; }, - }; - } - + ], + toDOM(node) { + return [ + 'li', + { class: 'task-list-item' }, + ['input', { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }], + ['div', { class: 'todo-content' }, 0], + ]; + }, + }, toMarkdown(state, node) { state.write(`[${node.attrs.done ? 'x' : ' '}] `); state.renderContent(node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js index 0dc77a12f5c..0e1f0bc0e40 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/text.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js @@ -1,20 +1,11 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; -export default class Text extends Node { - get name() { - return 'text'; - } - - get schema() { - return { - group: 'inline', - }; - } - +export default () => ({ + name: 'text', + schema: { + group: 'inline', + }, toMarkdown(state, node) { defaultMarkdownSerializer.nodes.text(state, node); - } -} + }, +}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js index 68085c2c416..aa1088826da 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/video.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js @@ -1,10 +1,4 @@ -import Playable from './playable'; +import playable from './playable'; // Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter -export default class Video extends Playable { - constructor() { - super(); - this.mediaType = 'video'; - this.extraElementAttrs = { width: '400' }; - } -} +export default () => playable({ mediaType: 'video', extraElementAttrs: { width: '400' } }); diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js index 1d54a1b0c04..85a991a1ec9 100644 --- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js @@ -88,7 +88,7 @@ function renderMermaidEl(el, source) { const iframeEl = document.createElement('iframe'); setAttributes(iframeEl, { src: getSandboxFrameSrc(), - sandbox: 'allow-scripts', + sandbox: 'allow-scripts allow-popups', frameBorder: 0, scrolling: 'no', width: '100%', diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js index 8bea24584cc..1b0f46ff4cb 100644 --- a/app/assets/javascripts/behaviors/markdown/schema.js +++ b/app/assets/javascripts/behaviors/markdown/schema.js @@ -1,24 +1,20 @@ import { Schema } from 'prosemirror-model'; import editorExtensions from './editor_extensions'; -const nodes = editorExtensions - .filter((extension) => extension.type === 'node') - .reduce( - (ns, { name, schema }) => ({ - ...ns, - [name]: schema, - }), - {}, - ); +const nodes = editorExtensions.nodes.reduce( + (ns, { name, schema }) => ({ + ...ns, + [name]: schema, + }), + {}, +); -const marks = editorExtensions - .filter((extension) => extension.type === 'mark') - .reduce( - (ms, { name, schema }) => ({ - ...ms, - [name]: schema, - }), - {}, - ); +const marks = editorExtensions.marks.reduce( + (ms, { name, schema }) => ({ + ...ms, + [name]: schema, + }), + {}, +); export default new Schema({ nodes, marks }); diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js index a5f97d7748a..e3e8a380cd5 100644 --- a/app/assets/javascripts/behaviors/markdown/serializer.js +++ b/app/assets/javascripts/behaviors/markdown/serializer.js @@ -1,24 +1,20 @@ import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import editorExtensions from './editor_extensions'; -const nodes = editorExtensions - .filter((extension) => extension.type === 'node') - .reduce( - (ns, { name, toMarkdown }) => ({ - ...ns, - [name]: toMarkdown, - }), - {}, - ); +const nodes = editorExtensions.nodes.reduce( + (ns, { name, toMarkdown }) => ({ + ...ns, + [name]: toMarkdown, + }), + {}, +); -const marks = editorExtensions - .filter((extension) => extension.type === 'mark') - .reduce( - (ms, { name, toMarkdown }) => ({ - ...ms, - [name]: toMarkdown, - }), - {}, - ); +const marks = editorExtensions.marks.reduce( + (ms, { name, toMarkdown }) => ({ + ...ms, + [name]: toMarkdown, + }), + {}, +); export default new MarkdownSerializer(nodes, marks); diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index b27dccabdf8..23b66405844 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -131,6 +131,13 @@ export const ITALIC_TEXT = { customizable: false, }; +export const STRIKETHROUGH_TEXT = { + id: 'editing.strikethroughText', + description: __('Strikethrough text'), + defaultKeys: ['mod+shift+x'], + customizable: false, +}; + export const LINK_TEXT = { id: 'editing.linkText', description: __('Link text'), @@ -511,7 +518,14 @@ export const GLOBAL_SHORTCUTS_GROUP = { export const EDITING_SHORTCUTS_GROUP = { id: 'editing', name: __('Editing'), - keybindings: [BOLD_TEXT, ITALIC_TEXT, LINK_TEXT, TOGGLE_MARKDOWN_PREVIEW, EDIT_RECENT_COMMENT], + keybindings: [ + BOLD_TEXT, + ITALIC_TEXT, + STRIKETHROUGH_TEXT, + LINK_TEXT, + TOGGLE_MARKDOWN_PREVIEW, + EDIT_RECENT_COMMENT, + ], }; export const WIKI_SHORTCUTS_GROUP = { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 9297b14aac9..4d78c7b56a0 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -173,12 +173,7 @@ export default class Shortcuts { e.preventDefault(); const canaryCookieName = 'gitlab_canary'; const currentValue = parseBoolean(getCookie(canaryCookieName)); - setCookie(canaryCookieName, (!currentValue).toString(), { - expires: 365, - path: '/', - // next.gitlab.com uses a leading period. See https://gitlab.com/gitlab-org/gitlab/-/issues/350186 - domain: `.${window.location.hostname}`, - }); + setCookie(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' }); refreshCurrentPage(); } diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index c5ab28e6ec5..8a4fe1a9025 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -63,6 +63,9 @@ export default { isEmpty() { return this.blob.rawSize === 0; }, + blobSwitcherDocIcon() { + return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document'; + }, }, watch: { viewer(newVal, oldVal) { @@ -90,7 +93,7 @@ export default { </div> <div class="gl-sm-display-flex file-actions"> - <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> + <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" /> <slot name="actions"></slot> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index 12bcb24b0cc..61baf4fa495 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; +import { setUrlParams, relativePathToAbsolute, getBaseURL } from '~/lib/utils/url_utility'; import { BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, @@ -56,7 +57,7 @@ export default { }, computed: { downloadUrl() { - return `${this.rawPath}?inline=false`; + return setUrlParams({ inline: false }, relativePathToAbsolute(this.rawPath, getBaseURL())); }, copyDisabled() { return this.activeViewer === RICH_BLOB_VIEWER; diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index b2546d47694..7351df0f93b 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -21,6 +21,11 @@ export default { default: SIMPLE_BLOB_VIEWER, required: false, }, + docIcon: { + type: String, + default: 'document', + required: false, + }, }, computed: { isSimpleViewer() { @@ -62,7 +67,7 @@ export default { :aria-label="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE" :selected="isRichViewer" - icon="document" + :icon="docIcon" category="primary" variant="default" class="js-blob-viewer-switch-btn" diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue index 1f9d20a487f..169167625e0 100644 --- a/app/assets/javascripts/blob/csv/csv_viewer.vue +++ b/app/assets/javascripts/blob/csv/csv_viewer.vue @@ -14,6 +14,11 @@ export default { type: String, required: true, }, + remoteFile: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -23,14 +28,29 @@ export default { }; }, mounted() { - const parsed = Papa.parse(this.csv, { skipEmptyLines: true }); - this.items = parsed.data; - - if (parsed.errors.length) { - this.papaParseErrors = parsed.errors; + if (!this.remoteFile) { + const parsed = Papa.parse(this.csv, { skipEmptyLines: true }); + this.handleParsedData(parsed); + } else { + Papa.parse(this.csv, { + download: true, + skipEmptyLines: true, + complete: (parsed) => { + this.handleParsedData(parsed); + }, + }); } + }, + methods: { + handleParsedData(parsed) { + this.items = parsed.data; - this.loading = false; + if (parsed.errors.length) { + this.papaParseErrors = parsed.errors; + } + + this.loading = false; + }, }, }; </script> diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 9fa70ce3c62..7eb699eacbe 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; export default class TemplateSelector { constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) { @@ -10,10 +11,9 @@ export default class TemplateSelector { this.dropdown = dropdown; this.$dropdownContainer = wrapper; this.$filenameInput = $input || $('#file_name'); - this.$dropdownIcon = $('.dropdown-menu-toggle-icon', dropdown); - this.$loadingIcon = $( - '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>', - ).insertAfter(this.$dropdownIcon); + this.dropdownIcon = dropdown[0].querySelector('.dropdown-menu-toggle-icon'); + this.loadingIcon = loadingIconForLegacyJS({ classes: ['gl-display-none'] }); + this.dropdownIcon.parentNode.insertBefore(this.loadingIcon, this.dropdownIcon.nextSibling); this.initDropdown(dropdown, data); this.listenForFilenameInput(); @@ -78,7 +78,12 @@ export default class TemplateSelector { setEditorContent(file, { skipFocus } = {}) { if (!file) return; - const newValue = file.content; + let newValue = file.content; + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('issue[description]')) { + newValue += `\n${urlParams.get('issue[description]')}`; + } this.editor.setValue(newValue, 1); @@ -95,12 +100,12 @@ export default class TemplateSelector { } startLoadingSpinner() { - this.$loadingIcon.removeClass('gl-display-none'); - this.$dropdownIcon.addClass('gl-display-none'); + this.loadingIcon.classList.remove('gl-display-none'); + this.dropdownIcon.classList.add('gl-display-none'); } stopLoadingSpinner() { - this.$loadingIcon.addClass('gl-display-none'); - this.$dropdownIcon.removeClass('gl-display-none'); + this.loadingIcon.classList.add('gl-display-none'); + this.dropdownIcon.classList.remove('gl-display-none'); } } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 2d9ffda06d0..425de914c17 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -2,7 +2,6 @@ import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; -import initCodeQualityWalkthrough from '~/code_quality_walkthrough'; import createFlash from '~/flash'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; @@ -39,13 +38,6 @@ const initPopovers = () => { } }; -const initCodeQualityWalkthroughStep = () => { - const codeQualityWalkthroughEl = document.querySelector('.js-code-quality-walkthrough'); - if (codeQualityWalkthroughEl) { - initCodeQualityWalkthrough(codeQualityWalkthroughEl); - } -}; - export const initUploadForm = () => { const uploadBlobForm = $('.js-upload-blob-form'); if (uploadBlobForm.length) { @@ -71,7 +63,7 @@ export default () => { const isMarkdown = editBlobForm.data('is-markdown'); const previewMarkdownPath = editBlobForm.data('previewMarkdownPath'); const commitButton = $('.js-commit-button'); - const cancelLink = $('.btn.btn-cancel'); + const cancelLink = $('#cancel-changes'); import('./edit_blob') .then(({ default: EditBlob } = {}) => { @@ -84,7 +76,6 @@ export default () => { previewMarkdownPath, }); initPopovers(); - initCodeQualityWalkthroughStep(); }) .catch((e) => createFlash({ diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 7e4d3ebb686..96cc774a280 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,5 +1,6 @@ import { sortBy, cloneDeep } from 'lodash'; -import { isGid } from '~/graphql_shared/utils'; +import { TYPE_BOARD, TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants'; +import { isGid, convertToGraphQLId } from '~/graphql_shared/utils'; import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants'; export function getMilestone() { @@ -80,19 +81,22 @@ export function formatListsPageInfo(lists) { } export function fullBoardId(boardId) { - return `gid://gitlab/Board/${boardId}`; + if (!boardId) { + return null; + } + return convertToGraphQLId(TYPE_BOARD, boardId); } export function fullIterationId(id) { - return `gid://gitlab/Iteration/${id}`; + return convertToGraphQLId(TYPE_ITERATION, id); } export function fullUserId(id) { - return `gid://gitlab/User/${id}`; + return convertToGraphQLId(TYPE_USER, id); } export function fullMilestoneId(id) { - return `gid://gitlab/Milestone/${id}`; + return convertToGraphQLId(TYPE_MILESTONE, id); } export function fullLabelId(label) { diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 45192b5304a..95d4fd5bc0a 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -151,10 +151,10 @@ export default { }); } - if (this.filterParams['not[iteration_id]']) { + if (this.filterParams['not[iterationId]']) { filteredSearchValue.push({ - type: 'iteration_id', - value: { data: this.filterParams['not[iteration_id]'], operator: '!=' }, + type: 'iteration', + value: { data: this.filterParams['not[iterationId]'], operator: '!=' }, }); } diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index cc048e2af1a..5fcf9514708 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,11 +1,9 @@ <script> import { GlModal, GlAlert } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; -import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants'; -import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; -import { fullLabelId } from '../boards_util'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { formType } from '../constants'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; @@ -18,6 +16,7 @@ const boardDefaults = { name: '', labels: [], milestone: {}, + iterationCadence: {}, iteration: {}, assignee: {}, weight: null, @@ -44,6 +43,7 @@ export default { BoardConfigurationOptions, GlAlert, }, + mixins: [glFeatureFlagMixin()], inject: { fullPath: { default: '', @@ -158,33 +158,8 @@ export default { groupPath: this.isGroupBoard ? this.fullPath : undefined, }; }, - issueBoardScopeMutationVariables() { - return { - weight: this.board.weight, - assigneeId: this.board.assignee?.id - ? convertToGraphQLId(TYPE_USER, this.board.assignee.id) - : null, - // Temporarily converting to milestone ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779 - milestoneId: this.board.milestone?.id - ? convertToGraphQLId(TYPE_MILESTONE, getIdFromGraphQLId(this.board.milestone.id)) - : null, - // Temporarily converting to iteration ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779 - iterationId: this.board.iteration?.id - ? convertToGraphQLId(TYPE_ITERATION, getIdFromGraphQLId(this.board.iteration.id)) - : null, - }; - }, - boardScopeMutationVariables() { - return { - labelIds: this.board.labels.map(fullLabelId), - ...(this.isIssueBoard && this.issueBoardScopeMutationVariables), - }; - }, mutationVariables() { - return { - ...this.baseMutationVariables, - ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}), - }; + return this.baseMutationVariables; }, }, mounted() { @@ -259,9 +234,12 @@ export default { this.board = { ...boardDefaults, ...this.currentBoard }; } }, - setIteration(iterationId) { + setIteration(iteration) { + if (this.glFeatures.iterationCadences) { + this.board.iterationCadenceId = iteration.iterationCadenceId; + } this.$set(this.board, 'iteration', { - id: iterationId, + id: iteration.id, }); }, setBoardLabels(labels) { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 6835d83a66c..46b28d20da9 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -89,10 +89,6 @@ export default { listTitle() { return this.list?.label?.description || this.list?.assignee?.name || this.list.title || ''; }, - listIterationPeriod() { - const iteration = this.list?.iteration; - return iteration ? this.getIterationPeriod(iteration) : ''; - }, isIterationList() { return this.listType === ListType.iteration; }, @@ -108,9 +104,6 @@ export default { showIterationListDetails() { return this.isIterationList && this.showListDetails; }, - iterationCadencesAvailable() { - return this.isIterationList && this.glFeatures.iterationCadences; - }, showListDetails() { return !this.list.collapsed || !this.isSwimlanesHeader; }, @@ -344,13 +337,6 @@ export default { class="board-title-main-text gl-text-truncate" > {{ listTitle }} - <span - v-if="iterationCadencesAvailable" - class="gl-display-inline-block gl-text-gray-400" - data-testid="board-list-iteration-period" - > - {{ listIterationPeriod }}</span - > </span> <span v-if="listType === 'assignee'" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 6dbb1ea0050..91fdfd668fc 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -101,6 +101,7 @@ export default { }, update(data) { const board = data.workspace?.board; + this.setBoardConfig(board); return { ...board, labels: board?.labels?.nodes, @@ -170,7 +171,7 @@ export default { eventHub.$off('showBoardModal', this.showPage); }, methods: { - ...mapActions(['setError']), + ...mapActions(['setError', 'setBoardConfig']), showPage(page) { this.currentPage = page; }, @@ -315,9 +316,7 @@ export default { <gl-dropdown-item v-if="hasMissingBoards" class="no-pointer-events"> {{ - s__( - 'IssueBoards|Some of your boards are hidden, activate a license to see them again.', - ) + s__('IssueBoards|Some of your boards are hidden, add a license to see them again.') }} </gl-dropdown-item> </div> diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js deleted file mode 100644 index 72586970008..00000000000 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ /dev/null @@ -1,81 +0,0 @@ -import { transformBoardConfig } from 'ee_else_ce/boards/boards_util'; -import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; -import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; -import { updateHistory } from '~/lib/utils/url_utility'; -import FilteredSearchContainer from '../filtered_search/container'; -import vuexstore from './stores'; - -export default class FilteredSearchBoards extends FilteredSearchManager { - constructor(store, updateUrl = false, cantEdit = []) { - super({ - page: 'boards', - isGroupDecendent: true, - stateFiltersSelector: '.issues-state-filters', - isGroup: IS_EE, - useDefaultState: false, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - }); - - this.store = store; - this.updateUrl = updateUrl; - - // Issue boards is slightly different, we handle all the requests async - // instead or reloading the page, we just re-fire the list ajax requests - this.isHandledAsync = true; - this.cantEdit = cantEdit.filter((i) => typeof i === 'string'); - this.cantEditWithValue = cantEdit.filter((i) => typeof i === 'object'); - - if (vuexstore.state.boardConfig) { - const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig); - // TODO Refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/329274 - // here we are using "window.location.search" as a temporary store - // only to unpack the params and do another validation inside - // 'performSearch' and 'setFilter' vuex actions. - if (boardConfigPath !== '') { - const filterPath = window.location.search ? `${window.location.search}&` : '?'; - updateHistory({ - url: `${filterPath}${transformBoardConfig(vuexstore.state.boardConfig)}`, - }); - } - } - } - - updateObject(path) { - const groupByParam = new URLSearchParams(window.location.search).get('group_by'); - this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`; - - updateHistory({ - url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`, - }); - vuexstore.dispatch('performSearch'); - } - - removeTokens() { - const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token'); - - // Remove all the tokens as they will be replaced by the search manager - [].forEach.call(tokens, (el) => { - el.parentNode.removeChild(el); - }); - - this.filteredSearchInput.value = ''; - } - - updateTokens() { - this.removeTokens(); - - this.loadSearchParamsFromURL(); - - // Get the placeholder back if search is empty - this.filteredSearchInput.dispatchEvent(new Event('input')); - } - - canEdit(tokenName, tokenValue) { - if (this.cantEdit.includes(tokenName)) return false; - return ( - this.cantEditWithValue.findIndex( - (token) => token.name === tokenName && token.value === tokenValue, - ) === -1 - ); - } -} diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js index 95863d4d5ac..d066a5d002e 100644 --- a/app/assets/javascripts/boards/graphql.js +++ b/app/assets/javascripts/boards/graphql.js @@ -10,5 +10,6 @@ export const gqlClient = createDefaultClient( return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object); }, }, + batchMax: 2, }, ); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index f6073f9d981..b31b56e6839 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -8,8 +8,6 @@ import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_t import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; import { issuableTypes } from '~/boards/constants'; -import eventHub from '~/boards/eventhub'; -import FilteredSearchBoards from '~/boards/filtered_search_boards'; import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards'; import store from '~/boards/stores'; import toggleFocusMode from '~/boards/toggle_focus'; @@ -30,6 +28,12 @@ const apolloProvider = new VueApollo({ function mountBoardApp(el) { const { boardId, groupId, fullPath, rootPath } = el.dataset; + store.dispatch('fetchBoard', { + fullPath, + fullBoardId: fullBoardId(boardId), + boardType: el.dataset.parent, + }); + store.dispatch('setInitialBoardData', { boardId, fullBoardId: fullBoardId(boardId), @@ -37,30 +41,8 @@ function mountBoardApp(el) { boardType: el.dataset.parent, disabled: parseBoolean(el.dataset.disabled) || true, issuableType: issuableTypes.issue, - boardConfig: { - milestoneId: parseInt(el.dataset.boardMilestoneId, 10), - milestoneTitle: el.dataset.boardMilestoneTitle || '', - iterationId: parseInt(el.dataset.boardIterationId, 10), - iterationTitle: el.dataset.boardIterationTitle || '', - assigneeId: el.dataset.boardAssigneeId, - assigneeUsername: el.dataset.boardAssigneeUsername, - labels: el.dataset.labels ? JSON.parse(el.dataset.labels) : [], - labelIds: el.dataset.labelIds ? JSON.parse(el.dataset.labelIds) : [], - weight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null, - }, }); - if (!gon?.features?.issueBoardsFilteredSearch) { - // Warning: FilteredSearchBoards has an implicit dependency on the Vuex state 'boardConfig' - // Improve this situation in the future. - const filterManager = new FilteredSearchBoards({ path: '' }, true, []); - filterManager.setup(); - - eventHub.$on('updateTokens', () => { - filterManager.updateTokens(); - }); - } - // eslint-disable-next-line no-new new Vue({ el, @@ -110,10 +92,14 @@ export default () => { } }); - if (gon?.features?.issueBoardsFilteredSearch) { - const { releasesFetchPath } = $boardApp.dataset; - initBoardsFilteredSearch(apolloProvider, isLoggedIn(), releasesFetchPath); - } + const { releasesFetchPath, epicFeatureAvailable, iterationFeatureAvailable } = $boardApp.dataset; + initBoardsFilteredSearch( + apolloProvider, + isLoggedIn(), + releasesFetchPath, + parseBoolean(epicFeatureAvailable), + parseBoolean(iterationFeatureAvailable), + ); mountBoardApp($boardApp); diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js index 327fb9ba8d7..bb659eb075a 100644 --- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -4,7 +4,13 @@ import store from '~/boards/stores'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; -export default (apolloProvider, isSignedIn, releasesFetchPath) => { +export default ( + apolloProvider, + isSignedIn, + releasesFetchPath, + epicFeatureAvailable, + iterationFeatureAvailable, +) => { const el = document.getElementById('js-issue-board-filtered-search'); const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -23,6 +29,8 @@ export default (apolloProvider, isSignedIn, releasesFetchPath) => { initialFilterParams, isSignedIn, releasesFetchPath, + epicFeatureAvailable, + iterationFeatureAvailable, }, store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 apolloProvider, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 1ebfcfc331b..82307da2572 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -36,6 +36,8 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { gqlClient } from '../graphql'; +import projectBoardQuery from '../graphql/project_board.query.graphql'; +import groupBoardQuery from '../graphql/group_board.query.graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; @@ -46,10 +48,44 @@ import projectBoardMilestonesQuery from '../graphql/project_board_milestones.que import * as types from './mutation_types'; export default { + fetchBoard: ({ commit, dispatch }, { fullPath, fullBoardId, boardType }) => { + const variables = { + fullPath, + boardId: fullBoardId, + }; + + return gqlClient + .query({ + query: boardType === BoardType.group ? groupBoardQuery : projectBoardQuery, + variables, + }) + .then(({ data }) => { + const board = data.workspace?.board; + commit(types.RECEIVE_BOARD_SUCCESS, board); + dispatch('setBoardConfig', board); + }) + .catch(() => commit(types.RECEIVE_BOARD_FAILURE)); + }, + setInitialBoardData: ({ commit }, data) => { commit(types.SET_INITIAL_BOARD_DATA, data); }, + setBoardConfig: ({ commit }, board) => { + const config = { + milestoneId: board.milestone?.id || null, + milestoneTitle: board.milestone?.title || null, + iterationId: board.iteration?.id || null, + iterationTitle: board.iteration?.title || null, + assigneeId: board.assignee?.id || null, + assigneeUsername: board.assignee?.username || null, + labels: board.labels?.nodes || [], + labelIds: board.labels?.nodes?.map((label) => label.id) || [], + weight: board.weight, + }; + commit(types.SET_BOARD_CONFIG, config); + }, + setActiveId({ commit }, { id, sidebarType }) { commit(types.SET_ACTIVE_ID, { id, sidebarType }); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 31b78014525..668a3b5e0f9 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -1,4 +1,7 @@ +export const RECEIVE_BOARD_SUCCESS = 'RECEIVE_BOARD_SUCCESS'; +export const RECEIVE_BOARD_FAILURE = 'RECEIVE_BOARD_FAILURE'; export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA'; +export const SET_BOARD_CONFIG = 'SET_BOARD_CONFIG'; export const SET_FILTERS = 'SET_FILTERS'; export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS'; export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 2a2ce7652e6..9a50dcf05b8 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -33,10 +33,20 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId }; export default { + [mutationTypes.RECEIVE_BOARD_SUCCESS]: (state, board) => { + state.board = { + ...board, + labels: board?.labels?.nodes || [], + }; + }, + + [mutationTypes.RECEIVE_BOARD_FAILURE]: (state) => { + state.error = s__('Boards|An error occurred while fetching the board. Please reload the page.'); + }, + [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { const { allowSubEpics, - boardConfig, boardId, boardType, disabled, @@ -45,7 +55,6 @@ export default { issuableType, } = data; state.allowSubEpics = allowSubEpics; - state.boardConfig = boardConfig; state.boardId = boardId; state.boardType = boardType; state.disabled = disabled; @@ -54,6 +63,10 @@ export default { state.issuableType = issuableType; }, + [mutationTypes.SET_BOARD_CONFIG](state, boardConfig) { + state.boardConfig = boardConfig; + }, + [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { state.boardLists = lists; }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 80c51c966d2..7af4e5a8798 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,6 +1,7 @@ import { inactiveId, ListType } from '~/boards/constants'; export default () => ({ + board: {}, boardType: null, issuableType: null, fullPath: null, diff --git a/app/assets/javascripts/branches/ajax_loading_spinner.js b/app/assets/javascripts/branches/ajax_loading_spinner.js deleted file mode 100644 index 79f4f919f3d..00000000000 --- a/app/assets/javascripts/branches/ajax_loading_spinner.js +++ /dev/null @@ -1,31 +0,0 @@ -import $ from 'jquery'; - -export default class AjaxLoadingSpinner { - static init() { - const $elements = $('.js-ajax-loading-spinner'); - $elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend); - } - - static ajaxBeforeSend(e) { - const button = e.target; - const newButton = document.createElement('button'); - newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button'); - newButton.setAttribute('disabled', 'disabled'); - - const spinner = document.createElement('span'); - spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange'); - newButton.appendChild(spinner); - - button.classList.add('hidden'); - button.parentNode.insertBefore(newButton, button.nextSibling); - - $(button).one('ajax:error', () => { - newButton.remove(); - button.classList.remove('hidden'); - }); - - $(button).one('ajax:success', () => { - $(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend); - }); - } -} diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js index d63ffaf5f1a..2d154139c7b 100644 --- a/app/assets/javascripts/captcha/apollo_captcha_link.js +++ b/app/assets/javascripts/captcha/apollo_captcha_link.js @@ -12,7 +12,7 @@ export const apolloCaptchaLink = new ApolloLink((operation, forward) => const spamLogId = captchaError.extensions.spam_log_id; return new Observable((observer) => { - import('~/captcha/wait_for_captcha_to_be_solved') + import('jh_else_ce/captcha/wait_for_captcha_to_be_solved') .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey)) .then((captchaResponse) => { // If the captcha was solved correctly, we re-do our action while setting diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue index a98a52a3130..b8b90b04beb 100644 --- a/app/assets/javascripts/captcha/captcha_modal.vue +++ b/app/assets/javascripts/captcha/captcha_modal.vue @@ -1,7 +1,7 @@ <script> -// NOTE 1: This is similar to recaptcha_modal.vue, but it directly uses the reCAPTCHA Javascript API -// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal, rather than relying -// on the form-based ReCAPTCHA HTML being pre-rendered by the backend and using deprecated-modal. +// NOTE 1: This modal directly uses the reCAPTCHA Javascript API +// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal, +// rather than relying form-based reCAPTCHA HTML being pre-rendered by the backend. // NOTE 2: Even though this modal currently only supports reCAPTCHA, we use 'captcha' instead // of 'recaptcha' throughout the code, so that we can easily add support for future alternative diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js index fdab188f6be..19fde2500f1 100644 --- a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js +++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js @@ -9,7 +9,9 @@ function needsCaptchaResponse(err) { const showCaptchaModalAndResubmit = async (axios, data, errConfig) => { // NOTE: We asynchronously import and unbox the module. Since this is included globally, we don't // do a regular import because that would increase the size of the webpack bundle. - const { waitForCaptchaToBeSolved } = await import('~/captcha/wait_for_captcha_to_be_solved'); + const { waitForCaptchaToBeSolved } = await import( + 'jh_else_ce/captcha/wait_for_captcha_to_be_solved' + ); // show the CAPTCHA modal and wait for it to be solved or closed const captchaResponse = await waitForCaptchaToBeSolved(data.captcha_site_key); diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index d541e89756a..8db4cba529f 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -103,7 +103,7 @@ export default { class="gl-mr-4" :loading="loading" category="primary" - variant="success" + variant="confirm" data-testid="ci-lint-validate" @click="lint" >{{ __('Validate') }}</gl-button diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue new file mode 100644 index 00000000000..d70ade36fe9 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -0,0 +1,133 @@ +<script> +import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { __ } from '~/locale'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlLink, + GlLoadingIcon, + GlPagination, + GlTable, + TimeagoTooltip, + }, + inject: ['projectId'], + docsLink: helpPagePath('ci/secure_files/index'), + DEFAULT_PER_PAGE, + i18n: { + pagination: { + next: __('Next'), + prev: __('Prev'), + }, + title: __('Secure Files'), + overviewMessage: __( + 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.', + ), + moreInformation: __('More information'), + }, + data() { + return { + page: 1, + totalItems: 0, + loading: false, + projectSecureFiles: [], + }; + }, + fields: [ + { + key: 'name', + label: __('Filename'), + }, + { + key: 'permissions', + label: __('Permissions'), + }, + { + key: 'created_at', + label: __('Uploaded'), + }, + ], + computed: { + fields() { + return this.$options.fields; + }, + }, + watch: { + page(newPage) { + this.getProjectSecureFiles(newPage); + }, + }, + created() { + this.getProjectSecureFiles(); + }, + methods: { + async getProjectSecureFiles(page) { + this.loading = true; + const response = await Api.projectSecureFiles(this.projectId, { page }); + + this.totalItems = parseInt(response.headers?.['x-total'], 10) || 0; + + this.projectSecureFiles = response.data; + + this.loading = false; + }, + }, +}; +</script> + +<template> + <div> + <h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1> + + <p> + <span data-testid="info-message" class="gl-mr-2"> + {{ $options.i18n.overviewMessage }} + <gl-link :href="$options.docsLink" target="_blank">{{ + $options.i18n.moreInformation + }}</gl-link> + </span> + </p> + + <gl-table + :busy="loading" + :fields="fields" + :items="projectSecureFiles" + tbody-tr-class="js-ci-secure-files-row" + data-qa-selector="ci_secure_files_table_content" + sort-by="key" + sort-direction="asc" + stacked="lg" + table-class="text-secondary" + show-empty + sort-icon-left + no-sort-reset + > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> + + <template #cell(name)="{ item }"> + {{ item.name }} + </template> + + <template #cell(permissions)="{ item }"> + {{ item.permissions }} + </template> + + <template #cell(created_at)="{ item }"> + <timeago-tooltip :time="item.created_at" /> + </template> + </gl-table> + <gl-pagination + v-if="!loading" + v-model="page" + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :next-text="$options.i18n.pagination.next" + :prev-text="$options.i18n.pagination.prev" + align="center" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci_secure_files/index.js b/app/assets/javascripts/ci_secure_files/index.js new file mode 100644 index 00000000000..18b4ac6866e --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import SecureFilesList from './components/secure_files_list.vue'; + +export const initCiSecureFiles = (selector = '#js-ci-secure-files') => { + const containerEl = document.querySelector(selector); + const { projectId } = containerEl.dataset; + + return new Vue({ + el: containerEl, + provide: { + projectId, + }, + render(createElement) { + return createElement(SecureFilesList); + }, + }); +}; diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 4ab9b36058d..4156717908d 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -8,8 +8,12 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link export default { i18n: { + copyTrigger: s__('Pipelines|Copy trigger token'), editButton: s__('Pipelines|Edit'), - revokeButton: s__('Pipelines|Revoke'), + revokeButton: s__('Pipelines|Revoke trigger'), + revokeButtonConfirm: s__( + 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?', + ), }, components: { GlTable, @@ -72,7 +76,7 @@ export default { :text="item.token" data-testid="clipboard-btn" data-qa-selector="clipboard_button" - :title="s__('Pipelines|Copy trigger token')" + :title="$options.i18n.copyTrigger" css-class="gl-border-none gl-py-0 gl-px-2" /> <div class="label-container"> @@ -122,13 +126,9 @@ export default { :title="$options.i18n.revokeButton" :aria-label="$options.i18n.revokeButton" icon="remove" - variant="warning" - :data-confirm=" - s__( - 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?', - ) - " + :data-confirm="$options.i18n.revokeButtonConfirm" data-method="delete" + data-confirm-btn-variant="danger" rel="nofollow" class="gl-ml-3" data-testid="trigger_revoke_button" diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index 065cb4f5616..055e2f83e33 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -3,7 +3,6 @@ import SecretValues from '../behaviors/secret_values'; import CreateItemDropdown from '../create_item_dropdown'; import { parseBoolean } from '../lib/utils/common_utils'; import { s__ } from '../locale'; -import setupToggleButtons from '../toggle_buttons'; const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments'); @@ -115,8 +114,6 @@ export default class VariableList { initRow(rowEl) { const $row = $(rowEl); - setupToggleButtons($row[0]); - // Reset the resizable textarea $row.find(this.inputMap.secret_value.selector).css('height', ''); diff --git a/app/assets/javascripts/clusters/agents/components/create_token_button.vue b/app/assets/javascripts/clusters/agents/components/create_token_button.vue new file mode 100644 index 00000000000..3e1a8994fb8 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/create_token_button.vue @@ -0,0 +1,246 @@ +<script> +import { + GlButton, + GlModalDirective, + GlTooltip, + GlModal, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlAlert, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; +import AgentToken from '~/clusters_list/components/agent_token.vue'; +import { + CREATE_TOKEN_MODAL, + EVENT_LABEL_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_CLICK, + TOKEN_NAME_LIMIT, + TOKEN_STATUS_ACTIVE, +} from '../constants'; +import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql'; +import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; +import { addAgentTokenToStore } from '../graphql/cache_update'; + +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL }); + +export default { + components: { + AgentToken, + GlButton, + GlTooltip, + GlModal, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlAlert, + }, + directives: { + GlModalDirective, + }, + mixins: [trackingMixin], + inject: ['agentName', 'projectPath', 'canAdminCluster'], + props: { + clusterAgentId: { + required: true, + type: String, + }, + cursor: { + required: true, + type: Object, + }, + }, + modalId: CREATE_TOKEN_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_CLICK, + EVENT_LABEL_MODAL, + TOKEN_NAME_LIMIT, + i18n: { + createTokenButton: s__('ClusterAgents|Create token'), + modalTitle: s__('ClusterAgents|Create agent access token'), + unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), + errorTitle: s__('ClusterAgents|Failed to create a token'), + dropdownDisabledHint: s__( + 'ClusterAgents|Requires a Maintainer or greater role to perform these actions', + ), + modalCancel: __('Cancel'), + modalClose: __('Close'), + tokenNameLabel: __('Name'), + tokenDescriptionLabel: __('Description (optional)'), + }, + data() { + return { + token: { + name: null, + description: null, + }, + agentToken: null, + error: null, + loading: false, + variables: { + agentName: this.agentName, + projectPath: this.projectPath, + tokenStatus: TOKEN_STATUS_ACTIVE, + ...this.cursor, + }, + }; + }, + computed: { + modalBtnDisabled() { + return this.loading || !this.hasTokenName; + }, + hasTokenName() { + return Boolean(this.token.name?.length); + }, + }, + methods: { + async createToken() { + this.loading = true; + this.error = null; + + try { + const { errors: tokenErrors, secret } = await this.createAgentTokenMutation(); + + if (tokenErrors?.length > 0) { + throw new Error(tokenErrors[0]); + } + + this.agentToken = secret; + } catch (error) { + if (error) { + this.error = error.message; + } else { + this.error = this.$options.i18n.unknownError; + } + } finally { + this.loading = false; + } + }, + resetModal() { + this.agentToken = null; + this.token.name = null; + this.token.description = null; + this.error = null; + }, + closeModal() { + this.$refs.modal.hide(); + }, + createAgentTokenMutation() { + return this.$apollo + .mutate({ + mutation: createNewAgentToken, + variables: { + input: { + clusterAgentId: this.clusterAgentId, + name: this.token.name, + description: this.token.description, + }, + }, + update: (store, { data: { clusterAgentTokenCreate } }) => { + addAgentTokenToStore( + store, + clusterAgentTokenCreate, + getClusterAgentQuery, + this.variables, + ); + }, + }) + .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate); + }, + }, +}; +</script> + +<template> + <div> + <div ref="addToken" class="gl-display-inline-block"> + <gl-button + v-gl-modal-directive="$options.modalId" + :disabled="!canAdminCluster" + category="primary" + variant="confirm" + >{{ $options.i18n.createTokenButton }} + </gl-button> + + <gl-tooltip + v-if="!canAdminCluster" + :target="() => $refs.addToken" + :title="$options.i18n.dropdownDisabledHint" + /> + </div> + + <gl-modal + ref="modal" + :modal-id="$options.modalId" + :title="$options.i18n.modalTitle" + static + lazy + @hidden="resetModal" + @show="track($options.EVENT_ACTIONS_OPEN)" + > + <gl-alert + v-if="error" + :title="$options.i18n.errorTitle" + :dismissible="false" + variant="danger" + class="gl-mb-5" + > + {{ error }} + </gl-alert> + + <template v-if="!agentToken"> + <gl-form-group :label="$options.i18n.tokenNameLabel"> + <gl-form-input + v-model="token.name" + :max-length="$options.TOKEN_NAME_LIMIT" + :disabled="loading" + required + /> + </gl-form-group> + + <gl-form-group :label="$options.i18n.tokenDescriptionLabel"> + <gl-form-textarea v-model="token.description" :disabled="loading" name="description" /> + </gl-form-group> + </template> + + <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" /> + + <template #modal-footer> + <gl-button + v-if="!agentToken && !loading" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="close" + data-testid="agent-token-close-button" + @click="closeModal" + >{{ $options.i18n.modalCancel }} + </gl-button> + + <gl-button + v-if="!agentToken" + :disabled="modalBtnDisabled" + :loading="loading" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="create-token" + variant="confirm" + type="submit" + @click="createToken" + >{{ $options.i18n.createTokenButton }} + </gl-button> + + <gl-button + v-else + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="close" + variant="confirm" + @click="closeModal" + >{{ $options.i18n.modalClose }} + </gl-button> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index 63f068a9327..5df3e0811a5 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -143,7 +143,7 @@ export default { <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" /> <div v-else> - <token-table :tokens="tokens" /> + <token-table :tokens="tokens" :cluster-agent-id="clusterAgent.id" :cursor="cursor" /> <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" /> diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue index 019fac531d1..fbb39c28d78 100644 --- a/app/assets/javascripts/clusters/agents/components/token_table.vue +++ b/app/assets/javascripts/clusters/agents/components/token_table.vue @@ -1,17 +1,17 @@ <script> -import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; +import { GlEmptyState, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui'; import { s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import CreateTokenButton from './create_token_button.vue'; export default { components: { GlEmptyState, - GlLink, GlTable, GlTooltip, GlTruncate, TimeAgoTooltip, + CreateTokenButton, }, i18n: { createdBy: s__('ClusterAgents|Created by'), @@ -19,7 +19,6 @@ export default { dateCreated: s__('ClusterAgents|Date created'), description: s__('ClusterAgents|Description'), lastUsed: s__('ClusterAgents|Last contact'), - learnMore: s__('ClusterAgents|Learn how to create an agent access token'), name: s__('ClusterAgents|Name'), neverUsed: s__('ClusterAgents|Never'), noTokens: s__('ClusterAgents|This agent has no tokens'), @@ -30,6 +29,14 @@ export default { required: true, type: Array, }, + clusterAgentId: { + required: true, + type: String, + }, + cursor: { + required: true, + type: Object, + }, }, computed: { fields() { @@ -61,11 +68,6 @@ export default { }, ]; }, - learnMoreUrl() { - return helpPagePath('user/clusters/agent/install/index', { - anchor: 'register-an-agent-with-gitlab', - }); - }, }, methods: { createdByName(token) { @@ -77,11 +79,11 @@ export default { <template> <div v-if="tokens.length"> - <div class="gl-text-right gl-my-5"> - <gl-link target="_blank" :href="learnMoreUrl"> - {{ $options.i18n.learnMore }} - </gl-link> - </div> + <create-token-button + class="gl-text-right gl-my-5" + :cluster-agent-id="clusterAgentId" + :cursor="cursor" + /> <gl-table :items="tokens" @@ -120,10 +122,9 @@ export default { </gl-table> </div> - <gl-empty-state - v-else - :title="$options.i18n.noTokens" - :primary-button-link="learnMoreUrl" - :primary-button-text="$options.i18n.learnMore" - /> + <gl-empty-state v-else :title="$options.i18n.noTokens"> + <template #actions> + <create-token-button :cluster-agent-id="clusterAgentId" :cursor="cursor" /> + </template> + </gl-empty-state> </template> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index 98d4707b4de..50d8f5e9e40 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -37,3 +37,10 @@ export const EVENT_DETAILS = { export const DEFAULT_ICON = 'token'; export const TOKEN_STATUS_ACTIVE = 'ACTIVE'; + +export const CREATE_TOKEN_MODAL = 'create-token'; +export const EVENT_LABEL_MODAL = 'agent_token_creation_modal'; +export const EVENT_ACTIONS_OPEN = 'open_modal'; +export const EVENT_ACTIONS_CLICK = 'click_button'; + +export const TOKEN_NAME_LIMIT = 255; diff --git a/app/assets/javascripts/clusters/agents/graphql/cache_update.js b/app/assets/javascripts/clusters/agents/graphql/cache_update.js new file mode 100644 index 00000000000..0219c4150eb --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/cache_update.js @@ -0,0 +1,24 @@ +import produce from 'immer'; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +export function addAgentTokenToStore(store, clusterAgentTokenCreate, query, variables) { + if (!hasErrors(clusterAgentTokenCreate)) { + const { token } = clusterAgentTokenCreate; + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + draftData.project.clusterAgent.tokens.nodes.unshift(token); + draftData.project.clusterAgent.tokens.count += 1; + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} diff --git a/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql b/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql new file mode 100644 index 00000000000..4a61263ba70 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql @@ -0,0 +1,11 @@ +#import "../fragments/cluster_agent_token.fragment.graphql" + +mutation createNewAgentToken($input: ClusterAgentTokenCreateInput!) { + clusterAgentTokenCreate(input: $input) { + secret + token { + ...Token + } + errors + } +} diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js index ba7b3edba72..8a447f57f00 100644 --- a/app/assets/javascripts/clusters/agents/index.js +++ b/app/assets/javascripts/clusters/agents/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue'; import apolloProvider from './graphql/provider'; import createRouter from './router'; @@ -16,6 +17,8 @@ export default () => { canAdminVulnerability, emptyStateSvgPath, projectPath, + kasAddress, + canAdminCluster, } = el.dataset; return new Vue({ @@ -28,6 +31,8 @@ export default () => { canAdminVulnerability, emptyStateSvgPath, projectPath, + kasAddress, + canAdminCluster: parseBoolean(canAdminCluster), }, render(createElement) { return createElement(AgentShowPage); diff --git a/app/assets/javascripts/clusters/components/new_cluster.vue b/app/assets/javascripts/clusters/components/new_cluster.vue index 2e74ad073c5..8f3e2916270 100644 --- a/app/assets/javascripts/clusters/components/new_cluster.vue +++ b/app/assets/javascripts/clusters/components/new_cluster.vue @@ -5,9 +5,9 @@ import { s__ } from '~/locale'; export default { i18n: { - title: s__('ClusterIntegration|Enter the details for your Kubernetes cluster'), + title: s__('ClusterIntegration|Enter your Kubernetes cluster certificate details'), information: s__( - 'ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{linkStart}documentation%{linkEnd} on Kubernetes', + 'ClusterIntegration|Enter details about your cluster. %{linkStart}How do I use a certificate to connect to my cluster?%{linkEnd}', ), }, components: { @@ -21,7 +21,7 @@ export default { </script> <template> - <div> + <div class="gl-pt-4"> <h4>{{ $options.i18n.title }}</h4> <p> <gl-sprintf :message="$options.i18n.information"> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 61c4904aacf..1144ce68e2c 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -1,5 +1,13 @@ <script> -import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui'; +import { + GlLink, + GlTable, + GlIcon, + GlSprintf, + GlTooltip, + GlTooltipDirective, + GlPopover, +} from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -19,12 +27,18 @@ export default { TimeAgoTooltip, DeleteAgentButton, }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [timeagoMixin], AGENT_STATUSES, troubleshootingLink: helpPagePath('user/clusters/agent/troubleshooting'), versionUpdateLink: helpPagePath('user/clusters/agent/install/index', { anchor: 'update-the-agent-version', }), + configHelpLink: helpPagePath('user/clusters/agent/install/index', { + anchor: 'create-an-agent-without-configuration-file', + }), inject: ['gitlabVersion'], props: { agents: { @@ -256,7 +270,16 @@ export default { {{ getAgentConfigPath(item.name) }} </gl-link> - <span v-else>{{ getAgentConfigPath(item.name) }}</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" :size="14" /></gl-link + ></span> </span> </template> diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue new file mode 100644 index 00000000000..eab3fc3ed63 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/agent_token.vue @@ -0,0 +1,109 @@ +<script> +import { GlAlert, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; +import { generateAgentRegistrationCommand } from '../clusters_util'; +import { I18N_AGENT_TOKEN } from '../constants'; + +export default { + i18n: I18N_AGENT_TOKEN, + basicInstallPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'install-the-agent-into-the-cluster', + }), + advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'advanced-installation', + }), + components: { + GlAlert, + CodeBlock, + GlFormInputGroup, + GlLink, + GlSprintf, + ModalCopyButton, + }, + inject: ['kasAddress'], + props: { + agentToken: { + required: true, + type: String, + }, + modalId: { + required: true, + type: String, + }, + }, + computed: { + agentRegistrationCommand() { + return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); + }, + }, +}; +</script> + +<template> + <div> + <p> + <strong>{{ $options.i18n.tokenTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="$options.i18n.tokenBody"> + <template #link="{ content }"> + <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + + <p> + <gl-alert + :title="$options.i18n.tokenSingleUseWarningTitle" + variant="warning" + :dismissible="false" + > + {{ $options.i18n.tokenSingleUseWarningBody }} + </gl-alert> + </p> + + <p> + <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> + <template #append> + <modal-copy-button + :text="agentToken" + :title="$options.i18n.copyToken" + :modal-id="modalId" + /> + </template> + </gl-form-input-group> + </p> + + <p> + <strong>{{ $options.i18n.basicInstallTitle }}</strong> + </p> + + <p> + {{ $options.i18n.basicInstallBody }} + </p> + + <p class="gl-display-flex gl-align-items-flex-start"> + <code-block class="gl-w-full" :code="agentRegistrationCommand" /> + <modal-copy-button + :title="$options.i18n.copyCommand" + :text="agentRegistrationCommand" + :modal-id="modalId" + /> + </p> + + <p> + <strong>{{ $options.i18n.advancedInstallTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="$options.i18n.advancedInstallBody"> + <template #link="{ content }"> + <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index bf096f53e9d..70b9b8ac3c9 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -116,9 +116,6 @@ export default { }, }, methods: { - reloadAgents() { - this.$apollo.queries.agents.refetch(); - }, nextPage() { this.cursor = { first: MAX_LIST_COUNT, 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 1630d0d5c92..662cf2a7e36 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlSprintf, +} from '@gitlab/ui'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants'; export default { @@ -8,6 +14,9 @@ export default { components: { GlDropdown, GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlSprintf, }, props: { isRegistering: { @@ -22,6 +31,7 @@ export default { data() { return { selectedAgent: null, + searchTerm: '', }; }, computed: { @@ -34,22 +44,45 @@ export default { return this.selectedAgent; }, + shouldRenderCreateButton() { + return this.searchTerm && !this.availableAgents.includes(this.searchTerm); + }, + filteredResults() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.availableAgents.filter((resultString) => + resultString.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, }, methods: { selectAgent(agent) { this.$emit('agentSelected', agent); this.selectedAgent = agent; + this.clearSearch(); }, isSelected(agent) { return this.selectedAgent === agent; }, + clearSearch() { + this.searchTerm = ''; + }, + focusSearch() { + this.$refs.searchInput.focusInput(); + }, + handleShow() { + this.clearSearch(); + this.focusSearch(); + }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" :loading="isRegistering"> + <gl-dropdown :text="dropdownText" :loading="isRegistering" @shown="handleShow"> + <template #header> + <gl-search-box-by-type ref="searchInput" v-model.trim="searchTerm" /> + </template> <gl-dropdown-item - v-for="agent in availableAgents" + v-for="agent in filteredResults" :key="agent" :is-checked="isSelected(agent)" is-check-item @@ -57,5 +90,16 @@ export default { > {{ agent }} </gl-dropdown-item> + <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + $options.i18n.noResults + }}</gl-dropdown-item> + <template v-if="shouldRenderCreateButton"> + <gl-dropdown-divider /> + <gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)"> + <gl-sprintf :message="$options.i18n.createButton"> + <template #searchTerm>{{ searchTerm }}</template> + </gl-sprintf> + </gl-dropdown-item> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 7fb3aa3ff7e..59cfdde731d 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -6,7 +6,7 @@ import { GlPagination, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, - GlTable, + GlTableLite, GlTooltipDirective, } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; @@ -27,7 +27,7 @@ export default { GlPagination, GlSkeletonLoading, GlSprintf, - GlTable, + GlTableLite, NodeErrorHelpText, ClustersEmptyState, }, @@ -229,7 +229,7 @@ export default { <section v-else> <ancestor-notice /> - <gl-table + <gl-table-lite v-if="hasClusters" :items="clusters" :fields="fields" @@ -326,7 +326,7 @@ export default { {{ value }} </gl-badge> </template> - </gl-table> + </gl-table-lite> <clusters-empty-state v-else :is-child-component="isChildComponent" /> diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index 5b8dc74b84f..ccb973f1eb8 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -1,5 +1,6 @@ <script> import { + GlButton, GlDropdown, GlDropdownItem, GlModalDirective, @@ -14,6 +15,7 @@ export default { i18n: CLUSTERS_ACTIONS, INSTALL_AGENT_MODAL_ID, components: { + GlButton, GlDropdown, GlDropdownItem, GlDropdownDivider, @@ -23,11 +25,27 @@ export default { GlModalDirective, GlTooltip: GlTooltipDirective, }, - inject: ['newClusterPath', 'addClusterPath', 'canAddCluster'], + inject: [ + 'newClusterPath', + 'addClusterPath', + 'canAddCluster', + 'displayClusterAgents', + 'certificateBasedClustersEnabled', + ], computed: { tooltip() { - const { connectWithAgent, dropdownDisabledHint } = this.$options.i18n; - return this.canAddCluster ? connectWithAgent : dropdownDisabledHint; + const { connectWithAgent, connectExistingCluster, dropdownDisabledHint } = this.$options.i18n; + + if (!this.canAddCluster) { + return dropdownDisabledHint; + } else if (this.displayClusterAgents) { + return connectWithAgent; + } + + return connectExistingCluster; + }, + shouldTriggerModal() { + return this.canAddCluster && this.displayClusterAgents; }, }, }; @@ -36,25 +54,29 @@ export default { <template> <div class="nav-controls gl-ml-auto"> <gl-dropdown + v-if="certificateBasedClustersEnabled" ref="dropdown" - v-gl-modal-directive="canAddCluster && $options.INSTALL_AGENT_MODAL_ID" + v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID" v-gl-tooltip="tooltip" category="primary" variant="confirm" :text="$options.i18n.actionsButton" :disabled="!canAddCluster" - split + :split="displayClusterAgents" right > - <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header> - <gl-dropdown-item - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" - data-testid="connect-new-agent-link" - > - {{ $options.i18n.connectWithAgent }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header> + <template v-if="displayClusterAgents"> + <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header> + <gl-dropdown-item + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + data-testid="connect-new-agent-link" + > + {{ $options.i18n.connectWithAgent }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header> + </template> + <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> {{ $options.i18n.createNewCluster }} </gl-dropdown-item> @@ -62,5 +84,15 @@ export default { {{ $options.i18n.connectExistingCluster }} </gl-dropdown-item> </gl-dropdown> + <gl-button + v-else + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + v-gl-tooltip="tooltip" + :disabled="!canAddCluster" + category="primary" + variant="confirm" + > + {{ $options.i18n.connectWithAgent }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue index ce601de57bd..76bec05cfc7 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue @@ -13,7 +13,7 @@ export default { GlSprintf, GlAlert, }, - inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'], + inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'addClusterPath'], props: { isChildComponent: { default: false, @@ -57,7 +57,7 @@ export default { category="primary" variant="confirm" :disabled="!canAddCluster" - :href="newClusterPath" + :href="addClusterPath" > {{ $options.i18n.buttonText }} </gl-button> diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue index 7dd5ece9b8e..aab6d3dc1f0 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue @@ -3,11 +3,13 @@ import { GlTabs, GlTab } from '@gitlab/ui'; import Tracking from '~/tracking'; import { CLUSTERS_TABS, + CERTIFICATE_TAB, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT, EVENT_LABEL_TABS, EVENT_ACTIONS_CHANGE, + AGENT_TAB, } from '../constants'; import Agents from './agents.vue'; import InstallAgentModal from './install_agent_modal.vue'; @@ -27,8 +29,8 @@ export default { Agents, InstallAgentModal, }, - CLUSTERS_TABS, mixins: [trackingMixin], + inject: ['displayClusterAgents', 'certificateBasedClustersEnabled'], props: { defaultBranchName: { default: '.noBranch', @@ -42,13 +44,30 @@ export default { maxAgents: MAX_CLUSTERS_LIST, }; }, + computed: { + availableTabs() { + const clusterTabs = this.displayClusterAgents ? CLUSTERS_TABS : [CERTIFICATE_TAB]; + return this.certificateBasedClustersEnabled ? clusterTabs : [AGENT_TAB]; + }, + }, + watch: { + selectedTabIndex: { + handler(val) { + this.onTabChange(val); + }, + immediate: true, + }, + }, methods: { - onTabChange(tabName) { - this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName); - this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST; + setSelectedTab(tabName) { + this.selectedTabIndex = this.availableTabs.findIndex( + (tab) => tab.queryParamValue === tabName, + ); }, - trackTabChange(tab) { - const tabName = CLUSTERS_TABS[tab].queryParamValue; + onTabChange(tab) { + const tabName = this.availableTabs[tab].queryParamValue; + + this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST; this.track(EVENT_ACTIONS_CHANGE, { property: tabName }); }, }, @@ -61,10 +80,9 @@ export default { sync-active-tab-with-query-params nav-class="gl-flex-grow-1 gl-align-items-center" lazy - @input="trackTabChange" > <gl-tab - v-for="(tab, idx) in $options.CLUSTERS_TABS" + v-for="(tab, idx) in availableTabs" :key="idx" :title="tab.title" :query-param-value="tab.queryParamValue" @@ -74,7 +92,7 @@ export default { :is="tab.component" :default-branch-name="defaultBranchName" data-testid="clusters-tab-component" - @changeTab="onTabChange" + @changeTab="setSelectedTab" /> </gl-tab> 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 6588d304d5c..6f2c353a67b 100644 --- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue +++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue @@ -116,7 +116,7 @@ export default { this.$toast.show(this.error || successMessage); - this.$refs.modal.hide(); + this.$refs.modal?.hide(); } }, deleteAgentMutation() { 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 8fc0a66cd7e..ae0affe4c8b 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -1,18 +1,7 @@ <script> -import { - GlAlert, - GlButton, - GlFormGroup, - GlFormInputGroup, - GlLink, - GlModal, - GlSprintf, -} from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormGroup, GlLink, GlModal, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; -import CodeBlock from '~/vue_shared/components/code_block.vue'; import Tracking from '~/tracking'; -import { generateAgentRegistrationCommand } from '../clusters_util'; import { INSTALL_AGENT_MODAL_ID, I18N_AGENT_MODAL, @@ -30,39 +19,32 @@ import createAgentToken from '../graphql/mutations/create_agent_token.mutation.g 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'; const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL }); export default { modalId: INSTALL_AGENT_MODAL_ID, + i18n: I18N_AGENT_MODAL, EVENT_ACTIONS_OPEN, EVENT_ACTIONS_CLICK, EVENT_LABEL_MODAL, - basicInstallPath: helpPagePath('user/clusters/agent/install/index', { - anchor: 'install-the-agent-into-the-cluster', - }), - advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { - anchor: 'advanced-installation', - }), enableKasPath: helpPagePath('administration/clusters/kas'), - installAgentPath: helpPagePath('user/clusters/agent/install/index'), registerAgentPath: helpPagePath('user/clusters/agent/install/index', { anchor: 'register-an-agent-with-gitlab', }), components: { AvailableAgentsDropdown, - CodeBlock, + AgentToken, GlAlert, GlButton, GlFormGroup, - GlFormInputGroup, GlLink, GlModal, GlSprintf, - ModalCopyButton, }, mixins: [trackingMixin], - inject: ['projectPath', 'kasAddress', 'emptyStateImage'], + inject: ['projectPath', 'emptyStateImage'], props: { defaultBranchName: { default: '.noBranch', @@ -109,13 +91,10 @@ export default { return !this.registering && this.agentName !== null; }, canCancel() { - return !this.registered && !this.registering && this.isAgentRegistrationModal; + return !this.registered && !this.registering && !this.kasDisabled; }, canRegister() { - return !this.registered && this.isAgentRegistrationModal; - }, - agentRegistrationCommand() { - return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); + return !this.registered && !this.kasDisabled; }, getAgentsQueryVariables() { return { @@ -125,32 +104,20 @@ export default { projectPath: this.projectPath, }; }, - i18n() { - return I18N_AGENT_MODAL[this.modalType]; - }, + repositoryPath() { return `/${this.projectPath}`; }, modalType() { - return !this.availableAgents?.length && !this.registered - ? MODAL_TYPE_EMPTY - : MODAL_TYPE_REGISTER; + return this.kasDisabled ? MODAL_TYPE_EMPTY : MODAL_TYPE_REGISTER; }, modalSize() { - return this.isEmptyStateModal ? 'sm' : 'md'; - }, - isEmptyStateModal() { - return this.modalType === MODAL_TYPE_EMPTY; - }, - isAgentRegistrationModal() { - return this.modalType === MODAL_TYPE_REGISTER; - }, - isKasEnabledInEmptyStateModal() { - return this.isEmptyStateModal && !this.kasDisabled; + return this.kasDisabled ? 'sm' : 'md'; }, }, methods: { setAgentName(name) { + this.error = null; this.agentName = name; this.track(EVENT_ACTIONS_SELECT); }, @@ -194,13 +161,13 @@ export default { return createClusterAgent; }); }, - createAgentTokenMutation(agendId) { + createAgentTokenMutation(agentId) { return this.$apollo .mutate({ mutation: createAgentToken, variables: { input: { - clusterAgentId: agendId, + clusterAgentId: agentId, name: this.agentName, }, }, @@ -244,7 +211,7 @@ export default { if (error) { this.error = error.message; } else { - this.error = this.i18n.unknownError; + this.error = this.$options.i18n.unknownError; } } finally { this.registering = false; @@ -258,22 +225,21 @@ export default { <gl-modal ref="modal" :modal-id="$options.modalId" - :title="i18n.modalTitle" + :title="$options.i18n.modalTitle" :size="modalSize" static lazy @hidden="resetModal" @show="track($options.EVENT_ACTIONS_OPEN, { property: modalType })" > - <template v-if="isAgentRegistrationModal"> + <template v-if="!kasDisabled"> <template v-if="!registered"> - <p> - <strong>{{ i18n.selectAgentTitle }}</strong> - </p> - - <p class="gl-mb-0">{{ i18n.selectAgentBody }}</p> - <p> - <gl-link :href="$options.registerAgentPath"> {{ i18n.learnMoreLink }}</gl-link> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.modalBody"> + <template #link="{ content }"> + <gl-link :href="repositoryPath">{{ content }}</gl-link> + </template> + </gl-sprintf> </p> <form> @@ -287,90 +253,36 @@ export default { </gl-form-group> </form> - <p v-if="error"> - <gl-alert :title="i18n.registrationErrorTitle" variant="danger" :dismissible="false"> - {{ error }} - </gl-alert> - </p> - </template> - - <template v-else> - <p> - <strong>{{ i18n.tokenTitle }}</strong> - </p> - <p> - <gl-sprintf :message="i18n.tokenBody"> - <template #link="{ content }"> - <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> + <gl-link :href="$options.registerAgentPath"> {{ $options.i18n.learnMoreLink }}</gl-link> </p> - <p> - <gl-alert :title="i18n.tokenSingleUseWarningTitle" variant="warning" :dismissible="false"> - {{ i18n.tokenSingleUseWarningBody }} + <p v-if="error"> + <gl-alert + :title="$options.i18n.registrationErrorTitle" + variant="danger" + :dismissible="false" + > + {{ error }} </gl-alert> </p> - - <p> - <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> - <template #append> - <modal-copy-button - :text="agentToken" - :title="i18n.copyToken" - :modal-id="$options.modalId" - /> - </template> - </gl-form-input-group> - </p> - - <p> - <strong>{{ i18n.basicInstallTitle }}</strong> - </p> - - <p> - {{ i18n.basicInstallBody }} - </p> - - <p> - <code-block :code="agentRegistrationCommand" /> - </p> - - <p> - <strong>{{ i18n.advancedInstallTitle }}</strong> - </p> - - <p> - <gl-sprintf :message="i18n.advancedInstallBody"> - <template #link="{ content }"> - <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </p> </template> + + <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" /> </template> <template v-else> <div class="gl-text-center gl-mb-5"> - <img :alt="i18n.altText" :src="emptyStateImage" height="100" /> + <img :alt="$options.i18n.altText" :src="emptyStateImage" height="100" /> </div> <p v-if="kasDisabled"> - <gl-sprintf :message="i18n.enableKasText"> + <gl-sprintf :message="$options.i18n.enableKasText"> <template #link="{ content }"> <gl-link :href="$options.enableKasPath">{{ content }}</gl-link> </template> </gl-sprintf> </p> - - <p v-else> - <gl-sprintf :message="i18n.modalBody"> - <template #link="{ content }"> - <gl-link :href="$options.installAgentPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> </template> <template #modal-footer> @@ -382,7 +294,7 @@ export default { :data-track-label="$options.EVENT_LABEL_MODAL" data-track-property="close" @click="closeModal" - >{{ i18n.close }} + >{{ $options.i18n.close }} </gl-button> <gl-button @@ -391,7 +303,7 @@ export default { :data-track-label="$options.EVENT_LABEL_MODAL" data-track-property="cancel" @click="closeModal" - >{{ i18n.cancel }} + >{{ $options.i18n.cancel }} </gl-button> <gl-button @@ -403,25 +315,16 @@ export default { :data-track-label="$options.EVENT_LABEL_MODAL" data-track-property="register" @click="registerAgent" - >{{ i18n.registerAgentButton }} + >{{ $options.i18n.registerAgentButton }} </gl-button> <gl-button - v-if="isEmptyStateModal" + v-if="kasDisabled" :data-track-action="$options.EVENT_ACTIONS_CLICK" :data-track-label="$options.EVENT_LABEL_MODAL" data-track-property="done" @click="closeModal" - >{{ i18n.done }} - </gl-button> - - <gl-button - v-if="isKasEnabledInEmptyStateModal" - :href="repositoryPath" - variant="confirm" - category="primary" - data-testid="agent-primary-button" - >{{ i18n.primaryButton }} + >{{ $options.i18n.close }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 5cf6fd050a1..c914ee518b2 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -75,74 +75,74 @@ export const I18N_AGENT_TABLE = { neverConnectedText: s__('ClusterAgents|Never'), versionMismatchTitle: s__('ClusterAgents|Agent version mismatch'), versionMismatchText: s__( - "ClusterAgents|The Agent version do not match each other across your cluster's pods. This can happen when a new Agent version was just deployed and Kubernetes is shutting down the old pods.", + "ClusterAgents|The agent version do not match each other across your cluster's pods. This can happen when a new agent version was just deployed and Kubernetes is shutting down the old pods.", ), versionOutdatedTitle: s__('ClusterAgents|Agent version update required'), versionOutdatedText: s__( - 'ClusterAgents|Your Agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the Agent installed on your cluster to the most recent version.', + 'ClusterAgents|Your agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the agent installed on your cluster to the most recent version.', ), versionMismatchOutdatedTitle: s__('ClusterAgents|Agent version mismatch and update'), - viewDocsText: s__('ClusterAgents|How to update the Agent?'), + viewDocsText: s__('ClusterAgents|How to update an agent?'), + defaultConfigText: s__('ClusterAgents|Default configuration'), + defaultConfigTooltip: s__('ClusterAgents|What is default configuration?'), }; -export const I18N_AGENT_MODAL = { - agent_registration: { - registerAgentButton: s__('ClusterAgents|Register'), - close: __('Close'), - cancel: __('Cancel'), - - modalTitle: s__('ClusterAgents|Connect a cluster through the Agent'), - selectAgentTitle: s__('ClusterAgents|Select an agent to register with GitLab'), - selectAgentBody: s__( - 'ClusterAgents|Register an agent to generate a token that will be used to install the agent on your cluster in the next step.', - ), - learnMoreLink: s__('ClusterAgents|How to register an agent?'), +export const I18N_AGENT_TOKEN = { + copyToken: s__('ClusterAgents|Copy token'), + copyCommand: s__('ClusterAgents|Copy command'), + tokenTitle: s__('ClusterAgents|Registration token'), - copyToken: s__('ClusterAgents|Copy token'), - tokenTitle: s__('ClusterAgents|Registration token'), - tokenBody: s__( - `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, - ), + tokenBody: s__( + `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, + ), + tokenSingleUseWarningTitle: s__( + 'ClusterAgents|You cannot see this token again after you close this window.', + ), + tokenSingleUseWarningBody: s__( + `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`, + ), - tokenSingleUseWarningTitle: s__( - 'ClusterAgents|You cannot see this token again after you close this window.', - ), - tokenSingleUseWarningBody: s__( - `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`, - ), + basicInstallTitle: s__('ClusterAgents|Recommended installation method'), + basicInstallBody: __( + `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, + ), - basicInstallTitle: s__('ClusterAgents|Recommended installation method'), - basicInstallBody: __( - `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, - ), + advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), + advancedInstallBody: s__( + 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.', + ), +}; - advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), - advancedInstallBody: s__( - 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.', - ), +export const I18N_AGENT_MODAL = { + registerAgentButton: s__('ClusterAgents|Register'), + close: __('Close'), + cancel: __('Cancel'), - registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'), - unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), - }, - empty_state: { - modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'), - modalBody: s__( - "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}Learn more about installing GitLab Agent.%{linkEnd}", - ), - enableKasText: s__( - "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", - ), - altText: s__('ClusterAgents|GitLab Agent for Kubernetes'), - primaryButton: s__('ClusterAgents|Go to the repository files'), - done: __('Cancel'), - }, + modalTitle: s__('ClusterAgents|Connect a cluster through an agent'), + modalBody: s__( + 'ClusterAgents|Add an agent configuration file to %{linkStart}this repository%{linkEnd} and select it, or create a new one to register with GitLab:', + ), + enableKasText: s__( + "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", + ), + altText: s__('ClusterAgents|GitLab Agent for Kubernetes'), + learnMoreLink: s__('ClusterAgents|How do I register an agent?'), + copyToken: s__('ClusterAgents|Copy token'), + tokenTitle: s__('ClusterAgents|Registration token'), + tokenBody: s__( + `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, + ), + registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'), + unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), }; export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError'; export const I18N_AVAILABLE_AGENTS_DROPDOWN = { - selectAgent: s__('ClusterAgents|Select an agent'), - registeringAgent: s__('ClusterAgents|Registering Agent'), + selectAgent: s__('ClusterAgents|Select an agent or enter a name to create new'), + registeringAgent: s__('ClusterAgents|Registering agent'), + noResults: __('No matching results'), + createButton: s__('ClusterAgents|Create agent: %{searchTerm}'), }; export const AGENT_STATUSES = { @@ -197,8 +197,8 @@ export const I18N_CLUSTERS_EMPTY_STATE = { export const AGENT_CARD_INFO = { tabName: 'agent', - title: sprintf(s__('ClusterAgents|%{number} of %{total} Agents')), - emptyTitle: s__('ClusterAgents|No Agents'), + title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')), + emptyTitle: s__('ClusterAgents|No agents'), tooltip: { label: s__('ClusterAgents|Recommended'), title: s__('ClusterAgents|GitLab Agent'), @@ -209,7 +209,7 @@ export const AGENT_CARD_INFO = { ), link: helpPagePath('user/clusters/agent/index'), }, - actionText: s__('ClusterAgents|Install new Agent'), + actionText: s__('ClusterAgents|Install a new agent'), footerText: sprintf(s__('ClusterAgents|View all %{number} agents')), installAgentDisabledHint: s__( 'ClusterAgents|Requires a Maintainer or greater role to install new agents', @@ -232,28 +232,29 @@ export const CERTIFICATE_BASED_CARD_INFO = { export const MAX_CLUSTERS_LIST = 6; -export const CLUSTERS_TABS = [ - { - title: s__('ClusterAgents|All'), - component: 'ClustersViewAll', - queryParamValue: 'all', - }, - { - title: s__('ClusterAgents|Agent'), - component: 'agents', - queryParamValue: 'agent', - }, - { - title: s__('ClusterAgents|Certificate'), - component: 'clusters', - queryParamValue: 'certificate_based', - }, -]; +export const ALL_TAB = { + title: s__('ClusterAgents|All'), + component: 'ClustersViewAll', + queryParamValue: 'all', +}; + +export const AGENT_TAB = { + title: s__('ClusterAgents|Agent'), + component: 'agents', + queryParamValue: 'agent', +}; +export const CERTIFICATE_TAB = { + title: s__('ClusterAgents|Certificate'), + component: 'clusters', + queryParamValue: 'certificate_based', +}; + +export const CLUSTERS_TABS = [ALL_TAB, AGENT_TAB, CERTIFICATE_TAB]; export const CLUSTERS_ACTIONS = { actionsButton: s__('ClusterAgents|Actions'), createNewCluster: s__('ClusterAgents|Create a new cluster'), - connectWithAgent: s__('ClusterAgents|Connect with Agent'), + connectWithAgent: s__('ClusterAgents|Connect with an agent'), connectExistingCluster: s__('ClusterAgents|Connect with a certificate'), agent: s__('ClusterAgents|Agent'), certificate: s__('ClusterAgents|Certificate'), diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js index 6476b7a6c2f..e68f6a378c0 100644 --- a/app/assets/javascripts/clusters_list/graphql/cache_update.js +++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js @@ -1,5 +1,4 @@ import produce from 'immer'; -import { getAgentConfigPath } from '../clusters_util'; export const hasErrors = ({ errors = [] }) => errors?.length; @@ -12,17 +11,8 @@ export function addAgentToStore(store, createClusterAgent, query, variables) { }); const data = produce(sourceData, (draftData) => { - const configuration = { - id: clusterAgent.id, - name: clusterAgent.name, - path: getAgentConfigPath(clusterAgent.name), - webPath: clusterAgent.webPath, - __typename: 'TreeEntry', - }; - draftData.project.clusterAgents.nodes.push(clusterAgent); draftData.project.clusterAgents.count += 1; - draftData.project.repository.tree.trees.nodes.push(configuration); }); store.writeQuery({ 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 f8efb6683f6..7743ffba5de 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 @@ -7,9 +7,7 @@ query getAgents( $first: Int $last: Int $afterAgent: String - $afterTree: String $beforeAgent: String - $beforeTree: String ) { project(fullPath: $projectPath) { id @@ -27,17 +25,13 @@ query getAgents( repository { tree(path: ".gitlab/agents", ref: $defaultBranchName) { - trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) { + trees { nodes { id name path webPath } - - pageInfo { - ...PageInfo - } } } } diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index 6148483dcb0..27eebc9d891 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -1,13 +1,63 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import loadClusters from './load_clusters'; -import loadMainView from './load_main_view'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createDefaultClient from '~/lib/graphql'; +import ClustersMainView from './components/clusters_main_view.vue'; +import { createStore } from './store'; Vue.use(GlToast); Vue.use(VueApollo); export default () => { - loadClusters(Vue); - loadMainView(Vue, VueApollo); + const el = document.querySelector('.js-clusters-main-view'); + + if (!el) { + return null; + } + + const defaultClient = createDefaultClient(); + + const { + emptyStateImage, + defaultBranchName, + projectPath, + kasAddress, + newClusterPath, + addClusterPath, + emptyStateHelpText, + clustersEmptyStateImage, + canAddCluster, + canAdminCluster, + gitlabVersion, + displayClusterAgents, + certificateBasedClustersEnabled, + } = el.dataset; + + return new Vue({ + el, + apolloProvider: new VueApollo({ defaultClient }), + provide: { + emptyStateImage, + projectPath, + kasAddress, + newClusterPath, + addClusterPath, + emptyStateHelpText, + clustersEmptyStateImage, + canAddCluster: parseBoolean(canAddCluster), + canAdminCluster: parseBoolean(canAdminCluster), + gitlabVersion, + displayClusterAgents: parseBoolean(displayClusterAgents), + certificateBasedClustersEnabled: parseBoolean(certificateBasedClustersEnabled), + }, + store: createStore(el.dataset), + render(createElement) { + return createElement(ClustersMainView, { + props: { + defaultBranchName, + }, + }); + }, + }); }; diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js deleted file mode 100644 index 1bb3ea546b2..00000000000 --- a/app/assets/javascripts/clusters_list/load_clusters.js +++ /dev/null @@ -1,25 +0,0 @@ -import Clusters from './components/clusters.vue'; -import { createStore } from './store'; - -export default (Vue) => { - const el = document.querySelector('#js-clusters-list-app'); - - if (!el) { - return null; - } - - const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset; - - return new Vue({ - el, - provide: { - emptyStateHelpText, - newClusterPath, - clustersEmptyStateImage, - }, - store: createStore(el.dataset), - render(createElement) { - return createElement(Clusters); - }, - }); -}; diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js deleted file mode 100644 index d52b1d4a64d..00000000000 --- a/app/assets/javascripts/clusters_list/load_main_view.js +++ /dev/null @@ -1,57 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import createDefaultClient from '~/lib/graphql'; -import ClustersMainView from './components/clusters_main_view.vue'; -import { createStore } from './store'; - -Vue.use(VueApollo); - -export default () => { - const el = document.querySelector('.js-clusters-main-view'); - - if (!el) { - return null; - } - - const defaultClient = createDefaultClient(); - - const { - emptyStateImage, - defaultBranchName, - projectPath, - kasAddress, - newClusterPath, - addClusterPath, - emptyStateHelpText, - clustersEmptyStateImage, - canAddCluster, - canAdminCluster, - gitlabVersion, - } = el.dataset; - - return new Vue({ - el, - apolloProvider: new VueApollo({ defaultClient }), - provide: { - emptyStateImage, - projectPath, - kasAddress, - newClusterPath, - addClusterPath, - emptyStateHelpText, - clustersEmptyStateImage, - canAddCluster: parseBoolean(canAddCluster), - canAdminCluster: parseBoolean(canAdminCluster), - gitlabVersion, - }, - store: createStore(el.dataset), - render(createElement) { - return createElement(ClustersMainView, { - props: { - defaultBranchName, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue index d38b38947b6..5c77f087d63 100644 --- a/app/assets/javascripts/code_navigation/components/app.vue +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -7,6 +7,23 @@ export default { components: { Popover, }, + props: { + codeNavigationPath: { + type: String, + required: false, + default: null, + }, + blobPath: { + type: String, + required: false, + default: null, + }, + pathPrefix: { + type: String, + required: false, + default: null, + }, + }, computed: { ...mapState([ 'currentDefinition', @@ -16,6 +33,14 @@ export default { ]), }, mounted() { + if (this.codeNavigationPath && this.blobPath && this.pathPrefix) { + const initialData = { + blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }], + definitionPathPrefix: this.pathPrefix, + }; + this.setInitialData(initialData); + } + this.body = document.body; eventHub.$on('showBlobInteractionZones', this.showBlobInteractionZones); @@ -28,7 +53,7 @@ export default { this.removeGlobalEventListeners(); }, methods: { - ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones']), + ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones', 'setInitialData']), addGlobalEventListeners() { if (this.body) { this.body.addEventListener('click', this.showDefinition); diff --git a/app/assets/javascripts/code_quality_walkthrough/components/step.vue b/app/assets/javascripts/code_quality_walkthrough/components/step.vue deleted file mode 100644 index 1a23c96b7d6..00000000000 --- a/app/assets/javascripts/code_quality_walkthrough/components/step.vue +++ /dev/null @@ -1,150 +0,0 @@ -<script> -import { GlPopover, GlSprintf, GlButton, GlAlert } from '@gitlab/ui'; -import { STEPS, STEPSTATES } from '../constants'; -import { - isWalkthroughEnabled, - getExperimentSettings, - setExperimentSettings, - track, -} from '../utils'; - -export default { - target: '#js-code-quality-walkthrough', - components: { - GlPopover, - GlSprintf, - GlButton, - GlAlert, - }, - props: { - step: { - type: String, - required: true, - }, - link: { - type: String, - required: false, - default: null, - }, - }, - data() { - return { - dismissedSettings: getExperimentSettings(), - currentStep: STEPSTATES[this.step], - }; - }, - computed: { - isPopoverVisible() { - return ( - [ - STEPS.commitCiFile, - STEPS.runningPipeline, - STEPS.successPipeline, - STEPS.failedPipeline, - ].includes(this.step) && - isWalkthroughEnabled() && - !this.isDismissed - ); - }, - isAlertVisible() { - return this.step === STEPS.troubleshootJob && isWalkthroughEnabled() && !this.isDismissed; - }, - isDismissed() { - return this.dismissedSettings[this.step]; - }, - title() { - return this.currentStep?.title || ''; - }, - body() { - return this.currentStep?.body || ''; - }, - buttonText() { - return this.currentStep?.buttonText || ''; - }, - buttonLink() { - return [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) ? this.link : ''; - }, - placement() { - return this.currentStep?.placement || 'bottom'; - }, - offset() { - return this.currentStep?.offset || 0; - }, - }, - created() { - this.trackDisplayed(); - }, - updated() { - this.trackDisplayed(); - }, - methods: { - onDismiss() { - this.$set(this.dismissedSettings, this.step, true); - setExperimentSettings(this.dismissedSettings); - const action = [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) - ? 'view_logs' - : 'dismissed'; - this.trackAction(action); - }, - trackDisplayed() { - if (this.isPopoverVisible || this.isAlertVisible) { - this.trackAction('displayed'); - } - }, - trackAction(action) { - track(`${this.step}_${action}`); - }, - }, -}; -</script> - -<template> - <div> - <gl-popover - v-if="isPopoverVisible" - :key="step" - :target="$options.target" - :placement="placement" - :offset="offset" - show - triggers="manual" - container="viewport" - > - <template #title> - <gl-sprintf :message="title"> - <template #emoji="{ content }"> - <gl-emoji class="gl-mr-2" :data-name="content" - /></template> - </gl-sprintf> - </template> - <gl-sprintf :message="body"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #lineBreak> - <div class="gl-mt-5"></div> - </template> - <template #emoji="{ content }"> - <gl-emoji :data-name="content" /> - </template> - </gl-sprintf> - <div class="gl-mt-2 gl-text-right"> - <gl-button category="tertiary" variant="link" :href="buttonLink" @click="onDismiss"> - {{ buttonText }} - </gl-button> - </div> - </gl-popover> - <gl-alert - v-if="isAlertVisible" - variant="tip" - :title="title" - :primary-button-text="buttonText" - :primary-button-link="link" - class="gl-my-5" - @primaryAction="trackAction('clicked')" - @dismiss="onDismiss" - > - {{ body }} - </gl-alert> - </div> -</template> diff --git a/app/assets/javascripts/code_quality_walkthrough/constants.js b/app/assets/javascripts/code_quality_walkthrough/constants.js deleted file mode 100644 index 011df06b5cc..00000000000 --- a/app/assets/javascripts/code_quality_walkthrough/constants.js +++ /dev/null @@ -1,67 +0,0 @@ -import { s__ } from '~/locale'; - -export const EXPERIMENT_NAME = 'code_quality_walkthrough'; - -export const STEPS = { - commitCiFile: 'commit_ci_file', - runningPipeline: 'running_pipeline', - successPipeline: 'success_pipeline', - failedPipeline: 'failed_pipeline', - troubleshootJob: 'troubleshoot_job', -}; - -export const STEPSTATES = { - [STEPS.commitCiFile]: { - title: s__("codeQualityWalkthrough|Let's start by creating a new CI file."), - body: s__( - 'codeQualityWalkthrough|To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page.', - ), - buttonText: s__('codeQualityWalkthrough|Got it'), - placement: 'right', - offset: 90, - }, - [STEPS.runningPipeline]: { - title: s__( - 'codeQualityWalkthrough|Congrats! Your first pipeline is running %{emojiStart}zap%{emojiEnd}', - ), - body: s__( - "codeQualityWalkthrough|Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!", - ), - buttonText: s__('codeQualityWalkthrough|Got it'), - offset: 97, - }, - [STEPS.successPipeline]: { - title: s__( - "codeQualityWalkthrough|Well done! You've just automated your code quality review. %{emojiStart}raised_hands%{emojiEnd}", - ), - body: s__( - 'codeQualityWalkthrough|A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs.', - ), - buttonText: s__('codeQualityWalkthrough|View the logs'), - offset: 98, - }, - [STEPS.failedPipeline]: { - title: s__( - "codeQualityWalkthrough|Something went wrong. %{emojiStart}thinking%{emojiEnd} Let's fix it.", - ), - body: s__( - "codeQualityWalkthrough|Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it.", - ), - buttonText: s__('codeQualityWalkthrough|View the logs'), - offset: 98, - }, - [STEPS.troubleshootJob]: { - title: s__('codeQualityWalkthrough|Troubleshoot your code quality job'), - body: s__( - 'codeQualityWalkthrough|Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation.', - ), - buttonText: s__('codeQualityWalkthrough|Read the documentation'), - }, -}; - -export const PIPELINE_STATUSES = { - running: 'running', - successWithWarnings: 'success-with-warnings', - success: 'success', - failed: 'failed', -}; diff --git a/app/assets/javascripts/code_quality_walkthrough/index.js b/app/assets/javascripts/code_quality_walkthrough/index.js deleted file mode 100644 index b0592b8a84b..00000000000 --- a/app/assets/javascripts/code_quality_walkthrough/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; -import Step from './components/step.vue'; - -export default (el) => - new Vue({ - el, - render(createElement) { - return createElement(Step, { - props: { - step: el.dataset.step, - }, - }); - }, - }); diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js deleted file mode 100644 index 894ec9a171d..00000000000 --- a/app/assets/javascripts/code_quality_walkthrough/utils.js +++ /dev/null @@ -1,39 +0,0 @@ -import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; -import { getExperimentData } from '~/experimentation/utils'; -import { setCookie, getCookie } from '~/lib/utils/common_utils'; -import { getParameterByName } from '~/lib/utils/url_utility'; -import Tracking from '~/tracking'; -import { EXPERIMENT_NAME } from './constants'; - -export function getExperimentSettings() { - return JSON.parse(getCookie(EXPERIMENT_NAME) || '{}'); -} - -export function setExperimentSettings(settings) { - setCookie(EXPERIMENT_NAME, settings); -} - -export function isWalkthroughEnabled() { - return getParameterByName(EXPERIMENT_NAME); -} - -export function track(action) { - const { data } = getExperimentSettings(); - - if (data) { - Tracking.event(EXPERIMENT_NAME, action, { - context: { - schema: TRACKING_CONTEXT_SCHEMA, - data, - }, - }); - } -} - -export function startCodeQualityWalkthrough() { - const data = getExperimentData(EXPERIMENT_NAME); - - if (data) { - setExperimentSettings({ data }); - } -} diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js index 84ab728274f..784e9cb2faa 100644 --- a/app/assets/javascripts/commons/nav/user_merge_requests.js +++ b/app/assets/javascripts/commons/nav/user_merge_requests.js @@ -23,7 +23,18 @@ function updateReviewerMergeRequestCounts(newCount) { function updateMergeRequestCounts(newCount) { const mergeRequestsCountEl = document.querySelector('.js-merge-requests-count'); mergeRequestsCountEl.textContent = newCount.toLocaleString(); - mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0); + mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0); +} + +function updateAttentionRequestsCount(count) { + const attentionCountEl = document.querySelector('.js-attention-count'); + attentionCountEl.textContent = count.toLocaleString(); + + if (Number(count) === 0) { + attentionCountEl.classList.replace('badge-warning', 'badge-neutral'); + } else { + attentionCountEl.classList.replace('badge-neutral', 'badge-warning'); + } } /** @@ -32,14 +43,22 @@ function updateMergeRequestCounts(newCount) { export function refreshUserMergeRequestCounts() { return getUserCounts() .then(({ data }) => { + const attentionRequestsEnabled = window.gon?.features?.mrAttentionRequests; const assignedMergeRequests = data.assigned_merge_requests; const reviewerMergeRequests = data.review_requested_merge_requests; - const fullCount = assignedMergeRequests + reviewerMergeRequests; + const attentionRequests = data.attention_requests; + const fullCount = attentionRequestsEnabled + ? attentionRequests + : assignedMergeRequests + reviewerMergeRequests; updateUserMergeRequestCounts(assignedMergeRequests); updateReviewerMergeRequestCounts(reviewerMergeRequests); updateMergeRequestCounts(fullCount); broadcastCount(fullCount); + + if (attentionRequestsEnabled) { + updateAttentionRequestsCount(attentionRequests); + } }) .catch((ex) => { console.error(ex); // eslint-disable-line no-console diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index a8405fe37c7..a942c9f1149 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,17 +1,16 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; -import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; import { createContentEditor } from '../services/create_content_editor'; import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; import FormattingBubbleMenu from './formatting_bubble_menu.vue'; import TopToolbar from './top_toolbar.vue'; +import LoadingIndicator from './loading_indicator.vue'; export default { components: { - GlLoadingIcon, + LoadingIndicator, ContentEditorAlert, ContentEditorProvider, TiptapEditorContent, @@ -41,7 +40,6 @@ export default { }, data() { return { - isLoadingContent: false, focused: false, }; }, @@ -55,25 +53,14 @@ export default { extensions, serializerConfig, }); - - this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator); - this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator); - this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator); + }, + mounted() { this.$emit('initialized', this.contentEditor); }, beforeDestroy() { this.contentEditor.dispose(); - this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator); - this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator); - this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator); }, methods: { - displayLoadingIndicator() { - this.isLoadingContent = true; - }, - hideLoadingIndicator() { - this.isLoadingContent = false; - }, focus() { this.focused = true; }, @@ -100,13 +87,11 @@ export default { :class="{ 'is-focused': focused }" > <top-toolbar ref="toolbar" class="gl-mb-4" /> - <div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center"> - <gl-loading-icon size="sm" /> - </div> - <template v-else> + <div class="gl-relative"> <formatting-bubble-menu /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> - </template> + <loading-indicator /> + </div> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue index 630aff9858f..cba3b627390 100644 --- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue +++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue @@ -8,6 +8,7 @@ export default { return { contentEditor, + eventHub: contentEditor.eventHub, tiptapEditor: contentEditor.tiptapEditor, }; }, diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue index 0604047a953..02de6470cf2 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -1,5 +1,11 @@ <script> import { debounce } from 'lodash'; +import { + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, + ALERT_EVENT, +} from '../constants'; export const tiptapToComponentMap = { update: 'docUpdate', @@ -7,30 +13,48 @@ export const tiptapToComponentMap = { transaction: 'transaction', focus: 'focus', blur: 'blur', - alert: 'alert', }; +export const eventHubEvents = [ + ALERT_EVENT, + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, +]; + const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; export default { - inject: ['tiptapEditor'], + inject: ['tiptapEditor', 'eventHub'], created() { this.disposables = []; Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => { - const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100); + const eventHandler = debounce( + (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params), + 100, + ); this.tiptapEditor?.on(tiptapEvent, eventHandler); this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler)); }); + + eventHubEvents.forEach((event) => { + const handler = (...params) => { + this.bubbleEvent(event, ...params); + }; + + this.eventHub.$on(event, handler); + this.disposables.push(() => this.eventHub?.$off(event, handler)); + }); }, beforeDestroy() { this.disposables.forEach((dispose) => dispose()); }, methods: { - handleTipTapEvent(tiptapEvent, params) { - this.$emit(getComponentEventName(tiptapEvent), params); + bubbleEvent(eventHubEvent, params) { + this.$emit(eventHubEvent, params); }, }, render() { diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue new file mode 100644 index 00000000000..5b9383d6e11 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -0,0 +1,39 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import EditorStateObserver from './editor_state_observer.vue'; + +export default { + components: { + GlLoadingIcon, + EditorStateObserver, + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + displayLoadingIndicator() { + this.isLoading = true; + }, + hideLoadingIndicator() { + this.isLoading = false; + }, + }, +}; +</script> +<template> + <editor-state-observer + @loading="displayLoadingIndicator" + @loadingSuccess="hideLoadingIndicator" + @loadingError="hideLoadingIndicator" + > + <div + v-if="isLoading" + class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" + > + <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> + <gl-loading-icon size="md" /> + </div> + </editor-state-observer> +</template> diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index 5e56078df01..a39a243ec6b 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants.js @@ -42,9 +42,10 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [ }, ]; -export const LOADING_CONTENT_EVENT = 'loadingContent'; +export const LOADING_CONTENT_EVENT = 'loading'; export const LOADING_SUCCESS_EVENT = 'loadingSuccess'; export const LOADING_ERROR_EVENT = 'loadingError'; +export const ALERT_EVENT = 'alert'; export const PARSE_HTML_PRIORITY_LOWEST = 1; export const PARSE_HTML_PRIORITY_DEFAULT = 50; @@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75; * https://tiptap.dev/guide/custom-extensions/#priority */ export const EXTENSION_PRIORITY_DEFAULT = 100; +export const EXTENSION_PRIORITY_HIGHEST = 200; diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js index 72df1d071d1..9634730f637 100644 --- a/app/assets/javascripts/content_editor/extensions/attachment.js +++ b/app/assets/javascripts/content_editor/extensions/attachment.js @@ -9,15 +9,22 @@ export default Extension.create({ return { uploadsPath: null, renderMarkdown: null, + eventHub: null, }; }, addCommands() { return { uploadAttachment: ({ file }) => () => { - const { uploadsPath, renderMarkdown } = this.options; + const { uploadsPath, renderMarkdown, eventHub } = this.options; - return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor }); + return handleFileEvent({ + file, + uploadsPath, + renderMarkdown, + editor: this.editor, + eventHub, + }); }, }; }, @@ -29,23 +36,25 @@ export default Extension.create({ key: new PluginKey('attachment'), props: { handlePaste: (_, event) => { - const { uploadsPath, renderMarkdown } = this.options; + const { uploadsPath, renderMarkdown, eventHub } = this.options; return handleFileEvent({ editor, file: event.clipboardData.files[0], uploadsPath, renderMarkdown, + eventHub, }); }, handleDrop: (_, event) => { - const { uploadsPath, renderMarkdown } = this.options; + const { uploadsPath, renderMarkdown, eventHub } = this.options; return handleFileEvent({ editor, file: event.dataTransfer.files[0], uploadsPath, renderMarkdown, + eventHub, }); }, }, diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 9dc17fcd570..204ac07d401 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,5 +1,5 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; -import * as lowlight from 'lowlight'; +import { lowlight } from 'lowlight/lib/all'; const extractLanguage = (element) => element.getAttribute('lang'); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js new file mode 100644 index 00000000000..c349aa42a62 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -0,0 +1,86 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { __ } from '~/locale'; +import { VARIANT_DANGER } from '~/flash'; +import createMarkdownDeserializer from '../services/markdown_deserializer'; +import { + ALERT_EVENT, + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, + EXTENSION_PRIORITY_HIGHEST, +} from '../constants'; + +const TEXT_FORMAT = 'text/plain'; +const HTML_FORMAT = 'text/html'; +const VS_CODE_FORMAT = 'vscode-editor-data'; + +export default Extension.create({ + name: 'pasteMarkdown', + priority: EXTENSION_PRIORITY_HIGHEST, + addOptions() { + return { + renderMarkdown: null, + }; + }, + addCommands() { + return { + pasteMarkdown: (markdown) => () => { + const { editor, options } = this; + const { renderMarkdown, eventHub } = options; + const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + + eventHub.$emit(LOADING_CONTENT_EVENT); + + deserializer + .deserialize({ schema: editor.schema, content: markdown }) + .then(({ document }) => { + if (!document) { + return; + } + + const { state, view } = editor; + const { tr, selection } = state; + + tr.replaceWith(selection.from - 1, selection.to, document.content); + view.dispatch(tr); + eventHub.$emit(LOADING_SUCCESS_EVENT); + }) + .catch(() => { + eventHub.$emit(ALERT_EVENT, { + message: __('An error occurred while pasting text in the editor. Please try again.'), + variant: VARIANT_DANGER, + }); + eventHub.$emit(LOADING_ERROR_EVENT); + }); + + return true; + }, + }; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('pasteMarkdown'), + props: { + handlePaste: (_, event) => { + const { clipboardData } = event; + const content = clipboardData.getData(TEXT_FORMAT); + const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT); + const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT); + const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; + const language = vsCodeMeta.mode; + + if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) { + return false; + } + + this.editor.commands.pasteMarkdown(content); + + return true; + }, + }, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js index 004bb8b815c..d7456ab4094 100644 --- a/app/assets/javascripts/content_editor/extensions/table.js +++ b/app/assets/javascripts/content_editor/extensions/table.js @@ -1,5 +1,6 @@ import { Table } from '@tiptap/extension-table'; import { debounce } from 'lodash'; +import { VARIANT_WARNING } from '~/flash'; import { __ } from '~/locale'; import { getMarkdownSource } from '../services/markdown_sourcemap'; import { shouldRenderHTMLTable } from '../services/serialization_helpers'; @@ -14,7 +15,7 @@ const onUpdate = debounce((editor) => { message: __( 'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.', ), - variant: 'warning', + variant: VARIANT_WARNING, }); alertShown = true; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index a387322bff7..c5638da2daf 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,17 +1,23 @@ -import eventHubFactory from '~/helpers/event_hub_factory'; +import { TextSelection } from 'prosemirror-state'; import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; + /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer }) { + constructor({ tiptapEditor, serializer, deserializer, eventHub }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; - this._eventHub = eventHubFactory(); + this._deserializer = deserializer; + this._eventHub = eventHub; } get tiptapEditor() { return this._tiptapEditor; } + get eventHub() { + return this._eventHub; + } + get empty() { const doc = this.tiptapEditor?.state.doc; @@ -23,39 +29,31 @@ export class ContentEditor { this.tiptapEditor.destroy(); } - once(type, handler) { - this._eventHub.$once(type, handler); - } - - on(type, handler) { - this._eventHub.$on(type, handler); - } - - emit(type, params = {}) { - this._eventHub.$emit(type, params); - } - - off(type, handler) { - this._eventHub.$off(type, handler); - } - disposeAllEvents() { this._eventHub.dispose(); } async setSerializedContent(serializedContent) { - const { _tiptapEditor: editor, _serializer: serializer } = this; + const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this; + const { doc, tr } = editor.state; + const selection = TextSelection.create(doc, 0, doc.content.size); try { - this._eventHub.$emit(LOADING_CONTENT_EVENT); - const document = await serializer.deserialize({ + eventHub.$emit(LOADING_CONTENT_EVENT); + const { document } = await deserializer.deserialize({ schema: editor.schema, content: serializedContent, }); - editor.commands.setContent(document); - this._eventHub.$emit(LOADING_SUCCESS_EVENT); + + if (document) { + tr.setSelection(selection) + .replaceSelectionWith(document, false) + .setMeta('preventUpdate', true); + editor.view.dispatch(tr); + } + eventHub.$emit(LOADING_SUCCESS_EVENT); } catch (e) { - this._eventHub.$emit(LOADING_ERROR_EVENT, e); + eventHub.$emit(LOADING_ERROR_EVENT, e); throw e; } } 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 f451357e211..d9d39a387d0 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,5 +1,6 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; import Audio from '../extensions/audio'; @@ -38,6 +39,7 @@ import Loading from '../extensions/loading'; import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; +import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; @@ -54,6 +56,7 @@ import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; +import createMarkdownDeserializer from './markdown_deserializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => @@ -78,8 +81,10 @@ export const createContentEditor = ({ throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); } + const eventHub = eventHubFactory(); + const builtInContentEditorExtensions = [ - Attachment.configure({ uploadsPath, renderMarkdown }), + Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), Audio, Blockquote, Bold, @@ -116,6 +121,7 @@ export const createContentEditor = ({ MathInline, OrderedList, Paragraph, + PasteMarkdown.configure({ renderMarkdown, eventHub }), Reference, Strike, Subscript, @@ -135,7 +141,8 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); - const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig }); + const serializer = createMarkdownSerializer({ serializerConfig }); + const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - return new ContentEditor({ tiptapEditor, serializer }); + return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer }); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_deserializer.js b/app/assets/javascripts/content_editor/services/markdown_deserializer.js new file mode 100644 index 00000000000..cd4863d8eac --- /dev/null +++ b/app/assets/javascripts/content_editor/services/markdown_deserializer.js @@ -0,0 +1,33 @@ +import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; + +export default ({ render }) => { + /** + * Converts a Markdown string into a ProseMirror JSONDocument based + * on a ProseMirror schema. + * + * @param {Object} options — The schema and content for deserialization + * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines + * the types of content supported in the document + * @param {String} params.content An arbitrary markdown string + * + * @returns An object with the following properties: + * - document: A ProseMirror document object generated from the deserialized Markdown + * - dom: The Markdown Deserializer renders Markdown as HTML to generate the ProseMirror + * document. The dom property contains the HTML generated from the Markdown Source. + */ + return { + deserialize: async ({ schema, content }) => { + const html = await render(content); + + if (!html) return {}; + + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + + // append original source as a comment that nodes can access + body.append(document.createComment(content)); + + return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body), dom: body }; + }, + }; +}; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 925b411e51c..eaaf69c3068 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -1,4 +1,3 @@ -import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer, @@ -237,31 +236,7 @@ const defaultSerializerConfig = { * that parses the Markdown and converts it into HTML. * @returns a markdown serializer */ -export default ({ render = () => null, serializerConfig = {} } = {}) => ({ - /** - * Converts a Markdown string into a ProseMirror JSONDocument based - * on a ProseMirror schema. - * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines - * the types of content supported in the document - * @param {String} params.content An arbitrary markdown string - * @returns A ProseMirror JSONDocument - */ - deserialize: async ({ schema, content }) => { - const html = await render(content); - - if (!html) return null; - - const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); - - // append original source as a comment that nodes can access - body.append(document.createComment(content)); - - const state = ProseMirrorDOMParser.fromSchema(schema).parse(body); - - return state.toJSON(); - }, - +export default ({ serializerConfig = {} } = {}) => ({ /** * Converts a ProseMirror JSONDocument based * on a ProseMirror schema into Markdown diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index a1199589c9b..4285e04bbab 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -1,7 +1,9 @@ -const getFullSource = (element) => { +import { isString } from 'lodash'; + +export const getFullSource = (element) => { const commentNode = element.ownerDocument.body.lastChild; - if (commentNode.nodeName === '#comment') { + if (commentNode?.nodeName === '#comment' && isString(commentNode.textContent)) { return commentNode.textContent.split('\n'); } diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 4d5a54c0347..5fdd294aa96 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -259,11 +259,16 @@ export function renderContent(state, node, forceRenderInline) { } } -export function renderHTMLNode(tagName, forceRenderInline = false) { +export function renderHTMLNode(tagName, forceRenderContentInline = false) { return (state, node) => { renderTagOpen(state, tagName, node.attrs); - renderContent(state, node, forceRenderInline); + renderContent(state, node, forceRenderContentInline); renderTagClose(state, tagName, false); + + if (forceRenderContentInline) { + state.closeBlock(node); + state.flushClose(); + } }; } diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index f5bf2742748..1abecb8f414 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -1,3 +1,4 @@ +import { VARIANT_DANGER } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import { extractFilename, readFileAsDataURL } from './utils'; @@ -49,7 +50,7 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { return extractAttachmentLinkUrl(rendered); }; -const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { +const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { const encodedSrc = await readFileAsDataURL(file); const { view } = editor; @@ -72,14 +73,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { ); } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); - editor.emit('alert', { + eventHub.$emit('alert', { message: __('An error occurred while uploading the image. Please try again.'), - variant: 'danger', + variant: VARIANT_DANGER, }); } }; -const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => { +const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { await Promise.resolve(); const { view } = editor; @@ -103,23 +104,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) = ); } catch (e) { editor.commands.deleteRange({ from, to: from + 1 }); - editor.emit('alert', { + eventHub.$emit('alert', { message: __('An error occurred while uploading the file. Please try again.'), - variant: 'danger', + variant: VARIANT_DANGER, }); } }; -export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { +export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { if (!file) return false; if (acceptedMimes.image.includes(file?.type)) { - uploadImage({ editor, file, uploadsPath, renderMarkdown }); + uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub }); return true; } - uploadAttachment({ editor, file, uploadsPath, renderMarkdown }); + uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub }); return true; }; diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js index 45b569066f8..79f5c701fb8 100644 --- a/app/assets/javascripts/contributors/stores/getters.js +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -7,10 +7,11 @@ export const parsedData = (state) => { state.chartData.forEach(({ date, author_name, author_email }) => { total[date] = total[date] ? total[date] + 1 : 1; - const authorData = byAuthorEmail[author_email]; + const normalizedEmail = author_email.toLowerCase(); + const authorData = byAuthorEmail[normalizedEmail]; if (!authorData) { - byAuthorEmail[author_email] = { + byAuthorEmail[normalizedEmail] = { name: author_name, commits: 1, dates: { diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue deleted file mode 100644 index 4c44aac4e2a..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; - -export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - count: { - type: Number, - required: true, - }, - }, -}; -</script> -<template> - <span v-if="count === 50" class="events-info float-right"> - <gl-icon - v-gl-tooltip="{ - title: n__( - 'Limited to showing %d event at most', - 'Limited to showing %d events at most', - 50, - ), - }" - name="warning" - /> - {{ n__('Showing %d event', 'Showing %d events', 50) }} - </span> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index ea5a1291a17..6a45969fd1a 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -18,7 +18,7 @@ import { PAGINATION_SORT_DIRECTION_ASC, PAGINATION_SORT_DIRECTION_DESC, } from '../constants'; -import TotalTime from './total_time_component.vue'; +import TotalTime from './total_time.vue'; const DEFAULT_WORKFLOW_TITLE_PROPERTIES = { thClass: 'gl-w-half', diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time.vue index a5a90a56974..a5a90a56974 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/total_time.vue diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue index 64461797c46..66bccf19496 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue @@ -1,16 +1,28 @@ <script> +import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; import DateRange from '~/analytics/shared/components/daterange.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants'; import FilterBar from './filter_bar.vue'; +export const AGGREGATION_TOGGLE_LABEL = s__('CycleAnalytics|Filter by stop date'); +export const AGGREGATION_DESCRIPTION = s__( + 'CycleAnalytics|When enabled, the results show items with a stop event within the date range. When disabled, the results show items with a start event within the date range.', +); + export default { name: 'ValueStreamFilters', components: { + GlIcon, + GlToggle, DateRange, ProjectsDropdownFilter, FilterBar, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { selectedProjects: { type: Array, @@ -45,6 +57,21 @@ export default { required: false, default: null, }, + canToggleAggregation: { + type: Boolean, + required: false, + default: false, + }, + isAggregationEnabled: { + type: Boolean, + required: false, + default: false, + }, + isUpdatingAggregationData: { + type: Boolean, + required: false, + default: false, + }, }, computed: { projectsQueryParams() { @@ -54,8 +81,19 @@ export default { }; }, }, + methods: { + onUpdateAggregation(ev) { + if (!this.isUpdatingAggregationData) { + this.$emit('toggleAggregation', ev); + } + }, + }, multiProjectSelect: true, maxDateRange: DATE_RANGE_LIMIT, + i18n: { + AGGREGATION_TOGGLE_LABEL, + AGGREGATION_DESCRIPTION, + }, }; </script> <template> @@ -84,7 +122,28 @@ export default { @selected="$emit('selectProject', $event)" /> </div> - <div> + <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row"> + <div + v-if="canToggleAggregation" + class="gl-display-flex gl-text-align-center gl-my-2 gl-lg-mt-0 gl-lg-mb-0 gl-mr-5" + > + <gl-toggle + class="gl-flex-direction-row" + :value="isAggregationEnabled" + :label="$options.i18n.AGGREGATION_TOGGLE_LABEL" + :disabled="isUpdatingAggregationData" + label-position="left" + @change="onUpdateAggregation" + > + <template #label> + {{ $options.i18n.AGGREGATION_TOGGLE_LABEL }} <gl-icon + v-gl-tooltip.hover + :title="$options.i18n.AGGREGATION_DESCRIPTION" + name="information-o" + /> + </template> + </gl-toggle> + </div> <date-range v-if="hasDateRangeFilter" :start-date="startDate" diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue index fdf8b7796bf..7879357a042 100644 --- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue +++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue @@ -17,9 +17,6 @@ export default { revokePath: { default: '', }, - buttonClass: { - default: '', - }, }, computed: { modalId() { @@ -38,10 +35,9 @@ export default { <div> <gl-button v-gl-modal="modalId" - :class="buttonClass" category="primary" variant="danger" - class="float-right" + class="gl-float-right" data-testid="revoke-button" >{{ s__('DeployTokens|Revoke') }}</gl-button > diff --git a/app/assets/javascripts/deploy_tokens/init_revoke_button.js b/app/assets/javascripts/deploy_tokens/init_revoke_button.js index 20187150a60..bc3f3c9ddf4 100644 --- a/app/assets/javascripts/deploy_tokens/init_revoke_button.js +++ b/app/assets/javascripts/deploy_tokens/init_revoke_button.js @@ -9,14 +9,13 @@ export default () => { } return containers.forEach((el) => { - const { token, revokePath, buttonClass } = el.dataset; + const { token, revokePath } = el.dataset; return new Vue({ el, provide: { token: JSON.parse(token), revokePath, - buttonClass, }, render(h) { return h(RevokeButton); diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 82bbbe891e2..0707ae02872 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-properties, babel/camelcase, no-unused-expressions, default-case, -consistent-return, no-alert, no-param-reassign, +consistent-return, no-param-reassign, no-shadow, no-useless-escape, class-methods-use-this */ @@ -17,9 +17,11 @@ import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; 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 Autosave from './autosave'; import loadAwardsHandler from './awards_handler'; import createFlash from './flash'; @@ -243,7 +245,7 @@ export default class Notes { }); } - keydownNoteText(e) { + async keydownNoteText(e) { let discussionNoteForm; let editNote; let myLastNote; @@ -276,9 +278,11 @@ export default class Notes { discussionNoteForm = $textarea.closest('.js-discussion-note-form'); if (discussionNoteForm.length) { if ($textarea.val() !== '') { - if (!window.confirm(__('Your comment will be discarded.'))) { - return; - } + const confirmed = await confirmAction(__('Your comment will be discarded.'), { + primaryBtnVariant: 'danger', + primaryBtnText: __('Discard'), + }); + if (!confirmed) return; } this.removeDiscussionNoteForm(discussionNoteForm); return; @@ -288,9 +292,14 @@ export default class Notes { originalText = $textarea.closest('form').data('originalNote'); newText = $textarea.val(); if (originalText !== newText) { - if (!window.confirm(__('Are you sure you want to discard this comment?'))) { - return; - } + const confirmed = await confirmAction( + __('Are you sure you want to discard this comment?'), + { + primaryBtnVariant: 'danger', + primaryBtnText: __('Discard'), + }, + ); + if (!confirmed) return; } return this.removeNoteEditForm(editNote); } @@ -1753,9 +1762,11 @@ export default class Notes { // Show updated comment content temporarily $noteBodyText.html(formContent); $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); - $editingNote - .find('.note-headline-meta a') - .html('<span class="spinner align-text-bottom"></span>'); + + const $timeAgo = $editingNote.find('.note-headline-meta a'); + + $timeAgo.empty(); + $timeAgo.append(loadingIconForLegacyJS({ inline: true, size: 'sm' })); // Make request to update comment on server axios diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 14d6e2db09d..a12829f8420 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { merge } from 'lodash'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -41,6 +42,8 @@ export default class Diff { } this.openAnchoredDiff(); + + this.prepareRenderedDiff(); } handleClickUnfold(e) { @@ -150,4 +153,43 @@ export default class Diff { .addClass('hll'); } } + + prepareRenderedDiff() { + const $elements = $('[data-diff-toggle-entity]'); + + if ($elements.length === 0) return; + + const diff = this; + + const elements = $elements.toArray().map(this.formatElementToObject).reduce(merge); + + Object.values(elements).forEach((e) => { + e.toShowBtn.onclick = () => diff.showOneHideAnother('rendered', e); // eslint-disable-line no-param-reassign + e.toHideBtn.onclick = () => diff.showOneHideAnother('raw', e); // eslint-disable-line no-param-reassign + + diff.showOneHideAnother('rendered', e); + }); + } + + formatElementToObject = (element) => { + const key = element.attributes['data-file-hash'].value; + const name = element.attributes['data-diff-toggle-entity'].value; + + return { [key]: { [name]: element } }; + }; + + showOneHideAnother = (mode, elements) => { + let { toShowBtn, toHideBtn, toShow, toHide } = elements; + + if (mode === 'raw') { + [toShowBtn, toHideBtn] = [toHideBtn, toShowBtn]; + [toShow, toHide] = [toHide, toShow]; + } + + toShowBtn.classList.add('selected'); + toHideBtn.classList.remove('selected'); + + toHide.classList.add('hidden'); + toShow.classList.remove('hidden'); + }; } diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index df7cf83b3f0..ba10f6deb29 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -72,8 +72,6 @@ export default { return this.author.id ? this.author.id : ''; }, authorUrl() { - // name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives - // eslint-disable-next-line @gitlab/require-i18n-strings return this.author.web_url || `mailto:${this.commit.author_email}`; }, authorAvatar() { diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 858d9e221ae..7ed5713ebfa 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -163,8 +163,8 @@ export default { v-if="diffFile.discussions.length" class="diff-file-discussions" :discussions="diffFile.discussions" - :should-collapse-discussions="true" - :render-avatar-badge="true" + should-collapse-discussions + render-avatar-badge /> <diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" /> <note-form diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 3cf1f69b08c..495c87a695c 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -431,7 +431,7 @@ export default { class="js-ide-edit-blob" data-qa-selector="edit_in_ide_button" > - {{ __('Edit in Web IDE') }} + {{ __('Open in Web IDE') }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 333bf1b356c..f46b0a538f1 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,4 +1,5 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters, mapState, mapActions } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; import DraftNote from '~/batch_comments/components/draft_note.vue'; @@ -19,6 +20,9 @@ export default { DiffCommentCell, DraftNote, }, + directives: { + SafeHtml, + }, mixins: [ draftCommentsMixin, glFeatureFlagsMixin(), @@ -173,15 +177,17 @@ export default { <div class="diff-grid-left diff-grid-3-col left-side"> <div class="diff-td diff-line-num"></div> <div v-if="inline" class="diff-td diff-line-num"></div> - <div class="diff-td line_content left-side gl-white-space-normal!"> - {{ line.left.rich_text }} - </div> + <div + v-safe-html="line.left.rich_text" + class="diff-td line_content left-side gl-white-space-normal!" + ></div> </div> <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side"> <div class="diff-td diff-line-num"></div> - <div class="diff-td line_content right-side gl-white-space-normal!"> - {{ line.left.rich_text }} - </div> + <div + v-safe-html="line.left.rich_text" + class="diff-td line_content right-side gl-white-space-normal!" + ></div> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue index baf7471582a..b9962682848 100644 --- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -1,6 +1,19 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const i18n = { + title: __('Too many changes to show.'), + plainDiff: __('Plain diff'), + emailPatch: __('Email patch'), +}; + export default { + i18n, + components: { + GlAlert, + GlSprintf, + }, props: { total: { type: String, @@ -23,17 +36,28 @@ export default { </script> <template> - <div class="alert alert-warning"> - <h4> - {{ __('Too many changes to show.') }} - <div class="float-right"> - <a :href="plainDiffPath" class="btn btn-sm"> {{ __('Plain diff') }} </a> - <a :href="emailPatchPath" class="btn btn-sm"> {{ __('Email patch') }} </a> - </div> - </h4> - <p> - To preserve performance only <strong> {{ visible }} of {{ total }} </strong> files are - displayed. - </p> - </div> + <gl-alert + variant="warning" + :title="$options.i18n.title" + :primary-button-text="$options.i18n.plainDiff" + :primary-button-link="plainDiffPath" + :secondary-button-text="$options.i18n.emailPatch" + :secondary-button-link="emailPatchPath" + :dismissible="false" + > + <gl-sprintf + :message=" + sprintf( + __( + 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.', + ), + { visible, total }, + ) + " + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-alert> </template> diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 65ffd42fa27..734407dec45 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -15,24 +15,19 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { // startVersion only exists if the user has selected a version other // than "base" so if startVersion is null then base must be selected - const defaultMergeRefForDiffs = window.gon?.features?.defaultMergeRefForDiffs || false; const diffHeadParam = getParameterByName('diff_head'); - const diffHead = parseBoolean(diffHeadParam) || (!diffHeadParam && defaultMergeRefForDiffs); - const isBaseSelected = !state.startVersion && !diffHead; + const diffHead = parseBoolean(diffHeadParam) || !diffHeadParam; + const isBaseSelected = !state.startVersion; const isHeadSelected = !state.startVersion && diffHead; let baseVersion = null; - if ( - !defaultMergeRefForDiffs || - (defaultMergeRefForDiffs && !state.mergeRequestDiff.head_version_path) - ) { + if (!state.mergeRequestDiff.head_version_path) { baseVersion = { versionName: state.targetBranchName, version_index: DIFF_COMPARE_BASE_VERSION_INDEX, href: state.mergeRequestDiff.base_version_path, isBase: true, - selected: - isBaseSelected || (defaultMergeRefForDiffs && !state.mergeRequestDiff.head_version_path), + selected: isBaseSelected, }; } diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js index db13daf0799..83dd4b0a124 100644 --- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js +++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js @@ -1,11 +1,13 @@ import $ from 'jquery'; import { memoize, throttle } from 'lodash'; +import createEventHub from '~/helpers/event_hub_factory'; class DirtySubmitForm { constructor(form) { this.form = form; this.dirtyInputs = []; this.isDisabled = true; + this.events = createEventHub(); this.init(); } @@ -36,11 +38,21 @@ class DirtySubmitForm { this.form.addEventListener('submit', (event) => this.formSubmit(event)); } + addInputsListener(callback) { + this.events.$on('input', callback); + } + + removeInputsListener(callback) { + this.events.$off('input', callback); + } + updateDirtyInput(event) { const { target } = event; if (!target.dataset.isDirtySubmitInput) return; + this.events.$emit('input', event); + this.updateDirtyInputs(target); this.toggleSubmission(); } diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js index 0290bb84b5f..b41eae88c54 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js @@ -14,7 +14,7 @@ export class CiSchemaExtension { // to fetch schema files, hence the `gon.gitlab_url` // reference. This prevents error: // "Failed to execute 'fetch' on 'WorkerGlobalScope'" - const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath; + const absoluteSchemaUrl = new URL(ciSchemaPath, gon.gitlab_url).href; const modelFileName = instance.getModel().uri.path.split('/').pop(); registerSchema({ diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 4d9fe6ff851..1c56327c03c 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -423,37 +423,34 @@ "description": "Defines secrets to be injected as environment variables", "additionalProperties": { "type": "object", - "additionalProperties": { - "type": "object", - "description": "Environment variable name", - "properties": { - "vault": { - "oneOf": [ - { - "type": "string", - "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)" - }, - { - "type": "object", - "properties": { - "engine": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "path": { "type": "string" } - }, - "required": ["name", "path"] + "description": "Environment variable name", + "properties": { + "vault": { + "oneOf": [ + { + "type": "string", + "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)" + }, + { + "type": "object", + "properties": { + "engine": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" } }, - "path": { "type": "string" }, - "field": { "type": "string" } + "required": ["name", "path"] }, - "required": ["engine", "path", "field"] - } - ] - } - }, - "required": ["vault"] - } + "path": { "type": "string" }, + "field": { "type": "string" } + }, + "required": ["engine", "path", "field"] + } + ] + } + }, + "required": ["vault"] } }, "before_script": { @@ -1250,7 +1247,7 @@ "oneOf": [ { "type": "object", - "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#simple-trigger-syntax-for-multi-project-pipelines", + "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#specify-a-downstream-pipeline-branch", "additionalProperties": false, "properties": { "project": { @@ -1266,6 +1263,23 @@ "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend", "type": "string", "enum": ["depend"] + }, + "forward": { + "description": "Specify what to forward to the downstream pipeline.", + "type": "object", + "additionalProperties": false, + "properties": { + "yaml_variables": { + "type": "boolean", + "description": "Variables defined in the trigger job are passed to downstream pipelines.", + "default": true + }, + "pipeline_variables": { + "type": "boolean", + "description": "Variables added for manual pipeline runs are passed to downstream pipelines.", + "default": false + } + } } }, "required": ["project"], @@ -1275,7 +1289,7 @@ }, { "type": "object", - "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger-syntax-for-child-pipeline", + "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/parent_child_pipelines.html", "additionalProperties": false, "properties": { "include": { @@ -1365,11 +1379,28 @@ "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend", "type": "string", "enum": ["depend"] + }, + "forward": { + "description": "Specify what to forward to the downstream pipeline.", + "type": "object", + "additionalProperties": false, + "properties": { + "yaml_variables": { + "type": "boolean", + "description": "Variables defined in the trigger job are passed to downstream pipelines.", + "default": true + }, + "pipeline_variables": { + "type": "boolean", + "description": "Variables added for manual pipeline runs are passed to downstream pipelines.", + "default": false + } + } } } }, { - "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", + "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#define-multi-project-pipelines-in-your-gitlab-ciyml-file", "type": "string", "pattern": "\\S/\\S" } diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 686b5ffff9e..840297b870a 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -108,6 +108,7 @@ export default { class="gl-mx-5! gl-mb-2!" autofocus debounce="500" + :aria-label="__('Search for an emoji')" @input="onSearchInput" /> <div diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue index 54b94480685..8577bf629a3 100644 --- a/app/assets/javascripts/environments/components/commit.vue +++ b/app/assets/javascripts/environments/components/commit.vue @@ -22,7 +22,6 @@ export default { return this.commit?.message; }, commitAuthorPath() { - // eslint-disable-next-line @gitlab/require-i18n-strings return this.commit?.author?.path || `mailto:${escape(this.commit?.authorEmail)}`; }, commitAuthorAvatar() { diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index d3d4c7d23d8..3173c2bd644 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -62,7 +62,8 @@ export default { mutation: deleteEnvironmentMutation, variables: { environment: this.environment }, }) - .then(([message]) => { + .then(({ data }) => { + const [message] = data?.deleteEvironment?.errors ?? []; if (message) { createFlash({ message }); } diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index f98edb6bb7d..19284b26d51 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -102,6 +102,9 @@ export default { refPath() { return this.ref?.refPath; }, + needsApproval() { + return this.deployment.pendingApprovalCount > 0; + }, }, methods: { toggleCollapse() { @@ -116,6 +119,7 @@ export default { showDetails: __('Show details'), hideDetails: __('Hide details'), triggerer: s__('Deployment|Triggerer'), + needsApproval: s__('Deployment|Needs Approval'), job: __('Job'), api: __('API'), branch: __('Branch'), @@ -153,6 +157,9 @@ export default { <div :class="$options.headerDetailsClasses"> <div :class="$options.deploymentStatusClasses"> <deployment-status-badge v-if="status" :status="status" /> + <gl-badge v-if="needsApproval" variant="warning"> + {{ $options.i18n.needsApproval }} + </gl-badge> <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge> </div> <div class="gl-display-flex gl-align-items-center gl-gap-x-5"> @@ -199,6 +206,7 @@ export default { </gl-button> </div> <commit v-if="commit" :commit="commit" class="gl-mt-3" /> + <div class="gl-mt-3"><slot name="approval"></slot></div> <gl-collapse :visible="visible"> <div class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0" diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index b757c55bfdb..4d43ee156fb 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -44,6 +45,11 @@ export default { copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), title: s__('ReviewApp|Enable Review App'), }, + data() { + const modalInfoCopyId = uniqueId('enable-review-app-copy-string-'); + + return { modalInfoCopyId }; + }, computed: { modalInfoCopyStr() { return `deploy_review: @@ -99,14 +105,14 @@ export default { </gl-sprintf> </p> <div class="gl-display-flex align-items-start"> - <pre class="gl-w-full" data-testid="enable-review-app-copy-string"> + <pre :id="modalInfoCopyId" class="gl-w-full" data-testid="enable-review-app-copy-string"> {{ modalInfoCopyStr }} </pre > <modal-copy-button :title="$options.modalInfo.copyToClipboardText" - :text="$options.modalInfo.copyString" :modal-id="modalId" css-classes="border-0" + :target="`#${modalInfoCopyId}`" /> </div> </div> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 98c95507168..c7e024aadec 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,6 @@ <script> import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { formatTime } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; @@ -37,7 +38,7 @@ export default { }, }, methods: { - onClickAction(action) { + async onClickAction(action) { if (action.scheduledAt) { const confirmationMessage = sprintf( s__( @@ -45,9 +46,10 @@ export default { ), { jobName: action.name }, ); - // https://gitlab.com/gitlab-org/gitlab-foss/issues/52156 - // eslint-disable-next-line no-alert - if (!window.confirm(confirmationMessage)) { + + const confirmed = await confirmAction(confirmationMessage); + + if (!confirmed) { return; } } diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/environment_folder.vue index 0d3867a4d74..d5c6d26cfd0 100644 --- a/app/assets/javascripts/environments/components/new_environment_folder.vue +++ b/app/assets/javascripts/environments/components/environment_folder.vue @@ -1,7 +1,9 @@ <script> import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; import folderQuery from '../graphql/queries/folder.query.graphql'; +import { ENVIRONMENT_COUNT_BY_SCOPE } from '../constants'; import EnvironmentItem from './new_environment_item.vue'; export default { @@ -18,16 +20,26 @@ export default { type: Object, required: true, }, + scope: { + type: String, + required: true, + }, }, data() { - return { visible: false }; + return { visible: false, interval: undefined }; }, apollo: { folder: { query: folderQuery, variables() { - return { environment: this.nestedEnvironment.latest }; + return { environment: this.nestedEnvironment.latest, scope: this.scope }; }, + pollInterval() { + return this.interval; + }, + }, + interval: { + query: pollIntervalQuery, }, }, i18n: { @@ -45,7 +57,8 @@ export default { return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand; }, count() { - return this.folder?.availableCount ?? 0; + const count = ENVIRONMENT_COUNT_BY_SCOPE[this.scope]; + return this.folder?.[count] ?? 0; }, folderClass() { return { 'gl-font-weight-bold': this.visible }; diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index acc16ecd874..c7008c03099 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,188 +1,272 @@ <script> -import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { s__ } from '~/locale'; -import eventHub from '../event_hub'; -import environmentsMixin from '../mixins/environments_mixin'; -import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin'; -import ConfirmRollbackModal from './confirm_rollback_modal.vue'; -import DeleteEnvironmentModal from './delete_environment_modal.vue'; -import emptyState from './empty_state.vue'; +import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; +import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; +import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; +import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; +import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; +import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql'; +import { ENVIRONMENTS_SCOPE } from '../constants'; +import EnvironmentFolder from './environment_folder.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; +import EnvironmentItem from './new_environment_item.vue'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; +import DeleteEnvironmentModal from './delete_environment_modal.vue'; +import CanaryUpdateModal from './canary_update_modal.vue'; +import EmptyState from './empty_state.vue'; export default { - i18n: { - newEnvironmentButtonLabel: s__('Environments|New environment'), - reviewAppButtonLabel: s__('Environments|Enable review app'), - }, - modal: { - id: 'enable-review-app-info', - }, components: { + DeleteEnvironmentModal, + CanaryUpdateModal, ConfirmRollbackModal, - emptyState, + EmptyState, + EnvironmentFolder, EnableReviewAppModal, + EnvironmentItem, + StopEnvironmentModal, GlBadge, - GlButton, + GlPagination, GlTab, GlTabs, - StopEnvironmentModal, - DeleteEnvironmentModal, }, - directives: { - 'gl-modal': GlModalDirective, - }, - mixins: [EnvironmentsPaginationApiMixin, environmentsMixin], - props: { - endpoint: { - type: String, - required: true, + apollo: { + environmentApp: { + query: environmentAppQuery, + variables() { + return { + scope: this.scope, + page: this.page ?? 1, + }; + }, + pollInterval() { + return this.interval; + }, + }, + interval: { + query: pollIntervalQuery, + }, + pageInfo: { + query: pageInfoQuery, + }, + environmentToDelete: { + query: environmentToDeleteQuery, }, - canCreateEnvironment: { - type: Boolean, - required: true, + environmentToRollback: { + query: environmentToRollbackQuery, }, - newEnvironmentPath: { - type: String, - required: true, + environmentToStop: { + query: environmentToStopQuery, }, - helpPagePath: { - type: String, - required: true, + environmentToChangeCanary: { + query: environmentToChangeCanaryQuery, + }, + weight: { + query: environmentToChangeCanaryQuery, }, }, - - created() { - eventHub.$on('toggleFolder', this.toggleFolder); - eventHub.$on('toggleDeployBoard', this.toggleDeployBoard); + inject: ['newEnvironmentPath', 'canCreateEnvironment', 'helpPagePath'], + i18n: { + newEnvironmentButtonLabel: s__('Environments|New environment'), + reviewAppButtonLabel: s__('Environments|Enable review app'), + available: __('Available'), + stopped: __('Stopped'), + prevPage: __('Go to previous page'), + nextPage: __('Go to next page'), + next: __('Next'), + prev: __('Prev'), + goto: (page) => sprintf(__('Go to page %{page}'), { page }), }, - - beforeDestroy() { - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('toggleFolder'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('toggleDeployBoard'); + modalId: 'enable-review-app-info', + data() { + const { page = '1', scope } = queryToObject(window.location.search); + return { + interval: undefined, + isReviewAppModalVisible: false, + page: parseInt(page, 10), + pageInfo: {}, + scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope) + ? scope + : ENVIRONMENTS_SCOPE.AVAILABLE, + environmentToDelete: {}, + environmentToRollback: {}, + environmentToStop: {}, + environmentToChangeCanary: {}, + weight: 0, + }; }, - - methods: { - toggleDeployBoard(model) { - this.store.toggleDeployBoard(model.id); + computed: { + canSetupReviewApp() { + return this.environmentApp?.reviewApp?.canSetupReviewApp; }, - toggleFolder(folder) { - this.store.toggleFolder(folder); - - if (!folder.isOpen) { - this.fetchChildEnvironments(folder, true); - } + folders() { + return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? []; }, - - fetchChildEnvironments(folder, showLoader = false) { - this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader); - - this.service - .getFolderContent(folder.folder_path, folder.state) - .then((response) => this.store.setfolderContent(folder, response.data.environments)) - .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false)) - .catch(() => { - createFlash({ - message: s__('Environments|An error occurred while fetching the environments.'), - }); - this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false); - }); + environments() { + return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? []; }, + hasEnvironments() { + return this.environments.length > 0 || this.folders.length > 0; + }, + availableCount() { + return this.environmentApp?.availableCount; + }, + addEnvironment() { + if (!this.canCreateEnvironment) { + return null; + } - successCallback(resp) { - this.saveData(resp); - - // We need to verify if any folder is open to also update it - const openFolders = this.store.getOpenFolders(); - if (openFolders.length) { - openFolders.forEach((folder) => this.fetchChildEnvironments(folder)); + return { + text: this.$options.i18n.newEnvironmentButtonLabel, + attributes: { + href: this.newEnvironmentPath, + category: 'primary', + variant: 'confirm', + }, + }; + }, + openReviewAppModal() { + if (!this.canSetupReviewApp) { + return null; } + + return { + text: this.$options.i18n.reviewAppButtonLabel, + attributes: { + category: 'secondary', + variant: 'confirm', + }, + }; + }, + stoppedCount() { + return this.environmentApp?.stoppedCount; }, + totalItems() { + return this.pageInfo?.total; + }, + itemsPerPage() { + return this.pageInfo?.perPage; + }, + }, + mounted() { + window.addEventListener('popstate', this.syncPageFromQueryParams); }, + destroyed() { + window.removeEventListener('popstate', this.syncPageFromQueryParams); + this.$apollo.queries.environmentApp.stopPolling(); + }, + methods: { + showReviewAppModal() { + this.isReviewAppModalVisible = true; + }, + setScope(scope) { + this.scope = scope; + this.moveToPage(1); + }, + movePage(direction) { + this.moveToPage(this.pageInfo[`${direction}Page`]); + }, + moveToPage(page) { + this.page = page; + updateHistory({ + url: setUrlParams({ page: this.page }), + title: document.title, + }); + this.resetPolling(); + }, + syncPageFromQueryParams() { + const { page = '1' } = queryToObject(window.location.search); + this.page = parseInt(page, 10); + }, + resetPolling() { + this.$apollo.queries.environmentApp.stopPolling(); + this.$apollo.queries.environmentApp.refetch(); + this.$nextTick(() => { + if (this.interval) { + this.$apollo.queries.environmentApp.startPolling(this.interval); + } + }); + }, + }, + ENVIRONMENTS_SCOPE, }; </script> <template> - <div class="environments-section"> - <stop-environment-modal :environment="environmentInStopModal" /> - <delete-environment-modal :environment="environmentInDeleteModal" /> - <confirm-rollback-modal :environment="environmentInRollbackModal" /> - - <div class="gl-w-full"> - <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-md-display-none!"> - <gl-button - v-if="state.reviewAppDetails.can_setup_review_app" - v-gl-modal="$options.modal.id" - data-testid="enable-review-app" - variant="info" - category="secondary" - type="button" - class="gl-mb-3 gl-flex-grow-1" - >{{ $options.i18n.reviewAppButtonLabel }}</gl-button - > - <gl-button - v-if="canCreateEnvironment" - :href="newEnvironmentPath" - data-testid="new-environment" - category="primary" - variant="confirm" - >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button - > - </div> - <gl-tabs :value="activeTab" content-class="gl-display-none"> - <gl-tab - v-for="(tab, idx) in tabs" - :key="idx" - :title-item-class="`js-environments-tab-${tab.scope}`" - @click="onChangeTab(tab.scope)" - > - <template #title> - <span>{{ tab.name }}</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> - </template> - </gl-tab> - <template #tabs-end> - <div - class="gl-display-none gl-md-display-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0" - > - <gl-button - v-if="state.reviewAppDetails.can_setup_review_app" - v-gl-modal="$options.modal.id" - data-testid="enable-review-app" - variant="info" - category="secondary" - type="button" - class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0" - >{{ $options.i18n.reviewAppButtonLabel }}</gl-button - > - <gl-button - v-if="canCreateEnvironment" - :href="newEnvironmentPath" - data-testid="new-environment" - category="primary" - variant="confirm" - >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button - > - </div> + <div> + <enable-review-app-modal + v-if="canSetupReviewApp" + v-model="isReviewAppModalVisible" + :modal-id="$options.modalId" + data-testid="enable-review-app-modal" + /> + <delete-environment-modal :environment="environmentToDelete" graphql /> + <stop-environment-modal :environment="environmentToStop" graphql /> + <confirm-rollback-modal :environment="environmentToRollback" graphql /> + <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" /> + <gl-tabs + :action-secondary="addEnvironment" + :action-primary="openReviewAppModal" + sync-active-tab-with-query-params + query-param-name="scope" + @primary="showReviewAppModal" + > + <gl-tab + :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE" + @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)" + > + <template #title> + <span>{{ $options.i18n.available }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge"> + {{ availableCount }} + </gl-badge> </template> - </gl-tabs> - <container - :is-loading="isLoading" - :environments="state.environments" - :pagination="state.paginationInformation" - @onChangePage="onChangePage" + </gl-tab> + <gl-tab + :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED" + @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)" > - <template v-if="!isLoading && state.environments.length === 0" #empty-state> - <empty-state :help-path="helpPagePath" /> + <template #title> + <span>{{ $options.i18n.stopped }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge"> + {{ stoppedCount }} + </gl-badge> </template> - </container> - <enable-review-app-modal - v-if="state.reviewAppDetails.can_setup_review_app" - :modal-id="$options.modal.id" - data-testid="enable-review-app-modal" + </gl-tab> + </gl-tabs> + <template v-if="hasEnvironments"> + <environment-folder + v-for="folder in folders" + :key="folder.name" + class="gl-mb-3" + :scope="scope" + :nested-environment="folder" + /> + <environment-item + v-for="environment in environments" + :key="environment.name" + class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid" + :environment="environment.latest" + @change="resetPolling" /> - </div> + </template> + <empty-state v-else :help-path="helpPagePath" /> + <gl-pagination + align="center" + :total-items="totalItems" + :per-page="itemsPerPage" + :value="page" + :next="$options.i18n.next" + :prev="$options.i18n.prev" + :label-previous-page="$options.prevPage" + :label-next-page="$options.nextPage" + :label-page="$options.goto" + @next="movePage('next')" + @previous="movePage('previous')" + @input="moveToPage" + /> </div> </template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 27a763fb9c4..f35fabccae7 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -40,6 +40,9 @@ export default { Terminal, TimeAgoTooltip, Delete, + EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'), + EnvironmentApproval: () => + import('ee_component/environments/components/environment_approval.vue'), }, directives: { GlTooltip, @@ -97,6 +100,9 @@ export default { hasDeployment() { return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment); }, + hasOpenedAlert() { + return this.environment?.hasOpenedAlert; + }, actions() { if (!this.lastDeployment) { return []; @@ -296,12 +302,20 @@ export default { class="gl-pl-4" /> </div> - <div v-if="upcomingDeployment" :class="$options.deploymentClasses"> + <div + v-if="upcomingDeployment" + :class="$options.deploymentClasses" + data-testid="upcoming-deployment-content" + > <deployment :deployment="upcomingDeployment" :class="{ 'gl-ml-7': inFolder }" class="gl-pl-4" - /> + > + <template #approval> + <environment-approval :environment="environment" @change="$emit('change')" /> + </template> + </deployment> </div> </template> <div v-else :class="$options.deploymentClasses"> @@ -319,6 +333,9 @@ export default { class="gl-pl-4" /> </div> + <div v-if="hasOpenedAlert" class="gl-bg-gray-10 gl-md-px-7"> + <environment-alert :environment="environment" class="gl-pl-4 gl-py-5" /> + </div> </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue deleted file mode 100644 index 3699f39b611..00000000000 --- a/app/assets/javascripts/environments/components/new_environments_app.vue +++ /dev/null @@ -1,252 +0,0 @@ -<script> -import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; -import { s__, __, sprintf } from '~/locale'; -import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; -import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; -import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; -import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; -import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; -import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; -import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; -import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql'; -import EnvironmentFolder from './new_environment_folder.vue'; -import EnableReviewAppModal from './enable_review_app_modal.vue'; -import StopEnvironmentModal from './stop_environment_modal.vue'; -import EnvironmentItem from './new_environment_item.vue'; -import ConfirmRollbackModal from './confirm_rollback_modal.vue'; -import DeleteEnvironmentModal from './delete_environment_modal.vue'; -import CanaryUpdateModal from './canary_update_modal.vue'; - -export default { - components: { - DeleteEnvironmentModal, - CanaryUpdateModal, - ConfirmRollbackModal, - EnvironmentFolder, - EnableReviewAppModal, - EnvironmentItem, - StopEnvironmentModal, - GlBadge, - GlPagination, - GlTab, - GlTabs, - }, - apollo: { - environmentApp: { - query: environmentAppQuery, - variables() { - return { - scope: this.scope, - page: this.page ?? 1, - }; - }, - pollInterval() { - return this.interval; - }, - }, - interval: { - query: pollIntervalQuery, - }, - pageInfo: { - query: pageInfoQuery, - }, - environmentToDelete: { - query: environmentToDeleteQuery, - }, - environmentToRollback: { - query: environmentToRollbackQuery, - }, - environmentToStop: { - query: environmentToStopQuery, - }, - environmentToChangeCanary: { - query: environmentToChangeCanaryQuery, - }, - weight: { - query: environmentToChangeCanaryQuery, - }, - }, - inject: ['newEnvironmentPath', 'canCreateEnvironment'], - i18n: { - newEnvironmentButtonLabel: s__('Environments|New environment'), - reviewAppButtonLabel: s__('Environments|Enable review app'), - available: __('Available'), - stopped: __('Stopped'), - prevPage: __('Go to previous page'), - nextPage: __('Go to next page'), - next: __('Next'), - prev: __('Prev'), - goto: (page) => sprintf(__('Go to page %{page}'), { page }), - }, - modalId: 'enable-review-app-info', - data() { - const { page = '1', scope = 'available' } = queryToObject(window.location.search); - return { - interval: undefined, - isReviewAppModalVisible: false, - page: parseInt(page, 10), - scope, - environmentToDelete: {}, - environmentToRollback: {}, - environmentToStop: {}, - environmentToChangeCanary: {}, - weight: 0, - }; - }, - computed: { - canSetupReviewApp() { - return this.environmentApp?.reviewApp?.canSetupReviewApp; - }, - folders() { - return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? []; - }, - environments() { - return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? []; - }, - availableCount() { - return this.environmentApp?.availableCount; - }, - addEnvironment() { - if (!this.canCreateEnvironment) { - return null; - } - - return { - text: this.$options.i18n.newEnvironmentButtonLabel, - attributes: { - href: this.newEnvironmentPath, - category: 'primary', - variant: 'confirm', - }, - }; - }, - openReviewAppModal() { - if (!this.canSetupReviewApp) { - return null; - } - - return { - text: this.$options.i18n.reviewAppButtonLabel, - attributes: { - category: 'secondary', - variant: 'confirm', - }, - }; - }, - stoppedCount() { - return this.environmentApp?.stoppedCount; - }, - totalItems() { - return this.pageInfo?.total; - }, - itemsPerPage() { - return this.pageInfo?.perPage; - }, - }, - mounted() { - window.addEventListener('popstate', this.syncPageFromQueryParams); - }, - destroyed() { - window.removeEventListener('popstate', this.syncPageFromQueryParams); - this.$apollo.queries.environmentApp.stopPolling(); - }, - methods: { - showReviewAppModal() { - this.isReviewAppModalVisible = true; - }, - setScope(scope) { - this.scope = scope; - this.moveToPage(1); - }, - movePage(direction) { - this.moveToPage(this.pageInfo[`${direction}Page`]); - }, - moveToPage(page) { - this.page = page; - updateHistory({ - url: setUrlParams({ page: this.page }), - title: document.title, - }); - this.resetPolling(); - }, - syncPageFromQueryParams() { - const { page = '1' } = queryToObject(window.location.search); - this.page = parseInt(page, 10); - }, - resetPolling() { - this.$apollo.queries.environmentApp.stopPolling(); - this.$nextTick(() => { - if (this.interval) { - this.$apollo.queries.environmentApp.startPolling(this.interval); - } else { - this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page }); - } - }); - }, - }, -}; -</script> -<template> - <div> - <enable-review-app-modal - v-if="canSetupReviewApp" - v-model="isReviewAppModalVisible" - :modal-id="$options.modalId" - data-testid="enable-review-app-modal" - /> - <delete-environment-modal :environment="environmentToDelete" graphql /> - <stop-environment-modal :environment="environmentToStop" graphql /> - <confirm-rollback-modal :environment="environmentToRollback" graphql /> - <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" /> - <gl-tabs - :action-secondary="addEnvironment" - :action-primary="openReviewAppModal" - sync-active-tab-with-query-params - query-param-name="scope" - @primary="showReviewAppModal" - > - <gl-tab query-param-value="available" @click="setScope('available')"> - <template #title> - <span>{{ $options.i18n.available }}</span> - <gl-badge size="sm" class="gl-tab-counter-badge"> - {{ availableCount }} - </gl-badge> - </template> - </gl-tab> - <gl-tab query-param-value="stopped" @click="setScope('stopped')"> - <template #title> - <span>{{ $options.i18n.stopped }}</span> - <gl-badge size="sm" class="gl-tab-counter-badge"> - {{ stoppedCount }} - </gl-badge> - </template> - </gl-tab> - </gl-tabs> - <environment-folder - v-for="folder in folders" - :key="folder.name" - class="gl-mb-3" - :nested-environment="folder" - /> - <environment-item - v-for="environment in environments" - :key="environment.name" - class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid" - :environment="environment.latest" - /> - <gl-pagination - align="center" - :total-items="totalItems" - :per-page="itemsPerPage" - :value="page" - :next="$options.i18n.next" - :prev="$options.i18n.prev" - :label-previous-page="$options.prevPage" - :label-next-page="$options.nextPage" - :label-page="$options.goto" - @next="movePage('next')" - @previous="movePage('previous')" - @input="moveToPage" - /> - </div> -</template> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 6d427bef4e6..942491039d6 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -38,3 +38,13 @@ export const CANARY_STATUS = { }; export const CANARY_UPDATE_MODAL = 'confirm-canary-change'; + +export const ENVIRONMENTS_SCOPE = { + AVAILABLE: 'available', + STOPPED: 'stopped', +}; + +export const ENVIRONMENT_COUNT_BY_SCOPE = { + [ENVIRONMENTS_SCOPE.AVAILABLE]: 'availableCount', + [ENVIRONMENTS_SCOPE.STOPPED]: 'stoppedCount', +}; diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 64b18c2003b..26514b59995 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -2,6 +2,9 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import environmentApp from './queries/environment_app.query.graphql'; import pageInfoQuery from './queries/page_info.query.graphql'; +import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; +import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -33,6 +36,52 @@ export const apolloProvider = (endpoint) => { }, }, }); + + cache.writeQuery({ + query: environmentToDeleteQuery, + data: { + environmentToDelete: { + name: 'null', + __typename: 'LocalEnvironment', + id: '0', + deletePath: null, + folderPath: null, + retryUrl: null, + autoStopPath: null, + lastDeployment: null, + }, + }, + }); + cache.writeQuery({ + query: environmentToStopQuery, + data: { + environmentToStop: { + name: 'null', + __typename: 'LocalEnvironment', + id: '0', + deletePath: null, + folderPath: null, + retryUrl: null, + autoStopPath: null, + lastDeployment: null, + }, + }, + }); + cache.writeQuery({ + query: environmentToRollbackQuery, + data: { + environmentToRollback: { + name: 'null', + __typename: 'LocalEnvironment', + id: '0', + deletePath: null, + folderPath: null, + retryUrl: null, + autoStopPath: null, + lastDeployment: null, + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql index 3292c916b2e..e8c145ee916 100644 --- a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql @@ -1,5 +1,5 @@ -query getEnvironmentFolder($environment: NestedLocalEnvironment) { - folder(environment: $environment) @client { +query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String) { + folder(environment: $environment, scope: $scope) @client { availableCount environments stoppedCount diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index dc763b77157..a7866c1e778 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -11,6 +11,7 @@ import environmentToRollbackQuery from './queries/environment_to_rollback.query. import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql'; +import isEnvironmentStoppingQuery from './queries/is_environment_stopping.query.graphql'; import pageInfoQuery from './queries/page_info.query.graphql'; const buildErrors = (errors = []) => ({ @@ -58,8 +59,8 @@ export const resolvers = (endpoint) => ({ }; }); }, - folder(_, { environment: { folderPath } }) { - return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({ + folder(_, { environment: { folderPath }, scope }) { + return axios.get(folderPath, { params: { scope, per_page: 3 } }).then((res) => ({ availableCount: res.data.available_count, environments: res.data.environments.map(mapEnvironment), stoppedCount: res.data.stopped_count, @@ -71,11 +72,21 @@ export const resolvers = (endpoint) => ({ }, }, Mutation: { - stopEnvironment(_, { environment }) { + stopEnvironment(_, { environment }, { client }) { + client.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment }, + data: { isEnvironmentStopping: true }, + }); return axios .post(environment.stopPath) .then(() => buildErrors()) .catch(() => { + client.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment }, + data: { isEnvironmentStopping: false }, + }); return buildErrors([ s__('Environments|An error occurred while stopping the environment, please try again'), ]); diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 3b1d35c1f22..d9a523fd806 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,48 +1,37 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '../lib/utils/common_utils'; -import Translate from '../vue_shared/translate'; -import environmentsComponent from './components/environments_app.vue'; +import { apolloProvider } from './graphql/client'; +import EnvironmentsApp from './components/environments_app.vue'; -Vue.use(Translate); Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - export default (el) => { if (el) { + const { + canCreateEnvironment, + endpoint, + newEnvironmentPath, + helpPagePath, + projectPath, + defaultBranchName, + projectId, + } = el.dataset; + return new Vue({ el, - components: { - environmentsComponent, - }, - apolloProvider, + apolloProvider: apolloProvider(endpoint), provide: { - projectPath: el.dataset.projectPath, - defaultBranchName: el.dataset.defaultBranchName, - }, - data() { - const environmentsData = el.dataset; - - return { - endpoint: environmentsData.environmentsDataEndpoint, - newEnvironmentPath: environmentsData.newEnvironmentPath, - helpPagePath: environmentsData.helpPagePath, - canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), - }; + projectPath, + defaultBranchName, + endpoint, + newEnvironmentPath, + helpPagePath, + projectId, + canCreateEnvironment: parseBoolean(canCreateEnvironment), }, - render(createElement) { - return createElement('environments-component', { - props: { - endpoint: this.endpoint, - newEnvironmentPath: this.newEnvironmentPath, - helpPagePath: this.helpPagePath, - canCreateEnvironment: this.canCreateEnvironment, - }, - }); + render(h) { + return h(EnvironmentsApp); }, }); } diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js deleted file mode 100644 index dd5c709c75a..00000000000 --- a/app/assets/javascripts/environments/new_index.js +++ /dev/null @@ -1,38 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { parseBoolean } from '../lib/utils/common_utils'; -import { apolloProvider } from './graphql/client'; -import EnvironmentsApp from './components/new_environments_app.vue'; - -Vue.use(VueApollo); - -export default (el) => { - if (el) { - const { - canCreateEnvironment, - endpoint, - newEnvironmentPath, - helpPagePath, - projectPath, - defaultBranchName, - } = el.dataset; - - return new Vue({ - el, - apolloProvider: apolloProvider(endpoint), - provide: { - projectPath, - defaultBranchName, - endpoint, - newEnvironmentPath, - helpPagePath, - canCreateEnvironment: parseBoolean(canCreateEnvironment), - }, - render(h) { - return h(EnvironmentsApp); - }, - }); - } - - return null; -}; diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js deleted file mode 100644 index 41b952e26d8..00000000000 --- a/app/assets/javascripts/error_tracking/components/constants.js +++ /dev/null @@ -1,21 +0,0 @@ -export const severityLevel = { - FATAL: 'fatal', - ERROR: 'error', - WARNING: 'warning', - INFO: 'info', - DEBUG: 'debug', -}; - -export const severityLevelVariant = { - [severityLevel.FATAL]: 'danger', - [severityLevel.ERROR]: 'neutral', - [severityLevel.WARNING]: 'warning', - [severityLevel.INFO]: 'info', - [severityLevel.DEBUG]: 'muted', -}; - -export const errorStatus = { - IGNORED: 'ignored', - RESOLVED: 'resolved', - UNRESOLVED: 'unresolved', -}; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index e00fec6fddf..0a8abdc90c6 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -26,7 +26,7 @@ import { trackErrorStatusUpdateOptions, } from '../utils'; -import { severityLevel, severityLevelVariant, errorStatus } from './constants'; +import { severityLevel, severityLevelVariant, errorStatus } from '../constants'; import Stacktrace from './stacktrace.vue'; const SENTRY_TIMEOUT = 10000; 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 5db8c8cf8d3..3d540d46b3c 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -1,5 +1,6 @@ <script> import { + GlAlert, GlEmptyState, GlButton, GlIcon, @@ -10,6 +11,7 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlSprintf, GlTooltipDirective, GlPagination, } from '@gitlab/ui'; @@ -21,6 +23,7 @@ import { __ } from '~/locale'; import Tracking from '~/tracking'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils'; +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'; @@ -29,6 +32,7 @@ export default { FIRST_PAGE: 1, PREV_PAGE: 1, NEXT_PAGE: 2, + i18n: I18N_ERROR_TRACKING_LIST, fields: [ { key: 'error', @@ -71,6 +75,7 @@ export default { frequency: __('Frequency'), }, components: { + GlAlert, GlEmptyState, GlButton, GlDropdown, @@ -81,6 +86,7 @@ export default { GlLoadingIcon, GlTable, GlFormInput, + GlSprintf, GlPagination, TimeAgo, ErrorTrackingActions, @@ -117,12 +123,17 @@ export default { type: String, required: true, }, + showIntegratedTrackingDisabledAlert: { + type: Boolean, + required: false, + }, }, hasLocalStorage: AccessorUtils.canUseLocalStorage(), data() { return { errorSearchQuery: '', pageValue: this.$options.FIRST_PAGE, + isAlertDismissed: false, }; }, computed: { @@ -142,6 +153,9 @@ export default { errorTrackingHelpUrl() { return helpPagePath('operations/error_tracking'); }, + showIntegratedDisabledAlert() { + return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert; + }, }, watch: { pagination() { @@ -150,6 +164,8 @@ export default { } }, }, + epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639', + featureFlagLink: helpPagePath('operations/error_tracking'), created() { if (this.errorTrackingEnabled) { this.setEndpoint(this.indexPath); @@ -232,6 +248,34 @@ export default { <template> <div class="error-list"> <div v-if="errorTrackingEnabled"> + <gl-alert + v-if="showIntegratedDisabledAlert" + variant="danger" + data-testid="integrated-disabled-alert" + @dismiss="isAlertDismissed = true" + > + <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText"> + <template #epicLink="{ content }"> + <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link> + </template> + <template #flagLink="{ content }"> + <gl-link :href="$options.featureFlagLink" target="_blank">{{ content }}</gl-link> + </template> + <template #settingsLink="{ content }"> + <gl-link :href="enableErrorTrackingLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + <div> + <gl-button + category="primary" + variant="confirm" + :href="enableErrorTrackingLink" + class="gl-mr-auto gl-mt-3" + > + {{ $options.i18n.viewProjectSettingsButton }} + </gl-button> + </div> + </gl-alert> <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" > diff --git a/app/assets/javascripts/error_tracking/constants.js b/app/assets/javascripts/error_tracking/constants.js new file mode 100644 index 00000000000..f01bac2e81d --- /dev/null +++ b/app/assets/javascripts/error_tracking/constants.js @@ -0,0 +1,30 @@ +import { s__ } from '~/locale'; + +export const severityLevel = { + FATAL: 'fatal', + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', + DEBUG: 'debug', +}; + +export const severityLevelVariant = { + [severityLevel.FATAL]: 'danger', + [severityLevel.ERROR]: 'neutral', + [severityLevel.WARNING]: 'warning', + [severityLevel.INFO]: 'info', + [severityLevel.DEBUG]: 'muted', +}; + +export const errorStatus = { + IGNORED: 'ignored', + RESOLVED: 'resolved', + UNRESOLVED: 'unresolved', +}; + +export const I18N_ERROR_TRACKING_LIST = { + integratedErrorTrackingDisabledText: s__( + 'ErrorTracking|Integrated error tracking is %{epicLinkStart}turned off by default%{epicLinkEnd} and no longer active for this project. To re-enable error tracking on self-hosted instances, you can either %{flagLinkStart}turn on the feature flag%{flagLinkEnd} for integrated error tracking, or provide a %{settingsLinkStart}Sentry API URL and Auth Token%{settingsLinkEnd} on your project settings page. However, error tracking is not ready for production use and cannot be enabled on GitLab.com.', + ), + viewProjectSettingsButton: s__('ErrorTracking|View project settings'), +}; diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js index 9c729407009..8b2086e1522 100644 --- a/app/assets/javascripts/error_tracking/list.js +++ b/app/assets/javascripts/error_tracking/list.js @@ -14,10 +14,15 @@ export default () => { projectPath, listPath, } = domEl.dataset; - let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset; + let { + errorTrackingEnabled, + userCanEnableErrorTracking, + showIntegratedTrackingDisabledAlert, + } = domEl.dataset; errorTrackingEnabled = parseBoolean(errorTrackingEnabled); userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking); + showIntegratedTrackingDisabledAlert = parseBoolean(showIntegratedTrackingDisabledAlert); // eslint-disable-next-line no-new new Vue({ @@ -36,6 +41,7 @@ export default () => { userCanEnableErrorTracking, projectPath, listPath, + showIntegratedTrackingDisabledAlert, }, }); }, diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 4808cd1d1c0..e850d954e0a 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -1,29 +1,40 @@ <script> import { + GlAlert, GlButton, GlFormGroup, GlFormCheckbox, GlFormRadioGroup, GlFormRadio, GlFormInputGroup, + GlLink, + GlSprintf, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { I18N_ERROR_TRACKING_SETTINGS } from '../constants'; import ErrorTrackingForm from './error_tracking_form.vue'; import ProjectDropdown from './project_dropdown.vue'; export default { + i18n: I18N_ERROR_TRACKING_SETTINGS, components: { ErrorTrackingForm, + GlAlert, GlButton, GlFormCheckbox, GlFormGroup, GlFormRadioGroup, GlFormRadio, GlFormInputGroup, + GlLink, + GlSprintf, ProjectDropdown, ClipboardButton, }, + mixins: [glFeatureFlagsMixin()], props: { initialApiHost: { type: String, @@ -62,6 +73,11 @@ export default { default: null, }, }, + data() { + return { + isAlertDismissed: false, + }; + }, computed: { ...mapGetters([ 'dropdownLabel', @@ -81,12 +97,34 @@ export default { showGitlabDsnSetting() { return this.integrated && this.enabled && this.gitlabDsn; }, + showIntegratedErrorTracking() { + return this.glFeatures.integratedErrorTracking === true; + }, + setInitialEnabled() { + if (this.showIntegratedErrorTracking) { + return this.initialEnabled; + } + if (this.initialIntegrated === 'true') { + return 'false'; + } + return this.initialEnabled; + }, + showIntegratedTrackingDisabledAlert() { + return ( + !this.isAlertDismissed && + !this.showIntegratedErrorTracking && + this.initialIntegrated === 'true' && + this.initialEnabled === 'true' + ); + }, }, + epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639', + featureFlagLink: helpPagePath('operations/error_tracking'), created() { this.setInitialState({ apiHost: this.initialApiHost, - enabled: this.initialEnabled, - integrated: this.initialIntegrated, + enabled: this.setInitialEnabled, + integrated: this.showIntegratedErrorTracking && this.initialIntegrated, project: this.initialProject, token: this.initialToken, listProjectsEndpoint: this.listProjectsEndpoint, @@ -104,21 +142,41 @@ export default { handleSubmit() { this.updateSettings(); }, + dismissAlert() { + this.isAlertDismissed = true; + }, }, }; </script> <template> <div> + <gl-alert v-if="showIntegratedTrackingDisabledAlert" variant="danger" @dismiss="dismissAlert"> + <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText"> + <template #epicLink="{ content }"> + <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link> + </template> + <template #flagLink="{ content }"> + <gl-link :href="$options.featureFlagLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <gl-form-group :label="s__('ErrorTracking|Enable error tracking')" label-for="error-tracking-enabled" > - <gl-form-checkbox id="error-tracking-enabled" :checked="enabled" @change="updateEnabled"> + <gl-form-checkbox + id="error-tracking-enabled" + :checked="enabled" + data-testid="error-tracking-enabled" + @change="updateEnabled" + > {{ s__('ErrorTracking|Active') }} </gl-form-checkbox> </gl-form-group> <gl-form-group + v-if="showIntegratedErrorTracking" :label="s__('ErrorTracking|Error tracking backend')" data-testid="tracking-backend-settings" > diff --git a/app/assets/javascripts/error_tracking_settings/constants.js b/app/assets/javascripts/error_tracking_settings/constants.js new file mode 100644 index 00000000000..ee86c55e843 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/constants.js @@ -0,0 +1,7 @@ +import { s__ } from '~/locale'; + +export const I18N_ERROR_TRACKING_SETTINGS = { + integratedErrorTrackingDisabledText: s__( + 'ErrorTracking|Integrated error tracking is %{epicLinkStart}turned off by default%{epicLinkEnd} and no longer active for this project. To re-enable error tracking on self-hosted instances, you can either %{flagLinkStart}turn on the feature flag%{flagLinkEnd} for integrated error tracking, or provide a Sentry API URL and Auth Token below. However, error tracking is not ready for production use and cannot be enabled on GitLab.com.', + ), +}; diff --git a/app/assets/javascripts/experimentation/components/gitlab_experiment.vue b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue index 294dbf77991..678ce447e80 100644 --- a/app/assets/javascripts/experimentation/components/gitlab_experiment.vue +++ b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue @@ -9,7 +9,7 @@ export default { }, }, render() { - return this.$slots?.[getExperimentVariant(this.name)]; + return this.$scopedSlots?.[getExperimentVariant(this.name)]?.(); }, }; </script> diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index fcc7caa9ff2..9de291b7809 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -1,3 +1,4 @@ +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { FILTER_TYPE } from './constants'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; @@ -13,7 +14,7 @@ export default class FilteredSearchDropdown { this.filter = filter; this.dropdown = dropdown; this.loadingTemplate = `<div class="filter-dropdown-loading"> - <span class="spinner"></span> + ${loadingIconForLegacyJS().outerHTML} </div>`; this.bindEvents(); } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index bf29a356abd..8cb2e9e249b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -3,6 +3,7 @@ import '~/lib/utils/jquery_at_who'; import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash'; import * as Emoji from '~/emoji'; import axios from '~/lib/utils/axios_utils'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { s__, __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import SidebarMediator from '~/sidebar/sidebar_mediator'; @@ -574,6 +575,10 @@ class GfmAutoComplete { // Do not match if there is no `~` before the cursor return null; } + if (subtext.endsWith('~~')) { + // Do not match if there are two consecutive `~` characters (strikethrough) before the cursor + return null; + } const lastCandidate = subtext.split(flag).pop(); if (labels.find((label) => label.title.startsWith(lastCandidate))) { return lastCandidate; @@ -953,9 +958,14 @@ GfmAutoComplete.Contacts = { return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`; }, }; + +const loadingSpinner = loadingIconForLegacyJS({ + inline: true, + classes: ['gl-mr-2'], +}).outerHTML; + GfmAutoComplete.Loading = { - template: - '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>', + template: `<li style="pointer-events: none;">${loadingSpinner}Loading...</li>`, }; export default GfmAutoComplete; diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue index 64784755b66..03b256297f6 100644 --- a/app/assets/javascripts/google_cloud/components/app.vue +++ b/app/assets/javascripts/google_cloud/components/app.vue @@ -4,6 +4,7 @@ import { __ } from '~/locale'; import Home from './home.vue'; import IncubationBanner from './incubation_banner.vue'; import ServiceAccountsForm from './service_accounts_form.vue'; +import GcpRegionsForm from './gcp_regions_form.vue'; import NoGcpProjects from './errors/no_gcp_projects.vue'; import GcpError from './errors/gcp_error.vue'; @@ -11,6 +12,7 @@ const SCREEN_GCP_ERROR = 'gcp_error'; const SCREEN_HOME = 'home'; const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects'; const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form'; +const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form'; export default { components: { @@ -34,6 +36,8 @@ export default { return NoGcpProjects; case SCREEN_SERVICE_ACCOUNTS_FORM: return ServiceAccountsForm; + case SCREEN_GCP_REGIONS_FORM: + return GcpRegionsForm; default: throw new Error(__('Unknown screen')); } diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue new file mode 100644 index 00000000000..23011e5a5b0 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue @@ -0,0 +1,62 @@ +<script> +import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + components: { GlButton, GlFormGroup, GlFormSelect }, + props: { + availableRegions: { required: true, type: Array }, + refs: { required: true, type: Array }, + cancelPath: { required: true, type: String }, + }, + i18n: { + title: __('Configure region for environment'), + gcpRegionLabel: __('Region'), + gcpRegionDescription: __('List of suitable GCP locations'), + refsLabel: s__('GoogleCloud|Refs'), + refsDescription: s__('GoogleCloud|Configured region is linked to the selected branch or tag'), + submitLabel: __('Configure region'), + cancelLabel: __('Cancel'), + }, +}; +</script> + +<template> + <div> + <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + </header> + + <gl-form-group + label-for="ref" + :label="$options.i18n.refsLabel" + :description="$options.i18n.refsDescription" + > + <gl-form-select id="ref" name="ref" required> + <option value="*">{{ __('All') }}</option> + <option v-for="ref in refs" :key="ref" :value="ref"> + {{ ref }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + label-for="gcp_region" + :label="$options.i18n.gcpRegionLabel" + :description="$options.i18n.gcpRegionDescription" + > + <gl-form-select id="gcp_region" name="gcp_region" required :list="availableRegions"> + <option v-for="(region, index) in availableRegions" :key="index" :value="region"> + {{ region }} + </option> + </gl-form-select> + </gl-form-group> + + <div class="form-actions row"> + <gl-button type="submit" category="primary" variant="confirm"> + {{ $options.i18n.submitLabel }} + </gl-button> + <gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue new file mode 100644 index 00000000000..1cc5a85198a --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlButton, GlEmptyState, GlTable }, + props: { + list: { + type: Array, + required: true, + }, + createUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + }, + tableFields: [ + { key: 'environment', label: __('Environment'), sortable: true }, + { key: 'gcp_region', label: __('Region'), sortable: true }, + ], + i18n: { + emptyStateTitle: __('No regions configured'), + description: __('Configure your environments to be deployed to specific geographical regions'), + emptyStateAction: __('Add a GCP region'), + configureRegions: __('Configure regions'), + listTitle: __('Regions'), + }, +}; +</script> + +<template> + <div> + <gl-empty-state + v-if="list.length === 0" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.description" + :primary-button-link="createUrl" + :primary-button-text="$options.i18n.configureRegions" + /> + + <div v-else> + <h2 class="gl-font-size-h2">{{ $options.i18n.listTitle }}</h2> + <p>{{ $options.i18n.description }}</p> + + <gl-table :items="list" :fields="$options.tableFields" /> + + <gl-button :href="createUrl" category="primary" variant="info"> + {{ $options.i18n.configureRegions }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue index c08d8bb7c51..e41337e2679 100644 --- a/app/assets/javascripts/google_cloud/components/home.vue +++ b/app/assets/javascripts/google_cloud/components/home.vue @@ -1,14 +1,18 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; import DeploymentsServiceTable from './deployments_service_table.vue'; +import RevokeOauth from './revoke_oauth.vue'; import ServiceAccountsList from './service_accounts_list.vue'; +import GcpRegionsList from './gcp_regions_list.vue'; export default { components: { GlTabs, GlTab, DeploymentsServiceTable, + RevokeOauth, ServiceAccountsList, + GcpRegionsList, }, props: { serviceAccounts: { @@ -19,6 +23,10 @@ export default { type: String, required: true, }, + configureGcpRegionsUrl: { + type: String, + required: true, + }, emptyIllustrationUrl: { type: String, required: true, @@ -31,6 +39,14 @@ export default { type: String, required: true, }, + gcpRegions: { + type: Array, + required: true, + }, + revokeOauthUrl: { + type: String, + required: true, + }, }, }; </script> @@ -44,6 +60,15 @@ export default { :create-url="createServiceAccountUrl" :empty-illustration-url="emptyIllustrationUrl" /> + <hr /> + <gcp-regions-list + class="gl-mx-4" + :empty-illustration-url="emptyIllustrationUrl" + :create-url="configureGcpRegionsUrl" + :list="gcpRegions" + /> + <hr v-if="revokeOauthUrl" /> + <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" /> </gl-tab> <gl-tab :title="__('Deployments')"> <deployments-service-table diff --git a/app/assets/javascripts/google_cloud/components/revoke_oauth.vue b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue new file mode 100644 index 00000000000..07d966894f6 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue @@ -0,0 +1,38 @@ +<script> +import { GlButton, GlForm } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { s__ } from '~/locale'; + +export const GOOGLE_CLOUD_REVOKE_TITLE = s__('GoogleCloud|Revoke authorizations'); +export const GOOGLE_CLOUD_REVOKE_DESCRIPTION = s__( + 'GoogleCloud|Revoke authorizations granted to GitLab. This does not invalidate service accounts.', +); + +export default { + components: { GlButton, GlForm }, + csrf, + props: { + url: { + type: String, + required: true, + }, + }, + i18n: { + title: GOOGLE_CLOUD_REVOKE_TITLE, + description: GOOGLE_CLOUD_REVOKE_DESCRIPTION, + }, +}; +</script> + +<template> + <div class="gl-mx-4"> + <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2> + <p>{{ $options.i18n.description }}</p> + <gl-form :action="url" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button category="secondary" variant="warning" type="submit"> + {{ $options.i18n.title }} + </gl-button> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue index 551783e6c50..faec94e735b 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue +++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue @@ -1,26 +1,29 @@ <script> -import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { + ALL_REFS: '*', components: { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox }, props: { gcpProjects: { required: true, type: Array }, - environments: { required: true, type: Array }, + refs: { required: true, type: Array }, cancelPath: { required: true, type: String }, }, i18n: { - title: __('Create service account'), - gcpProjectLabel: __('Google Cloud project'), - gcpProjectDescription: __( - 'New service account is generated for the selected Google Cloud project', + title: s__('GoogleCloud|Create service account'), + gcpProjectLabel: s__('GoogleCloud|Google Cloud project'), + gcpProjectDescription: s__( + 'GoogleCloud|New service account is generated for the selected Google Cloud project', ), - environmentLabel: __('Environment'), - environmentDescription: __('Generated service account is linked to the selected environment'), - submitLabel: __('Create service account'), - cancelLabel: __('Cancel'), - checkboxLabel: __( - 'I understand the responsibilities involved with managing service account keys', + refsLabel: s__('GoogleCloud|Refs'), + refsDescription: s__( + 'GoogleCloud|Generated service account is linked to the selected branch or tag', + ), + submitLabel: s__('GoogleCloud|Create service account'), + cancelLabel: s__('GoogleCloud|Cancel'), + checkboxLabel: s__( + 'GoogleCloud|I understand the responsibilities involved with managing service account keys', ), }, }; @@ -47,18 +50,14 @@ export default { </gl-form-select> </gl-form-group> <gl-form-group - label-for="environment" - :label="$options.i18n.environmentLabel" - :description="$options.i18n.environmentDescription" + label-for="ref" + :label="$options.i18n.refsLabel" + :description="$options.i18n.refsDescription" > - <gl-form-select id="environment" name="environment" required> - <option value="*">{{ __('All') }}</option> - <option - v-for="environment in environments" - :key="environment.name" - :value="environment.name" - > - {{ environment.name }} + <gl-form-select id="ref" name="ref" required> + <option :value="$options.ALL_REFS">{{ __('All') }}</option> + <option v-for="ref in refs" :key="ref" :value="ref"> + {{ ref }} </option> </gl-form-select> </gl-form-group> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue index 4db84746482..37b716d7be5 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue +++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue @@ -18,16 +18,12 @@ export default { required: true, }, }, - data() { - return { - tableFields: [ - { key: 'environment', label: __('Environment'), sortable: true }, - { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true }, - { key: 'service_account_exists', label: __('Service Account'), sortable: true }, - { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true }, - ], - }; - }, + tableFields: [ + { key: 'ref', label: __('Environment'), sortable: true }, + { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true }, + { key: 'service_account_exists', label: __('Service Account'), sortable: true }, + { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true }, + ], i18n: { createServiceAccount: __('Create service account'), found: __('✔'), @@ -62,7 +58,7 @@ export default { <h2 class="gl-font-size-h2">{{ $options.i18n.serviceAccountsTitle }}</h2> <p>{{ $options.i18n.serviceAccountsDescription }}</p> - <gl-table :items="list" :fields="tableFields"> + <gl-table :items="list" :fields="$options.tableFields"> <template #cell(service_account_exists)="{ value }"> {{ value ? $options.i18n.found : $options.i18n.notFound }} </template> diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index 55987ce64e6..f42152006d2 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -150,7 +150,7 @@ export const trackSaasTrialProject = () => { }); }; -export const trackSaasTrialProjectImport = () => { +export const trackProjectImport = () => { if (!isSupported()) { return; } @@ -159,7 +159,7 @@ export const trackSaasTrialProjectImport = () => { importButtons.forEach((button) => { button.addEventListener('click', () => { const { platform } = button.dataset; - pushEvent('saasTrialProjectImport', { saasProjectImport: platform }); + pushEvent('projectImport', { platform }); }); }); }; @@ -231,3 +231,43 @@ export const trackTransaction = (transactionDetails) => { pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData); }; + +export const trackAddToCartUsageTab = () => { + if (!isSupported()) { + return; + } + + const getStartedButton = document.querySelector('.js-buy-additional-minutes'); + getStartedButton.addEventListener('click', () => { + window.dataLayer.push({ + event: 'EECproductAddToCart', + ecommerce: { + currencyCode: 'USD', + add: { + products: [ + { + name: 'CI/CD Minutes', + id: '0003', + price: '10', + brand: 'GitLab', + category: 'DevOps', + variant: 'add-on', + quantity: 1, + }, + ], + }, + }, + }); + }); +}; + +export const trackCombinedGroupProjectForm = () => { + if (!isSupported()) { + return; + } + + const form = document.querySelector('.js-groups-projects-form'); + form.addEventListener('submit', () => { + pushEvent('combinedGroupProjectFormSubmit'); + }); +}; diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index 7964e762dac..d376c9f76ba 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { queryToObject } from '~/lib/utils/url_utility'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { __ } from '~/locale'; @@ -14,7 +15,7 @@ export default class GpgBadges { const badges = $('.js-loading-gpg-badge'); - badges.html('<span class="gl-spinner gl-spinner-orange gl-spinner-sm"></span>'); + badges.html(loadingIconForLegacyJS()); badges.children().attr('aria-label', __('Loading')); const displayError = () => diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 3b36c3e6ac5..4ebb49b4756 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,9 +1,11 @@ export const MINIMUM_SEARCH_LENGTH = 3; +export const TYPE_BOARD = 'Board'; export const TYPE_CI_RUNNER = 'Ci::Runner'; export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; export const TYPE_DISCUSSION = 'Discussion'; export const TYPE_EPIC = 'Epic'; +export const TYPE_EPIC_BOARD = 'Boards::EpicBoard'; export const TYPE_GROUP = 'Group'; export const TYPE_ISSUE = 'Issue'; export const TYPE_ITERATION = 'Iteration'; diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json index 9a24d2a3afc..01116067887 100644 --- a/app/assets/javascripts/graphql_shared/possibleTypes.json +++ b/app/assets/javascripts/graphql_shared/possibleTypes.json @@ -1 +1 @@ -{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"User":["MergeRequestAssignee","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]} +{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest","WorkItem"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"Todoable":["AlertManagementAlert","BoardEpic","Commit","Design","Epic","EpicIssue","Issue","MergeRequest"],"User":["MergeRequestAssignee","MergeRequestAuthor","MergeRequestParticipant","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]}
\ No newline at end of file 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 new file mode 100644 index 00000000000..2bd016feb19 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql @@ -0,0 +1,24 @@ +#import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query projectUsersSearchWithMRPermissions( + $search: String! + $fullPath: ID! + $mergeRequestId: MergeRequestID! +) { + workspace: project(fullPath: $fullPath) { + id + users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { + nodes { + id + mergeRequestInteraction(id: $mergeRequestId) { + canMerge + } + user { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index c24eeed9f03..3620c884c5f 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -68,7 +68,7 @@ export default { /> <item-stats-value v-if="isGroup" - :title="__('Members')" + :title="__('Direct members')" :value="item.memberCount" css-class="number-users gl-ml-5" icon-name="users" diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 9f4f4768247..c0e2c18bece 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -4,20 +4,28 @@ import { GlDropdownSectionHeader, GlDropdownDivider, GlAvatar, + GlAlert, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; import highlight from '~/lib/utils/highlight'; import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; export default { name: 'HeaderSearchAutocompleteItems', + i18n: { + autocompleteErrorMessage: s__( + 'GlobalSearch|There was an error fetching search autocomplete suggestions.', + ), + }, components: { GlDropdownItem, GlDropdownSectionHeader, GlDropdownDivider, GlAvatar, + GlAlert, GlLoadingIcon, }, directives: { @@ -31,7 +39,7 @@ export default { }, }, computed: { - ...mapState(['search', 'loading']), + ...mapState(['search', 'loading', 'autocompleteError']), ...mapGetters(['autocompleteGroupedSearchOptions']), }, watch: { @@ -93,5 +101,13 @@ export default { </div> </template> <gl-loading-icon v-else size="lg" class="my-4" /> + <gl-alert + v-if="autocompleteError" + class="gl-text-body gl-mt-2" + :dismissible="false" + variant="danger" + > + {{ $options.i18n.autocompleteErrorMessage }} + </gl-alert> </div> </template> diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue index 53e63bc6cca..04deaba7b0f 100644 --- a/app/assets/javascripts/header_search/components/header_search_default_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue @@ -24,8 +24,8 @@ export default { ...mapGetters(['defaultSearchOptions']), sectionHeader() { return ( - this.searchContext.project?.name || - this.searchContext.group?.name || + this.searchContext?.project?.name || + this.searchContext?.group?.name || this.$options.i18n.allGitLab ); }, diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index d7e21f55ea5..4af8513ecdb 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -5,7 +5,7 @@ import createStore from './store'; Vue.use(Translate); -export const initHeaderSearchApp = () => { +export const initHeaderSearchApp = (search = '') => { const el = document.getElementById('js-header-search'); if (!el) { @@ -18,7 +18,7 @@ export const initHeaderSearchApp = () => { return new Vue({ el, - store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }), + store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), render(createElement) { return createElement(HeaderSearchApp); }, diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index 0ba956f3ed1..ee4c312fed0 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -1,6 +1,4 @@ -import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; import * as types from './mutation_types'; export const fetchAutocompleteOptions = ({ commit, getters }) => { @@ -10,7 +8,6 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => { .then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data)) .catch(() => { commit(types.RECEIVE_AUTOCOMPLETE_ERROR); - createFlash({ message: __('There was an error fetching search autocomplete suggestions') }); }); }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index a1348a8aa3f..87dec95153f 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -17,9 +17,12 @@ export const searchQuery = (state) => { { search: state.search, nav_source: 'navbar', - project_id: state.searchContext.project?.id, - group_id: state.searchContext.group?.id, + project_id: state.searchContext?.project?.id, + group_id: state.searchContext?.group?.id, scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -31,7 +34,7 @@ export const autocompleteQuery = (state) => { const query = omitBy( { term: state.search, - project_id: state.searchContext.project?.id, + project_id: state.searchContext?.project?.id, project_ref: state.searchContext?.ref, }, isNil, @@ -42,16 +45,16 @@ export const autocompleteQuery = (state) => { export const scopedIssuesPath = (state) => { return ( - state.searchContext.project_metadata?.issues_path || - state.searchContext.group_metadata?.issues_path || + state.searchContext?.project_metadata?.issues_path || + state.searchContext?.group_metadata?.issues_path || state.issuesPath ); }; export const scopedMRPath = (state) => { return ( - state.searchContext.project_metadata?.mr_path || - state.searchContext.group_metadata?.mr_path || + state.searchContext?.project_metadata?.mr_path || + state.searchContext?.group_metadata?.mr_path || state.mrPath ); }; @@ -96,6 +99,9 @@ export const projectUrl = (state) => { project_id: state.searchContext?.project?.id, group_id: state.searchContext?.group?.id, scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -110,6 +116,9 @@ export const groupUrl = (state) => { nav_source: 'navbar', group_id: state.searchContext?.group?.id, scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -123,6 +132,9 @@ export const allUrl = (state) => { search: state.search, nav_source: 'navbar', scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -133,19 +145,19 @@ export const allUrl = (state) => { export const scopedSearchOptions = (state, getters) => { const options = []; - if (state.searchContext.project) { + if (state.searchContext?.project) { options.push({ html_id: 'scoped-in-project', - scope: state.searchContext.project.name, + scope: state.searchContext.project?.name || '', description: MSG_IN_PROJECT, url: getters.projectUrl, }); } - if (state.searchContext.group) { + if (state.searchContext?.group) { options.push({ html_id: 'scoped-in-group', - scope: state.searchContext.group.name, + scope: state.searchContext.group?.name || '', description: MSG_IN_GROUP, url: getters.groupUrl, }); diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js index 06cca4be8a7..b83433c5b49 100644 --- a/app/assets/javascripts/header_search/store/index.js +++ b/app/assets/javascripts/header_search/store/index.js @@ -13,11 +13,12 @@ export const getStoreConfig = ({ mrPath, autocompletePath, searchContext, + search, }) => ({ actions, getters, mutations, - state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }), + state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), }); const createStore = (config) => new Vuex.Store(getStoreConfig(config)); diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js index 26b4a8854fe..92948bec515 100644 --- a/app/assets/javascripts/header_search/store/mutations.js +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -4,19 +4,23 @@ export default { [types.REQUEST_AUTOCOMPLETE](state) { state.loading = true; state.autocompleteOptions = []; + state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; state.autocompleteOptions = data.map((d, i) => { return { html_id: `autocomplete-${d.category}-${i}`, ...d }; }); + state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { state.loading = false; state.autocompleteOptions = []; + state.autocompleteError = true; }, [types.CLEAR_AUTOCOMPLETE](state) { state.autocompleteOptions = []; + state.autocompleteError = false; }, [types.SET_SEARCH](state, value) { state.search = value; diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js index 3d4073f0583..bebdbc7b92e 100644 --- a/app/assets/javascripts/header_search/store/state.js +++ b/app/assets/javascripts/header_search/store/state.js @@ -1,11 +1,19 @@ -const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({ +const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, - search: '', + search, +}) => ({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search, autocompleteOptions: [], + autocompleteError: false, loading: false, }); export default createState; diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index 0803925104d..0921b5a5424 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -1,17 +1,46 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import Dropdown from './dropdown.vue'; +import { __ } from '~/locale'; + +const barLabel = __('File templates'); +const templateListDropdownLabel = __('Choose a template...'); +const templateTypesDropdownLabel = __('Choose a type...'); +const undoButtonText = __('Undo'); export default { + i18n: { + barLabel, + templateListDropdownLabel, + templateTypesDropdownLabel, + undoButtonText, + }, components: { - Dropdown, GlButton, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + }, + data() { + return { + search: '', + }; }, computed: { ...mapGetters(['activeFile']), ...mapGetters('fileTemplates', ['templateTypes']), - ...mapState('fileTemplates', ['selectedTemplateType', 'updateSuccess']), + ...mapState('fileTemplates', [ + 'selectedTemplateType', + 'updateSuccess', + 'templates', + 'isLoading', + ]), + filteredTemplateTypes() { + return this.templates.filter((t) => { + return t.name.toLowerCase().includes(this.search.toLowerCase()); + }); + }, showTemplatesDropdown() { return Object.keys(this.selectedTemplateType).length > 0; }, @@ -26,6 +55,7 @@ export default { ...mapActions('fileTemplates', [ 'setSelectedTemplateType', 'fetchTemplate', + 'fetchTemplateTypes', 'undoFileTemplate', ]), setInitialType() { @@ -50,27 +80,46 @@ export default { <template> <div - class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1" + class="gl-display-flex gl-align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1" > - <strong class="gl-mr-3"> {{ __('File templates') }} </strong> - <dropdown - :data="templateTypes" - :label="selectedTemplateType.name || __('Choose a type...')" - class="mr-2" - @click="selectTemplateType" - /> - <dropdown + <strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong> + <gl-dropdown + class="gl-mr-6" + :text="selectedTemplateType.name || $options.i18n.templateTypesDropdownLabel" + > + <gl-dropdown-item + v-for="template in templateTypes" + :key="template.key" + @click.prevent="selectTemplateType(template)" + > + {{ template.name }} + </gl-dropdown-item> + </gl-dropdown> + <gl-dropdown v-if="showTemplatesDropdown" - :label="__('Choose a template...')" - :is-async-data="true" - :searchable="true" - :title="__('File templates')" - class="mr-2 qa-file-template-dropdown" - @click="selectTemplate" - /> + class="gl-mr-6 qa-file-template-dropdown" + :text="$options.i18n.templateListDropdownLabel" + @show="fetchTemplateTypes" + > + <template #header> + <gl-search-box-by-type v-model.trim="search" data-qa-selector="dropdown_filter_input" /> + </template> + <div> + <gl-loading-icon v-if="isLoading" /> + <template v-else> + <gl-dropdown-item + v-for="template in filteredTemplateTypes" + :key="template.key" + @click="selectTemplate(template)" + > + {{ template.name }} + </gl-dropdown-item> + </template> + </div> + </gl-dropdown> <transition name="fade"> <gl-button v-show="updateSuccess" category="secondary" variant="default" @click="undo"> - {{ __('Undo') }} + {{ $options.i18n.undoButtonText }} </gl-button> </transition> </div> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index ec61e3374d7..e8b42ac9490 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -84,7 +84,7 @@ export default { v-model="search" :placeholder="__('Filter...')" type="search" - class="dropdown-input-field qa-dropdown-filter-input" + class="dropdown-input-field" /> <gl-icon name="search" class="dropdown-input-search" /> </div> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 1c5a00568eb..e3c230f7660 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -6,6 +6,10 @@ import { __, sprintf } from '~/locale'; import { modalTypes } from '../../constants'; import { trimPathComponents, getPathParent } from '../../utils'; +const i18n = { + cancelButtonText: __('Cancel'), +}; + export default { components: { GlModal, @@ -43,6 +47,18 @@ export default { return __('Create file'); }, + actionPrimary() { + return { + text: this.buttonLabel, + attributes: [{ variant: 'confirm' }], + }; + }, + actionCancel() { + return { + text: i18n.cancelButtonText, + attributes: [{ variant: 'default' }], + }; + }, isCreatingNewFile() { return this.modalType === modalTypes.blob; }, @@ -136,11 +152,11 @@ export default { data-qa-selector="new_file_modal" data-testid="ide-new-entry" :title="modalTitle" - :ok-title="buttonLabel" - ok-variant="success" size="lg" - @ok="submitForm" - @hide="resetData" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + @primary="submitForm" + @cancel="resetData" > <div class="form-group row"> <label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 05493db1dff..f14d86114b8 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -147,6 +147,9 @@ export default { fileType() { return this.previewMode?.id || ''; }, + showTabs() { + return !this.shouldHideEditor && this.isEditModeActive && this.previewMode; + }, }, watch: { 'file.name': { @@ -194,6 +197,9 @@ export default { this.refreshEditorDimensions(); } }, + showTabs() { + this.$nextTick(() => this.refreshEditorDimensions()); + }, rightPaneIsOpen() { this.refreshEditorDimensions(); }, @@ -410,7 +416,7 @@ export default { } }, refreshEditorDimensions() { - if (this.showEditor) { + if (this.showEditor && this.editor) { this.editor.updateDimensions(); } }, @@ -495,7 +501,7 @@ export default { <template> <div id="ide" class="blob-viewer-container blob-editor-container"> - <div v-if="!shouldHideEditor && isEditModeActive" class="ide-mode-tabs clearfix"> + <div v-if="showTabs" class="ide-mode-tabs clearfix"> <ul class="nav-links float-left border-bottom-0"> <li :class="editTabCSS"> <a @@ -506,7 +512,7 @@ export default { >{{ __('Edit') }}</a > </li> - <li v-if="previewMode" :class="previewTabCSS"> + <li :class="previewTabCSS"> <a href="javascript:void(0);" role="button" diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index b9f0b5012ac..bd0f4cd5dd7 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -137,7 +137,7 @@ export default { <gl-form-input data-qa-selector="githubish_import_filter_field" name="filter" - :placeholder="__('Filter your repositories by name')" + :placeholder="__('Filter by name')" autofocus size="lg" @keyup.enter="setFilter($event.target.value)" diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 7a904bdb6ad..324797ad645 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -1,5 +1,6 @@ <script> import { + GlLink, GlLoadingIcon, GlTable, GlAvatarsInline, @@ -24,9 +25,11 @@ import { } from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { I18N, INCIDENT_STATUS_TABS, + ESCALATION_STATUSES, TH_CREATED_AT_TEST_ID, TH_INCIDENT_SLA_TEST_ID, TH_SEVERITY_TEST_ID, @@ -38,7 +41,7 @@ import { import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import getIncidents from '../graphql/queries/get_incidents.query.graphql'; -const MAX_VISIBLE_ASSIGNEES = 4; +const MAX_VISIBLE_ASSIGNEES = 3; export default { trackIncidentCreateNewOptions, @@ -49,7 +52,7 @@ export default { { key: 'severity', label: s__('IncidentManagement|Severity'), - thClass: `${thClass} w-15p`, + thClass: `${thClass} gl-w-15p`, tdClass: `${tdClass} sortable-cell`, actualSortKey: 'SEVERITY', sortable: true, @@ -62,6 +65,12 @@ export default { tdClass, }, { + key: 'escalationStatus', + label: s__('IncidentManagement|Status'), + thClass: `${thClass} gl-w-eighth gl-pointer-events-none`, + tdClass, + }, + { key: 'createdAt', label: s__('IncidentManagement|Date created'), thClass: `${thClass} gl-w-eighth`, @@ -73,7 +82,7 @@ export default { { key: 'incidentSla', label: s__('IncidentManagement|Time to SLA'), - thClass: `gl-text-right gl-w-eighth`, + thClass: `gl-text-right gl-w-10p`, tdClass: `${tdClass} gl-text-right`, thAttr: TH_INCIDENT_SLA_TEST_ID, actualSortKey: 'SLA_DUE_AT', @@ -83,13 +92,13 @@ export default { { key: 'assignees', label: s__('IncidentManagement|Assignees'), - thClass: 'gl-pointer-events-none w-15p', + thClass: 'gl-pointer-events-none gl-w-15', tdClass, }, { key: 'published', label: s__('IncidentManagement|Published'), - thClass: `${thClass} w-15p`, + thClass: `${thClass} gl-w-15`, tdClass: `${tdClass} sortable-cell`, actualSortKey: 'PUBLISHED', sortable: true, @@ -98,6 +107,7 @@ export default { ], MAX_VISIBLE_ASSIGNEES, components: { + GlLink, GlLoadingIcon, GlTable, GlAvatarsInline, @@ -112,6 +122,7 @@ export default { GlEmptyState, SeverityToken, PaginatedTableWithSearchAndTabs, + TooltipOnTruncate, }, directives: { GlTooltip: GlTooltipDirective, @@ -129,6 +140,7 @@ export default { 'assigneeUsernameQuery', 'slaFeatureAvailable', 'canCreateIncident', + 'incidentEscalationsAvailable', ], apollo: { incidents: { @@ -222,6 +234,7 @@ export default { const isHidden = { published: !this.publishedAvailable, incidentSla: !this.slaFeatureAvailable, + escalationStatus: !this.incidentEscalationsAvailable, }; return this.$options.fields.filter(({ key }) => !isHidden[key]); @@ -260,7 +273,7 @@ export default { return Boolean(assignees.nodes?.length); }, navigateToIncidentDetails({ iid }) { - return visitUrl(joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid)); + return visitUrl(this.showIncidentLink({ iid })); }, navigateToCreateNewIncident() { const { category, action } = this.$options.trackIncidentCreateNewOptions; @@ -283,6 +296,12 @@ export default { getSeverity(severity) { return INCIDENT_SEVERITY[severity]; }, + getEscalationStatus(escalationStatus) { + return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus; + }, + showIncidentLink({ iid }) { + return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid); + }, pageChanged(pagination) { this.pagination = pagination; }, @@ -370,7 +389,14 @@ export default { <template #cell(title)="{ item }"> <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> - <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> + <gl-link + v-gl-tooltip + :title="item.title" + data-testid="incident-link" + :href="showIncidentLink(item)" + > + {{ item.title }} + </gl-link> <gl-icon v-if="item.state === 'closed'" name="issue-close" @@ -381,8 +407,21 @@ export default { </div> </template> + <template v-if="incidentEscalationsAvailable" #cell(escalationStatus)="{ item }"> + <tooltip-on-truncate + :title="getEscalationStatus(item.escalationStatus)" + data-testid="incident-escalation-status" + class="gl-display-block gl-text-truncate" + > + {{ getEscalationStatus(item.escalationStatus) }} + </tooltip-on-truncate> + </template> + <template #cell(createdAt)="{ item }"> - <time-ago-tooltip :time="item.createdAt" /> + <time-ago-tooltip + :time="item.createdAt" + class="gl-display-block gl-max-w-full gl-text-truncate" + /> </template> <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> @@ -392,6 +431,7 @@ export default { :project-path="projectPath" :sla-due-at="item.slaDueAt" data-testid="incident-sla" + class="gl-display-block gl-max-w-full gl-text-truncate" /> </template> @@ -432,6 +472,7 @@ export default { :un-published="$options.i18n.unPublished" /> </template> + <template #table-busy> <gl-loading-icon size="lg" color="dark" class="mt-3" /> </template> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index 23909ae3b6c..21cdbef05a1 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -7,6 +7,7 @@ export const I18N = { unassigned: s__('IncidentManagement|Unassigned'), createIncidentBtnLabel: s__('IncidentManagement|Create incident'), unPublished: s__('IncidentManagement|Unpublished'), + noEscalationStatus: s__('IncidentManagement|None'), emptyState: { title: s__('IncidentManagement|Display your incidents in a dedicated view'), emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'), @@ -37,6 +38,12 @@ export const INCIDENT_STATUS_TABS = [ }, ]; +export const ESCALATION_STATUSES = { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), +}; + export const DEFAULT_PAGE_SIZE = 20; export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql index faa68d37088..b72941966c6 100644 --- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql +++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql @@ -1,4 +1,5 @@ # eslint-disable-next-line @graphql-eslint/require-id-when-available fragment IncidentFields on Issue { severity + escalationStatus } diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js index 1d40f1093a4..c0f16a43d5c 100644 --- a/app/assets/javascripts/incidents/list.js +++ b/app/assets/javascripts/incidents/list.js @@ -46,6 +46,7 @@ export default () => { assigneeUsernameQuery, slaFeatureAvailable: parseBoolean(slaFeatureAvailable), canCreateIncident: parseBoolean(canCreateIncident), + incidentEscalationsAvailable: parseBoolean(gon?.features?.incidentEscalations), }, apolloProvider, render(createElement) { diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 004601bc0a3..c5ed5bb08a9 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,6 +1,7 @@ import { s__, __ } from '~/locale'; export const integrationLevels = { + PROJECT: 'project', GROUP: 'group', INSTANCE: 'instance', }; @@ -24,3 +25,15 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s export const settingsTabTitle = __('Settings'); export const overridesTabTitle = s__('Integrations|Projects using custom settings'); + +export const integrationFormSections = { + CONNECTION: 'connection', + JIRA_TRIGGER: 'jira_trigger', + JIRA_ISSUES: 'jira_issues', +}; + +export const integrationFormSectionComponents = { + [integrationFormSections.CONNECTION]: 'IntegrationSectionConnection', + [integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger', + [integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues', +}; diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index 5ddf3aeb639..a4415a5a2b3 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -1,6 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; export default { name: 'ActiveCheckbox', @@ -15,6 +15,10 @@ export default { }, computed: { ...mapGetters(['isInheriting', 'propsSource']), + ...mapState(['customState']), + disabled() { + return this.isInheriting || this.customState.activateDisabled; + }, }, mounted() { this.activated = this.propsSource.initialActivated; @@ -34,7 +38,7 @@ export default { <gl-form-checkbox v-model="activated" class="gl-display-block" - :disabled="isInheriting" + :disabled="disabled" @change="onChange" > {{ __('Active') }} diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 007a384f41e..6e89872ff68 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -3,12 +3,14 @@ import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml, GlForm } f import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; + import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, integrationLevels, + integrationFormSectionComponents, } from '~/integrations/constants'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import csrf from '~/lib/utils/csrf'; @@ -33,6 +35,18 @@ export default { DynamicField, ConfirmationModal, ResetConfirmationModal, + IntegrationSectionConnection: () => + import( + /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue' + ), + IntegrationSectionJiraIssues: () => + import( + /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue' + ), + IntegrationSectionJiraTrigger: () => + import( + /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue' + ), GlButton, GlForm, }, @@ -41,10 +55,13 @@ export default { SafeHtml, }, mixins: [glFeatureFlagsMixin()], - props: { + provide() { + return { + hasSections: this.hasSections, + }; + }, + inject: { helpHtml: { - type: String, - required: false, default: '', }, }, @@ -81,28 +98,42 @@ export default { disableButtons() { return Boolean(this.isSaving || this.isResetting || this.isTesting); }, - form() { - return this.$refs.integrationForm.$el; + sectionsEnabled() { + return this.glFeatures.integrationFormSections; + }, + hasSections() { + return this.sectionsEnabled && this.customState.sections.length !== 0; + }, + fieldsWithoutSection() { + return this.sectionsEnabled + ? this.propsSource.fields.filter((field) => !field.section) + : this.propsSource.fields; }, }, methods: { ...mapActions(['setOverride', 'requestJiraIssueTypes']), + fieldsForSection(section) { + return this.propsSource.fields.filter((field) => field.section === section.type); + }, + form() { + return this.$refs.integrationForm.$el; + }, setIsValidated() { this.isValidated = true; }, onSaveClick() { this.isSaving = true; - if (this.integrationActive && !this.form.checkValidity()) { + if (this.integrationActive && !this.form().checkValidity()) { this.isSaving = false; this.setIsValidated(); return; } - this.form.submit(); + this.form().submit(); }, onTestClick() { - if (!this.form.checkValidity()) { + if (!this.form().checkValidity()) { this.setIsValidated(); return; } @@ -147,7 +178,7 @@ export default { this.requestJiraIssueTypes(this.getFormData()); }, getFormData() { - return new FormData(this.form); + return new FormData(this.form()); }, onToggleIntegrationState(integrationActive) { this.integrationActive = integrationActive; @@ -159,6 +190,7 @@ export default { FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes }, csrf, + integrationFormSectionComponents, }; </script> @@ -187,46 +219,75 @@ export default { @change="setOverride" /> + <template v-if="hasSections"> + <div + v-for="(section, index) in customState.sections" + :key="section.type" + :class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }" + data-testid="integration-section" + > + <div class="row"> + <div class="col-lg-4"> + <h4 class="gl-mt-0">{{ section.title }}</h4> + <p v-safe-html="section.description"></p> + </div> + + <div class="col-lg-8"> + <component + :is="$options.integrationFormSectionComponents[section.type]" + :fields="fieldsForSection(section)" + :is-validated="isValidated" + @toggle-integration-active="onToggleIntegrationState" + @request-jira-issue-types="onRequestJiraIssueTypes" + /> + </div> + </div> + </div> + </template> + <div class="row"> <div class="col-lg-4"></div> <div class="col-lg-8"> <!-- helpHtml is trusted input --> - <div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> + <div v-if="helpHtml && !hasSections" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> <active-checkbox - v-if="propsSource.showActive" + v-if="propsSource.showActive && !hasSections" :key="`${currentKey}-active-checkbox`" @toggle-integration-active="onToggleIntegrationState" /> <jira-trigger-fields - v-if="isJira" + v-if="isJira && !hasSections" :key="`${currentKey}-jira-trigger-fields`" v-bind="propsSource.triggerFieldsProps" :is-validated="isValidated" /> <trigger-fields - v-else-if="propsSource.triggerEvents.length" + v-else-if="propsSource.triggerEvents.length && !hasSections" :key="`${currentKey}-trigger-fields`" :events="propsSource.triggerEvents" :type="propsSource.type" /> <dynamic-field - v-for="field in propsSource.fields" + v-for="field in fieldsWithoutSection" :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" /> <jira-issues-fields - v-if="isJira && !isInstanceOrGroupLevel" + v-if="isJira && !isInstanceOrGroupLevel && !hasSections" :key="`${currentKey}-jira-issues-fields`" v-bind="propsSource.jiraIssuesProps" :is-validated="isValidated" @request-jira-issue-types="onRequestJiraIssueTypes" /> + </div> + </div> + <div v-if="isEditable" class="row"> + <div :class="hasSections ? 'col' : 'col-lg-8 offset-lg-4'"> <div - v-if="isEditable" class="footer-block row-content-block gl-display-flex gl-justify-content-space-between" > <div> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 7f2f7620a86..7cf8e11f162 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -16,6 +16,11 @@ export default { JiraIssueCreationVulnerabilities: () => import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'), }, + inject: { + hasSections: { + default: false, + }, + }, props: { showJiraIssuesIntegration: { type: Boolean, @@ -83,17 +88,17 @@ export default { i18n: { sectionTitle: s__('JiraService|View Jira issues in GitLab'), sectionDescription: s__( - 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.', + 'JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues.', ), enableCheckboxLabel: s__('JiraService|Enable Jira issues'), enableCheckboxHelp: s__( - 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.', + 'JiraService|Warning: All GitLab users with access to this GitLab project can view all issues from the Jira project you select.', ), projectKeyLabel: s__('JiraService|Jira project key'), projectKeyPlaceholder: s__('JiraService|For example, AB'), requiredFieldFeedback: __('This field is required.'), issueTrackerConflictWarning: s__( - 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', + 'JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', ), }, }; @@ -101,9 +106,12 @@ export default { <template> <div> - <gl-form-group :label="$options.i18n.sectionTitle" label-for="jira-issue-settings"> + <gl-form-group + :label="hasSections ? null : $options.i18n.sectionTitle" + label-for="jira-issue-settings" + > <div id="jira-issue-settings"> - <p> + <p v-if="!hasSections"> {{ $options.i18n.sectionDescription }} </p> <template v-if="showJiraIssuesIntegration"> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index df5946b814a..3c06660e7c5 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -62,6 +62,11 @@ export default { GlLink, GlSprintf, }, + inject: { + hasSections: { + default: false, + }, + }, props: { initialTriggerCommit: { type: Boolean, @@ -134,12 +139,14 @@ export default { <template> <div> <gl-form-group - :label="__('Trigger')" + :label="hasSections ? null : __('Trigger')" label-for="service[trigger]" :description=" - s__( - 'Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled).', - ) + hasSections + ? null + : s__( + 'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.', + ) " > <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue new file mode 100644 index 00000000000..364e9324e43 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue @@ -0,0 +1,45 @@ +<script> +import { mapGetters } from 'vuex'; + +import ActiveCheckbox from '../active_checkbox.vue'; +import DynamicField from '../dynamic_field.vue'; + +export default { + name: 'IntegrationSectionConnection', + components: { + ActiveCheckbox, + DynamicField, + }, + props: { + fields: { + type: Array, + required: false, + default: () => [], + }, + isValidated: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['currentKey', 'propsSource']), + }, +}; +</script> + +<template> + <div> + <active-checkbox + v-if="propsSource.showActive" + :key="`${currentKey}-active-checkbox`" + @toggle-integration-active="$emit('toggle-integration-active', $event)" + /> + <dynamic-field + v-for="field in fields" + :key="`${currentKey}-${field.name}`" + v-bind="field" + :is-validated="isValidated" + /> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue new file mode 100644 index 00000000000..75202209d38 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue @@ -0,0 +1,33 @@ +<script> +import { mapGetters } from 'vuex'; + +import JiraIssuesFields from '../jira_issues_fields.vue'; + +export default { + name: 'IntegrationSectionJiraIssues', + components: { + JiraIssuesFields, + }, + props: { + isValidated: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['currentKey', 'propsSource']), + }, +}; +</script> + +<template> + <div> + <jira-issues-fields + :key="`${currentKey}-jira-issues-fields`" + v-bind="propsSource.jiraIssuesProps" + :is-validated="isValidated" + @request-jira-issue-types="$emit('request-jira-issue-types')" + /> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue new file mode 100644 index 00000000000..f36d3b1fbda --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue @@ -0,0 +1,32 @@ +<script> +import { mapGetters } from 'vuex'; + +import JiraTriggerFields from '../jira_trigger_fields.vue'; + +export default { + name: 'IntegrationSectionJiraTrigger', + components: { + JiraTriggerFields, + }, + props: { + isValidated: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['currentKey', 'propsSource']), + }, +}; +</script> + +<template> + <div> + <jira-trigger-fields + :key="`${currentKey}-jira-trigger-fields`" + v-bind="propsSource.triggerFieldsProps" + :is-validated="isValidated" + /> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 433fe21ad76..92042a5c981 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -1,6 +1,5 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; -import { startCase } from 'lodash'; import { mapGetters } from 'vuex'; import { __ } from '~/locale'; @@ -45,7 +44,6 @@ export default { fieldName(name) { return `service[${name}]`; }, - startCase, }, }; </script> @@ -58,10 +56,10 @@ export default { data-testid="trigger-fields-group" > <div id="trigger-fields" class="gl-pt-3"> - <gl-form-group v-for="event in events" :key="event.title" :description="event.description"> + <gl-form-group v-for="event in events" :key="event.name" :description="event.description"> <input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" /> <gl-form-checkbox v-model="event.value" :disabled="isInheriting"> - {{ startCase(event.title) }} + {{ event.title }} </gl-form-checkbox> <gl-form-input v-if="event.field" diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index fbda8c1e3d0..3e58dd0be99 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -22,6 +22,7 @@ function parseDatasetToProps(data) { editProjectPath, learnMorePath, triggerEvents, + sections, fields, inheritFromId, integrationLevel, @@ -38,6 +39,7 @@ function parseDatasetToProps(data) { const { showActive, activated, + activateDisabled, editable, canTest, commitEvents, @@ -53,6 +55,7 @@ function parseDatasetToProps(data) { return { initialActivated: activated, showActive, + activateDisabled, type, cancelPath, editable, @@ -81,6 +84,7 @@ function parseDatasetToProps(data) { }, learnMorePath, triggerEvents: JSON.parse(triggerEvents), + sections: JSON.parse(sections, { deep: true }), fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }), inheritFromId: parseInt(inheritFromId, 10), integrationLevel, @@ -114,13 +118,13 @@ export default function initIntegrationSettingsForm() { return new Vue({ el: customSettingsEl, + name: 'IntegrationEditRoot', store: createStore(initialState), + provide: { + helpHtml, + }, render(createElement) { - return createElement(IntegrationForm, { - props: { - helpHtml, - }, - }); + return createElement(IntegrationForm); }, }); } diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js index b79132128cc..b0adc444395 100644 --- a/app/assets/javascripts/integrations/edit/store/getters.js +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -1,5 +1,10 @@ +import { integrationLevels } from '~/integrations/constants'; + export const isInheriting = (state) => (state.defaultState === null ? false : !state.override); +export const isProjectLevel = (state) => + state.customState.integrationLevel === integrationLevels.PROJECT; + export const propsSource = (state, getters) => getters.isInheriting ? state.defaultState : state.customState; diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue index c08a4d75c59..424a9d3fabd 100644 --- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -28,7 +28,12 @@ export default { </script> <template> - <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal"> + <gl-button + :class="classes" + data-qa-selector="invite_a_group_button" + data-test-id="invite-group-button" + @click="openModal" + > {{ displayText }} </gl-button> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 6598000c464..f266d978ffa 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -4,6 +4,7 @@ import Api from '~/api'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants'; import eventHub from '../event_hub'; +import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import GroupSelect from './group_select.vue'; import InviteModalBase from './invite_modal_base.vue'; @@ -55,6 +56,8 @@ export default { }, data() { return { + invalidFeedbackMessage: '', + isLoading: false, modalId: uniqueId('invite-groups-modal-'), groupToBeSharedWith: {}, }; @@ -83,13 +86,19 @@ export default { }); }, methods: { + showInvalidFeedbackMessage(response) { + this.invalidFeedbackMessage = getInvalidFeedbackMessage(response); + }, openModal() { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, - sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) { + sendInvite({ accessLevel, expiresAt }) { + this.invalidFeedbackMessage = ''; + this.isLoading = true; + const apiShareWithGroup = this.isProject ? Api.projectShareWithGroup.bind(Api) : Api.groupShareWithGroup.bind(Api); @@ -101,18 +110,27 @@ export default { expires_at: expiresAt, }) .then(() => { - onSuccess(); this.showSuccessMessage(); }) - .catch(onError); + .catch((e) => { + this.showInvalidFeedbackMessage(e); + }) + .finally(() => { + this.isLoading = false; + }); }, resetFields() { + this.invalidFeedbackMessage = ''; + this.isLoading = false; this.groupToBeSharedWith = {}; }, showSuccessMessage() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.closeModal(); }, + clearValidation() { + this.invalidFeedbackMessage = ''; + }, }, labels: GROUP_MODAL_LABELS, }; @@ -129,10 +147,12 @@ export default { :label-intro-text="labelIntroText" :label-search-field="$options.labels.searchField" :submit-disabled="inviteDisabled" + :invalid-feedback-message="invalidFeedbackMessage" + :is-loading="isLoading" @reset="resetFields" @submit="sendInvite" > - <template #select="{ clearValidation }"> + <template #select> <group-select v-model="groupToBeSharedWith" :access-levels="accessLevels" diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index 6c0fc5caf26..be48a58d838 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -21,6 +21,7 @@ import { } from '../constants'; import eventHub from '../event_hub'; import { responseMessageFromSuccess } from '../utils/response_message_parser'; +import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import ModalConfetti from './confetti.vue'; import MembersTokenSelect from './members_token_select.vue'; @@ -84,6 +85,8 @@ export default { }, data() { return { + invalidFeedbackMessage: '', + isLoading: false, modalId: uniqueId('invite-members-modal-'), newUsersToInvite: [], selectedTasksToBeDone: [], @@ -152,6 +155,9 @@ export default { } }, methods: { + showInvalidFeedbackMessage(response) { + this.invalidFeedbackMessage = getInvalidFeedbackMessage(response); + }, partitionNewUsersToInvite() { const [usersToInviteByEmail, usersToAddById] = partition( this.newUsersToInvite, @@ -176,7 +182,10 @@ export default { const tracking = new ExperimentTracking(experimentName); tracking.event(eventName); }, - sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) { + sendInvite({ accessLevel, expiresAt }) { + this.isLoading = true; + this.invalidFeedbackMessage = ''; + const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; const baseData = { @@ -220,19 +229,17 @@ export default { const message = responseMessageFromSuccess(responses); if (message) { - onError({ - response: { - data: { - message, - }, - }, + this.showInvalidFeedbackMessage({ + response: { data: { message } }, }); } else { - onSuccess(); this.showSuccessMessage(); } }) - .catch(onError); + .catch((e) => this.showInvalidFeedbackMessage(e)) + .finally(() => { + this.isLoading = false; + }); }, trackinviteMembersForTask() { const label = 'selected_tasks_to_be_done'; @@ -241,6 +248,8 @@ export default { tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, resetFields() { + this.isLoading = false; + this.invalidFeedbackMessage = ''; this.newUsersToInvite = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; @@ -260,6 +269,9 @@ export default { onAccessLevelUpdate(val) { this.selectedAccessLevel = val; }, + clearValidation() { + this.invalidFeedbackMessage = ''; + }, }, labels: MEMBER_MODAL_LABELS, }; @@ -276,6 +288,8 @@ export default { :label-search-field="$options.labels.searchField" :form-group-description="$options.labels.placeHolder" :submit-disabled="inviteDisabled" + :invalid-feedback-message="invalidFeedbackMessage" + :is-loading="isLoading" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -288,7 +302,7 @@ export default { <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span> <modal-confetti v-if="isCelebration" /> </template> - <template #select="{ clearValidation, validationState, labelId }"> + <template #select="{ validationState, labelId }"> <members-token-select v-model="newUsersToInvite" class="gl-mb-2" diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index fc00f5b9343..bafbe94b8bd 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -10,19 +10,27 @@ import { GlButton, GlFormInput, } from '@gitlab/ui'; -import { unescape } from 'lodash'; -import { sanitize } from '~/lib/dompurify'; import { sprintf } from '~/locale'; +import ContentTransition from '~/vue_shared/components/content_transition.vue'; import { ACCESS_LEVEL, ACCESS_EXPIRE_DATE, - INVALID_FEEDBACK_MESSAGE_DEFAULT, READ_MORE_TEXT, INVITE_BUTTON_TEXT, CANCEL_BUTTON_TEXT, HEADER_CLOSE_LABEL, } from '../constants'; -import { responseMessageFromError } from '../utils/response_message_parser'; + +const DEFAULT_SLOT = 'default'; +const DEFAULT_SLOTS = [ + { + key: DEFAULT_SLOT, + attributes: { + class: 'invite-modal-content', + 'data-testid': 'invite-modal-initial-content', + }, + }, +]; export default { components: { @@ -35,6 +43,7 @@ export default { GlSprintf, GlButton, GlFormInput, + ContentTransition, }, inheritAttrs: false, props: { @@ -80,14 +89,37 @@ export default { required: false, default: false, }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + invalidFeedbackMessage: { + type: String, + required: false, + default: '', + }, + submitButtonText: { + type: String, + required: false, + default: INVITE_BUTTON_TEXT, + }, + currentSlot: { + type: String, + required: false, + default: DEFAULT_SLOT, + }, + extraSlots: { + type: Array, + required: false, + default: () => [], + }, }, data() { // Be sure to check out reset! return { - invalidFeedbackMessage: '', selectedAccessLevel: this.defaultAccessLevel, selectedDate: undefined, - isLoading: false, minDate: new Date(), }; }, @@ -106,6 +138,9 @@ export default { (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), ); }, + contentSlots() { + return [...DEFAULT_SLOTS, ...(this.extraSlots || [])]; + }, }, watch: { selectedAccessLevel: { @@ -116,16 +151,9 @@ export default { }, }, methods: { - showInvalidFeedbackMessage(response) { - const message = this.unescapeMsg(responseMessageFromError(response)); - - this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT; - }, reset() { // This component isn't necessarily disposed, // so we might need to reset it's state. - this.isLoading = false; - this.invalidFeedbackMessage = ''; this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; @@ -135,33 +163,15 @@ export default { this.reset(); this.$refs.modal.hide(); }, - clearValidation() { - this.invalidFeedbackMessage = ''; - }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, submit() { - this.isLoading = true; - this.invalidFeedbackMessage = ''; - this.$emit('submit', { - onSuccess: () => { - this.isLoading = false; - }, - onError: (...args) => { - this.isLoading = false; - this.showInvalidFeedbackMessage(...args); - }, - data: { - accessLevel: this.selectedAccessLevel, - expiresAt: this.selectedDate, - }, + accessLevel: this.selectedAccessLevel, + expiresAt: this.selectedDate, }); }, - unescapeMsg(message) { - return unescape(sanitize(message, { ALLOWED_TAGS: [] })); - }, }, HEADER_CLOSE_LABEL, ACCESS_EXPIRE_DATE, @@ -169,6 +179,7 @@ export default { READ_MORE_TEXT, INVITE_BUTTON_TEXT, CANCEL_BUTTON_TEXT, + DEFAULT_SLOT, }; </script> @@ -185,91 +196,105 @@ export default { @close="reset" @hide="reset" > - <div class="gl-display-flex" data-testid="modal-base-intro-text"> - <slot name="intro-text-before"></slot> - <p> - <gl-sprintf :message="introText"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <slot name="intro-text-after"></slot> - </div> - - <gl-form-group - :invalid-feedback="invalidFeedbackMessage" - :state="validationState" - :description="formGroupDescription" - data-testid="members-form-group" + <content-transition + class="gl-display-grid" + transition-name="invite-modal-transition" + :slots="contentSlots" + :current-slot="currentSlot" > - <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> - <slot - name="select" - v-bind="{ clearValidation, validationState, labelId: selectLabelId }" - ></slot> - </gl-form-group> + <template #[$options.DEFAULT_SLOT]> + <div class="gl-display-flex" data-testid="modal-base-intro-text"> + <slot name="intro-text-before"></slot> + <p> + <gl-sprintf :message="introText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <slot name="intro-text-after"></slot> + </div> - <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - class="gl-shadow-none gl-w-full" - data-qa-selector="access_level_dropdown" - v-bind="$attrs" - :text="selectedRoleName" - > - <template v-for="(key, item) in accessLevels"> - <gl-dropdown-item - :key="key" - active-class="is-active" - is-check-item - :is-checked="key === selectedAccessLevel" - @click="changeSelectedItem(key)" - > - <div>{{ item }}</div> - </gl-dropdown-item> - </template> - </gl-dropdown> - </div> + <gl-form-group + :invalid-feedback="invalidFeedbackMessage" + :state="validationState" + :description="formGroupDescription" + data-testid="members-form-group" + > + <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> + <slot name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot> + </gl-form-group> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-sprintf :message="$options.READ_MORE_TEXT"> - <template #link="{ content }"> - <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> + <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + class="gl-shadow-none gl-w-full" + data-qa-selector="access_level_dropdown" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + is-check-item + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> - <label class="gl-mt-5 gl-display-block" for="expires_at">{{ - $options.ACCESS_EXPIRE_DATE - }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> - <gl-datepicker - v-model="selectedDate" - class="gl-display-inline!" - :min-date="minDate" - :target="null" - > - <template #default="{ formattedDate }"> - <gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" /> - </template> - </gl-datepicker> - </div> - <slot name="form-after"></slot> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-sprintf :message="$options.READ_MORE_TEXT"> + <template #link="{ content }"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + <label class="gl-mt-5 gl-display-block" for="expires_at">{{ + $options.ACCESS_EXPIRE_DATE + }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-datepicker + v-model="selectedDate" + class="gl-display-inline!" + :min-date="minDate" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input + class="gl-w-full" + :value="formattedDate" + :placeholder="__(`YYYY-MM-DD`)" + /> + </template> + </gl-datepicker> + </div> + <slot name="form-after"></slot> + </template> + <template v-for="{ key } in extraSlots" #[key]> + <slot :name="key"></slot> + </template> + </content-transition> <template #modal-footer> - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.CANCEL_BUTTON_TEXT }} - </gl-button> + <slot name="cancel-button"> + <gl-button data-testid="cancel-button" @click="closeModal"> + {{ $options.CANCEL_BUTTON_TEXT }} + </gl-button> + </slot> <gl-button :disabled="submitDisabled" :loading="isLoading" - variant="success" + variant="confirm" data-qa-selector="invite_button" data-testid="invite-button" @click="submit" > - {{ $options.INVITE_BUTTON_TEXT }} + {{ submitButtonText }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/invite_members/init_invite_members_form.js b/app/assets/javascripts/invite_members/init_invite_members_form.js deleted file mode 100644 index 5f8688755ba..00000000000 --- a/app/assets/javascripts/invite_members/init_invite_members_form.js +++ /dev/null @@ -1,7 +0,0 @@ -import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; - -// This is only used when `invite_members_group_modal` feature flag is disabled. -// This file can be removed when `invite_members_group_modal` feature flag is removed -export default () => { - disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); -}; diff --git a/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js b/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js new file mode 100644 index 00000000000..62f66d009dc --- /dev/null +++ b/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js @@ -0,0 +1,12 @@ +import { unescape } from 'lodash'; +import { sanitize } from '~/lib/dompurify'; +import { INVALID_FEEDBACK_MESSAGE_DEFAULT } from '../constants'; +import { responseMessageFromError } from './response_message_parser'; + +const unescapeMsg = (message) => unescape(sanitize(message, { ALLOWED_TAGS: [] })); + +export const getInvalidFeedbackMessage = (response) => { + const message = unescapeMsg(responseMessageFromError(response)); + + return message || INVALID_FEEDBACK_MESSAGE_DEFAULT; +}; diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index 512fa6f8c68..fcebae3af71 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -65,7 +65,6 @@ export default { const body = sprintf(__('Enter the %{name} description'), { name: this.issuableName, }); - // eslint-disable-next-line @gitlab/require-i18n-strings return `mailto:${this.email}?subject=${subject}&body=${body}`; }, }, diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index a3752c7043c..247f8dd0bd6 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -10,6 +10,7 @@ import ISetter from '~/filtered_search/droplab/plugins/input_setter'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; // Todo: Remove this when fixing issue in input_setter plugin const InputSetter = { ...ISetter }; @@ -171,12 +172,21 @@ export default class CreateMergeRequestDropdown { this.isCreatingMergeRequest = true; return this.createBranch().then(() => { - window.location.href = canCreateConfidentialMergeRequest() + let path = canCreateConfidentialMergeRequest() ? this.createMrPath.replace( this.projectPath, confidentialMergeRequestState.selectedProject.pathWithNamespace, ) : this.createMrPath; + path = mergeUrlParams( + { + 'merge_request[target_branch]': this.refInput.value, + 'merge_request[source_branch]': this.branchInput.value, + }, + path, + ); + + window.location.href = path; }); }); } 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 3866a7b3305..a532fa5b771 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -39,8 +39,11 @@ import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/con import { CREATED_DESC, i18n, + ISSUE_REFERENCE, MAX_LIST_SIZE, PAGE_SIZE, + PARAM_PAGE_AFTER, + PARAM_PAGE_BEFORE, PARAM_STATE, RELATIVE_POSITION_ASC, TOKEN_TYPE_ASSIGNEE, @@ -134,6 +137,8 @@ export default { }, }, data() { + const pageAfter = getParameterByName(PARAM_PAGE_AFTER); + const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); const state = getParameterByName(PARAM_STATE); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; const dashboardSortKey = getSortKey(this.initialSort); @@ -165,7 +170,7 @@ export default { issuesCounts: {}, issuesError: null, pageInfo: {}, - pageParams: getInitialPageParams(sortKey), + pageParams: getInitialPageParams(sortKey, pageAfter, pageBefore), showBulkEditSidebar: false, sortKey, state: state || IssuableStates.Opened, @@ -219,11 +224,13 @@ export default { }, computed: { queryVariables() { + const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery); return { fullPath: this.fullPath, + iid: isIidSearch ? this.searchQuery.slice(1) : undefined, isProject: this.isProject, isSignedIn: this.isSignedIn, - search: this.searchQuery, + search: isIidSearch ? undefined : this.searchQuery, sort: this.sortKey, state: this.state, ...this.pageParams, @@ -234,7 +241,12 @@ export default { return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; }, hasSearch() { - return this.searchQuery || Object.keys(this.urlFilterParams).length; + return ( + this.searchQuery || + Object.keys(this.urlFilterParams).length || + this.pageParams.afterCursor || + this.pageParams.beforeCursor + ); }, isBulkEditButtonDisabled() { return this.showBulkEditSidebar || !this.issues.length; @@ -391,6 +403,8 @@ export default { }, urlParams() { return { + page_after: this.pageParams.afterCursor, + page_before: this.pageParams.beforeCursor, search: this.searchQuery, sort: urlSortParams[this.sortKey], state: this.state, diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 284167a933f..4b07a078512 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -52,20 +52,15 @@ export const i18n = { upvotes: __('Upvotes'), }; +export const ISSUE_REFERENCE = /^#\d+$/; export const MAX_LIST_SIZE = 10; export const PAGE_SIZE = 20; export const PAGE_SIZE_MANUAL = 100; +export const PARAM_PAGE_AFTER = 'page_after'; +export const PARAM_PAGE_BEFORE = 'page_before'; export const PARAM_STATE = 'state'; export const RELATIVE_POSITION = 'relative_position'; -export const defaultPageSizeParams = { - firstPageSize: PAGE_SIZE, -}; - -export const largePageSizeParams = { - firstPageSize: PAGE_SIZE_MANUAL, -}; - export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; export const CREATED_ASC = 'CREATED_ASC'; diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index be8deb3fe97..529262d2162 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -5,6 +5,7 @@ query getIssues( $isProject: Boolean = false $isSignedIn: Boolean = false $fullPath: ID! + $iid: String $search: String $sort: IssueSort $state: IssuableState @@ -29,6 +30,7 @@ query getIssues( id issues( includeSubgroups: true + iid: $iid search: $search sort: $sort state: $state @@ -59,6 +61,7 @@ query getIssues( project(fullPath: $fullPath) @include(if: $isProject) { id issues( + iid: $iid search: $search sort: $sort state: $state diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql index 1a345fd2877..58e7ce32e7c 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql @@ -1,6 +1,7 @@ query getIssuesCount( $isProject: Boolean = false $fullPath: ID! + $iid: String $search: String $assigneeId: String $assigneeUsernames: [String!] @@ -20,6 +21,7 @@ query getIssuesCount( openedIssues: issues( includeSubgroups: true state: opened + iid: $iid search: $search assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames @@ -37,6 +39,7 @@ query getIssuesCount( closedIssues: issues( includeSubgroups: true state: closed + iid: $iid search: $search assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames @@ -54,6 +57,7 @@ query getIssuesCount( allIssues: issues( includeSubgroups: true state: all + iid: $iid search: $search assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames @@ -73,6 +77,7 @@ query getIssuesCount( id openedIssues: issues( state: opened + iid: $iid search: $search assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames @@ -91,6 +96,7 @@ query getIssuesCount( } closedIssues: issues( state: closed + iid: $iid search: $search assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames @@ -109,6 +115,7 @@ query getIssuesCount( } allIssues: issues( state: all + iid: $iid search: $search assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames 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 92517ad35d0..46b48e4e41c 100644 --- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql @@ -3,7 +3,7 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { id - groupMembers(search: $search) { + groupMembers(search: $search, relations: [DIRECT, INHERITED, SHARED_FROM_GROUPS]) { nodes { id user { @@ -14,7 +14,7 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) } project(fullPath: $fullPath) @include(if: $isProject) { id - projectMembers(search: $search) { + projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { nodes { id user { diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 6322968b3f0..4b77bd9bc5f 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -10,16 +10,16 @@ import { BLOCKING_ISSUES_DESC, CREATED_ASC, CREATED_DESC, - defaultPageSizeParams, DUE_DATE_ASC, DUE_DATE_DESC, filters, LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, - largePageSizeParams, MILESTONE_DUE_ASC, MILESTONE_DUE_DESC, NORMAL_FILTER, + PAGE_SIZE, + PAGE_SIZE_MANUAL, POPULARITY_ASC, POPULARITY_DESC, PRIORITY_ASC, @@ -43,8 +43,11 @@ import { WEIGHT_DESC, } from './constants'; -export const getInitialPageParams = (sortKey) => - sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams; +export const getInitialPageParams = (sortKey, afterCursor, beforeCursor) => ({ + firstPageSize: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, + afterCursor, + beforeCursor, +}); export const getSortKey = (sort) => Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort); diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue index 26862346b86..47b09bd6aa0 100644 --- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -31,7 +31,10 @@ export default { computed: { actionPrimary() { return { - attributes: { variant: 'danger' }, + attributes: { + variant: 'danger', + 'data-qa-selector': 'confirm_delete_issue_button', + }, text: this.title, }; }, diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index eeccf886b65..68ed7bb4062 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -10,7 +10,9 @@ import $ from 'jquery'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import TaskList from '~/task_list'; +import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import animateMixin from '../mixins/animate'; @@ -24,8 +26,9 @@ export default { GlPopover, CreateWorkItem, GlButton, + WorkItemDetailModal, }, - mixins: [animateMixin, glFeatureFlagMixin()], + mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()], props: { canUpdate: { type: Boolean, @@ -68,9 +71,13 @@ export default { initialUpdate: true, taskButtons: [], activeTask: {}, + workItemId: null, }; }, computed: { + showWorkItemDetailModal() { + return Boolean(this.workItemId); + }, workItemsEnabled() { return this.glFeatures.workItems; }, @@ -194,7 +201,13 @@ export default { closeCreateTaskModal() { this.$refs.modal.hide(); }, - handleCreateTask(title) { + closeWorkItemDetailModal() { + this.workItemId = null; + }, + handleWorkItemDetailModalError(message) { + createFlash({ message }); + }, + handleCreateTask({ id, title, type }) { const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement; const taskBadge = document.createElement('span'); taskBadge.innerHTML = ` @@ -204,12 +217,28 @@ export default { <span class="badge badge-info badge-pill gl-badge sm gl-mr-1"> ${__('Task')} </span> - <a href="#">${title}</a> `; + const button = this.createWorkItemDetailButton(id, title, type); + taskBadge.append(button); + listItem.insertBefore(taskBadge, listItem.lastChild); listItem.removeChild(listItem.lastChild); this.closeCreateTaskModal(); }, + createWorkItemDetailButton(id, title, type) { + const button = document.createElement('button'); + button.addEventListener('click', () => { + this.workItemId = id; + this.track('viewed_work_item_from_modal', { + category: 'workItems:show', + label: 'work_item_view', + property: `type_${type}`, + }); + }); + button.classList.add('btn-link'); + button.innerText = title; + return button; + }, focusButton() { this.$refs.convertButton[0].$el.focus(); }, @@ -262,6 +291,12 @@ export default { @onCreate="handleCreateTask" /> </gl-modal> + <work-item-detail-modal + :visible="showWorkItemDetailModal" + :work-item-id="workItemId" + @close="closeWorkItemDetailModal" + @error="handleWorkItemDetailModalError" + /> <template v-if="workItemsEnabled"> <gl-popover v-for="item in taskButtons" diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue index 9ce49b65a1a..d528641dcb6 100644 --- a/app/assets/javascripts/issues/show/components/fields/description_template.vue +++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue @@ -68,7 +68,10 @@ export default { data-toggle="dropdown" > <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span> - <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" /> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500 dropdown-menu-toggle-icon" + /> </button> <div class="dropdown-menu dropdown-select"> <div class="dropdown-title gl-display-flex gl-justify-content-center"> diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 8ba08472ea0..adf449aca7b 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -128,13 +128,21 @@ export default { }); }, newIssueTypeText() { - return sprintf(__('New %{issueType}'), { issueType: this.issueType }); + return sprintf(__('New related %{issueType}'), { issueType: this.issueType }); }, showToggleIssueStateButton() { const canClose = !this.isClosed && this.canUpdateIssue; const canReopen = this.isClosed && this.canReopenIssue; return canClose || canReopen; }, + hasDesktopDropdown() { + return ( + this.canCreateIssue || this.canPromoteToEpic || !this.isIssueAuthor || this.canReportSpam + ); + }, + hasMobileDropdown() { + return this.hasDesktopDropdown || this.showToggleIssueStateButton; + }, }, created() { eventHub.$on('toggle.issuable.state', this.toggleIssueState); @@ -223,10 +231,12 @@ export default { <template> <div class="detail-page-header-actions gl-display-flex"> <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" > <gl-dropdown-item @@ -276,11 +286,14 @@ export default { </gl-button> <gl-dropdown + v-if="hasDesktopDropdown" class="gl-display-none gl-sm-display-inline-flex! gl-ml-3" icon="ellipsis_v" category="tertiary" + data-qa-selector="issue_actions_ellipsis_dropdown" :text="dropdownText" :text-sr-only="true" + data-testid="desktop-dropdown" no-caret right > @@ -311,6 +324,7 @@ export default { <gl-dropdown-item v-gl-modal="$options.deleteModalId" variant="danger" + data-qa-selector="delete_issue_button" @click="track('click_dropdown')" > {{ deleteButtonText }} diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 4790062ab7d..04ddc7f3501 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -5,6 +5,7 @@ import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DescriptionComponent from '../description.vue'; import getAlert from './graphql/queries/get_alert.graphql'; import HighlightBar from './highlight_bar.vue'; @@ -17,7 +18,10 @@ export default { GlTabs, HighlightBar, MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'), + TimelineTab: () => + import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'), }, + mixins: [glFeatureFlagsMixin()], inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], apollo: { alert: { @@ -47,6 +51,9 @@ export default { loading() { return this.$apollo.queries.alert.loading; }, + incidentTabEnabled() { + return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimelineEventTab; + }, }, mounted() { this.trackPageViews(); @@ -76,6 +83,7 @@ export default { > <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> + <timeline-tab v-if="incidentTabEnabled" data-testid="timeline-events-tab" /> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 5e92211685a..1982147e454 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -68,7 +68,7 @@ export default { <template> <div class="title-container"> - <h2 + <h1 v-safe-html="titleHtml" :class="{ 'issue-realtime-pre-pulse': preAnimation, @@ -76,7 +76,7 @@ export default { }" class="title qa-title" dir="auto" - ></h2> + ></h1> <gl-button v-if="showInlineEditButton && canUpdate" v-gl-tooltip.bottom diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index f5c71f9691f..c9af5d9b4a7 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -77,9 +77,7 @@ export function initIssueApp(issueData, store) { const { fullPath } = el.dataset; - if (gon?.features?.fixCommentScroll) { - scrollToTargetOnResize(); - } + scrollToTargetOnResize(); bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 905e242e977..afdb414e82c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -3,6 +3,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapState, mapMutations } from 'vuex'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; +import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants'; import { SET_ALERT } from '../store/mutation_types'; import SignInPage from '../pages/sign_in.vue'; import SubscriptionsPage from '../pages/subscriptions.vue'; @@ -28,6 +29,11 @@ export default { default: [], }, }, + data() { + return { + user: null, + }; + }, computed: { ...mapState(['alert']), shouldShowAlert() { @@ -37,7 +43,7 @@ export default { return !isEmpty(this.subscriptions); }, userSignedIn() { - return Boolean(!this.usersPath); + return Boolean(!this.usersPath || this.user); }, }, created() { @@ -51,6 +57,15 @@ export default { const { linkUrl, title, message, variant } = retrieveAlert() || {}; this.setAlert({ linkUrl, title, message, variant }); }, + onSignInOauth(user) { + this.user = user; + }, + onSignInError() { + this.setAlert({ + message: I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE, + variant: 'danger', + }); + }, }, }; </script> @@ -78,11 +93,16 @@ export default { </template> </gl-alert> - <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" /> + <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" :user="user" /> <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> - <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" /> + <sign-in-page + v-if="!userSignedIn" + :has-subscriptions="hasSubscriptions" + @sign-in-oauth="onSignInOauth" + @error="onSignInError" + /> <subscriptions-page v-else :has-subscriptions="hasSubscriptions" /> </div> </div> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue index 627abcdd4a0..ec718d5b3ca 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue @@ -1,7 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; -import { s__ } from '~/locale'; +import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants'; export default { components: { @@ -27,7 +27,7 @@ export default { }, }, i18n: { - defaultButtonText: s__('Integrations|Sign in to GitLab'), + defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, }, }; </script> 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 new file mode 100644 index 00000000000..d7ec909cb28 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -0,0 +1,124 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { + I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, + OAUTH_WINDOW_OPTIONS, + PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM, +} from '~/jira_connect/subscriptions/constants'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import AccessorUtilities from '~/lib/utils/accessor'; + +import { createCodeVerifier, createCodeChallenge } from '../pkce'; + +export default { + components: { + GlButton, + }, + inject: ['oauthMetadata'], + data() { + return { + token: null, + loading: false, + codeVerifier: null, + canUseCrypto: AccessorUtilities.canUseCrypto(), + }; + }, + mounted() { + window.addEventListener('message', this.handleWindowMessage); + }, + beforeDestroy() { + window.removeEventListener('message', this.handleWindowMessage); + }, + methods: { + async startOAuthFlow() { + this.loading = true; + + // Generate state necessary for PKCE OAuth flow + this.codeVerifier = createCodeVerifier(); + const codeChallenge = await createCodeChallenge(this.codeVerifier); + + // Build the initial OAuth authorization URL + const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata; + const oauthAuthorizeURLWithChallenge = setUrlParams( + { + code_challenge: codeChallenge, + code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short, + }, + oauthAuthorizeURL, + ); + + window.open( + oauthAuthorizeURLWithChallenge, + this.$options.i18n.defaultButtonText, + OAUTH_WINDOW_OPTIONS, + ); + }, + async handleWindowMessage(event) { + if (window.origin !== event.origin) { + this.loading = false; + this.handleError(); + return; + } + + // Verify that OAuth state isn't altered. + const state = event.data?.state; + if (state !== this.oauthMetadata.state) { + this.loading = false; + this.handleError(); + return; + } + + // Request access token and load the authenticated user. + const code = event.data?.code; + try { + const accessToken = await this.getOAuthToken(code); + await this.loadUser(accessToken); + } catch (e) { + this.handleError(); + } finally { + this.loading = false; + } + }, + handleError() { + this.$emit('error'); + }, + async getOAuthToken(code) { + const { + oauth_token_payload: oauthTokenPayload, + oauth_token_url: oauthTokenURL, + } = this.oauthMetadata; + const { data } = await axios.post(oauthTokenURL, { + ...oauthTokenPayload, + code, + code_verifier: this.codeVerifier, + }); + + return data.access_token; + }, + async loadUser(accessToken) { + const { data } = await axios.get('/api/v4/user', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + this.$emit('sign-in', data); + }, + }, + i18n: { + defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, + }, +}; +</script> +<template> + <gl-button + category="primary" + variant="info" + :loading="loading" + :disabled="!canUseCrypto" + @click="startOAuthFlow" + > + <slot> + {{ $options.i18n.defaultButtonText }} + </slot> + </gl-button> +</template> 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 fad3d2616d8..5e2c83aff65 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue @@ -25,6 +25,11 @@ export default { type: Boolean, required: true, }, + user: { + type: Object, + required: false, + default: null, + }, }, data() { return { @@ -32,8 +37,19 @@ export default { }; }, computed: { + gitlabUserName() { + return gon.current_username ?? this.user?.username; + }, gitlabUserHandle() { - return `@${gon.current_username}`; + return this.gitlabUserName ? `@${this.gitlabUserName}` : undefined; + }, + gitlabUserLink() { + return this.gitlabUserPath ?? `${gon.relative_root_url}/${this.gitlabUserName}`; + }, + signedInText() { + return this.gitlabUserHandle + ? this.$options.i18n.signedInAsUserText + : this.$options.i18n.signedInText; }, }, async created() { @@ -42,14 +58,15 @@ export default { i18n: { signInText: __('Sign in to GitLab'), signedInAsUserText: __('Signed in to GitLab as %{user_link}'), + signedInText: __('Signed in to GitLab'), }, }; </script> <template> <div class="jira-connect-user gl-font-base"> - <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText"> + <gl-sprintf v-if="userSignedIn" :message="signedInText"> <template #user_link> - <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank"> + <gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank"> {{ gitlabUserHandle }} </gl-link> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 2a65b7bc1fa..d30ebdbb487 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -1,5 +1,26 @@ +import { s__ } from '~/locale'; + export const DEFAULT_GROUPS_PER_PAGE = 10; export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; 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_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.'); + +const OAUTH_WINDOW_SIZE = 800; +export const OAUTH_WINDOW_OPTIONS = [ + 'resizable=yes', + 'scrollbars=yes', + 'status=yes', + `width=${OAUTH_WINDOW_SIZE}`, + `height=${OAUTH_WINDOW_SIZE}`, + `left=${window.screen.width / 2 - OAUTH_WINDOW_SIZE / 2}`, + `top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`, +].join(','); + +export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = { + long: 'SHA-256', + short: 'S256', +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index cd1fc1d4455..320f0f8aa6c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -21,7 +21,14 @@ export function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset; + const { + groupsPath, + subscriptions, + subscriptionsPath, + usersPath, + gitlabUserPath, + oauthMetadata, + } = el.dataset; sizeToParent(); return new Vue({ @@ -33,6 +40,7 @@ export function initJiraConnect() { subscriptionsPath, usersPath, gitlabUserPath, + oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null, }, render(createElement) { return createElement(JiraConnectApp); diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue index 2bce5afc72b..a24ee33b723 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue @@ -1,14 +1,17 @@ <script> import { s__ } from '~/locale'; + +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SubscriptionsList from '../components/subscriptions_list.vue'; -import SignInButton from '../components/sign_in_button.vue'; export default { name: 'SignInPage', components: { SubscriptionsList, - SignInButton, + SignInLegacyButton: () => import('../components/sign_in_legacy_button.vue'), + SignInOauthButton: () => import('../components/sign_in_oauth_button.vue'), }, + mixins: [glFeatureFlagMixin()], inject: ['usersPath'], props: { hasSubscriptions: { @@ -16,25 +19,47 @@ export default { required: true, }, }, + computed: { + useSignInOauthButton() { + return this.glFeatures.jiraConnectOauth; + }, + }, i18n: { - signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), + signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), signInText: s__('JiraService|Sign in to GitLab.com to get started.'), }, + methods: { + onSignInError() { + this.$emit('error'); + }, + }, }; </script> <template> <div v-if="hasSubscriptions"> <div class="gl-display-flex gl-justify-content-end"> - <sign-in-button :users-path="usersPath"> - {{ $options.i18n.signinButtonTextWithSubscriptions }} - </sign-in-button> + <sign-in-oauth-button + v-if="useSignInOauthButton" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + > + {{ $options.i18n.signInButtonTextWithSubscriptions }} + </sign-in-oauth-button> + <sign-in-legacy-button v-else :users-path="usersPath"> + {{ $options.i18n.signInButtonTextWithSubscriptions }} + </sign-in-legacy-button> </div> <subscriptions-list /> </div> <div v-else class="gl-text-center"> <p class="gl-mb-7">{{ $options.i18n.signInText }}</p> - <sign-in-button class="gl-mb-7" :users-path="usersPath" /> + <sign-in-oauth-button + v-if="useSignInOauthButton" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + /> + <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" /> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pkce.js b/app/assets/javascripts/jira_connect/subscriptions/pkce.js new file mode 100644 index 00000000000..18ea5cae860 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pkce.js @@ -0,0 +1,60 @@ +import { bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util'; +import { PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM } from './constants'; + +// PKCE codeverifier should have a maximum length of 128 characters. +// Using 96 bytes generates a string of 128 characters. +// RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 +export const CODE_VERIFIER_BYTES = 96; + +/** + * Generate a cryptographically random string. + * @param {Number} lengthBytes + * @returns {String} a random string + */ +function getRandomString(lengthBytes) { + // generate random values and load them into byteArray. + const byteArray = new Uint8Array(lengthBytes); + window.crypto.getRandomValues(byteArray); + + // Convert array to string + const randomString = bufferToBase64(byteArray); + return randomString; +} + +/** + * Creates a code verifier to be used for OAuth PKCE authentication. + * The code verifier has 128 characters. + * + * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + * @returns {String} code verifier + */ +export function createCodeVerifier() { + const verifier = getRandomString(CODE_VERIFIER_BYTES); + return base64ToBase64Url(verifier); +} + +/** + * Creates a code challenge for OAuth PKCE authentication. + * The code challenge is derived from the given [codeVerifier]. + * [codeVerifier] is tranformed in the following way (as per the RFC): + * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(codeVerifier))) + * + * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + * @param {String} codeVerifier + * @returns {String} code challenge + */ +export async function createCodeChallenge(codeVerifier) { + // Generate SHA-256 digest of the [codeVerifier] + const buffer = new TextEncoder().encode(codeVerifier); + const digestArrayBuffer = await window.crypto.subtle.digest( + PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.long, + buffer, + ); + + // Convert digest to a Base64URL-encoded string + const digestHash = bufferToBase64(digestArrayBuffer); + // Escape string to remove reserved charaters + const codeChallenge = base64ToBase64Url(digestHash); + + return codeChallenge; +} diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index fe4158a1bd1..85fe5ed7e26 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -3,7 +3,6 @@ import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; -import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { __, sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; @@ -33,7 +32,6 @@ export default { GlLoadingIcon, SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), GlAlert, - CodeQualityWalkthrough, }, directives: { SafeHtml, @@ -69,11 +67,6 @@ export default { required: false, default: null, }, - codeQualityHelpUrl: { - type: String, - required: false, - default: null, - }, }, computed: { ...mapState([ @@ -123,9 +116,6 @@ export default { return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; }, - shouldRenderCodeQualityWalkthrough() { - return this.job.status.group === 'failed-with-warnings'; - }, itemName() { return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); }, @@ -224,11 +214,6 @@ export default { > <div v-safe-html="job.callout_message"></div> </gl-alert> - <code-quality-walkthrough - v-if="shouldRenderCodeQualityWalkthrough" - step="troubleshoot_job" - :link="codeQualityHelpUrl" - /> </header> <!-- EO Header Section --> @@ -288,7 +273,6 @@ export default { 'sidebar-collapsed': !isSidebarOpen, 'has-archived-block': job.archived, }" - :erase-path="job.erase_path" :size="jobLogSize" :raw-path="job.raw_path" :is-scroll-bottom-disabled="isScrollBottomDisabled" @@ -325,6 +309,7 @@ export default { 'right-sidebar-expanded': isSidebarOpen, 'right-sidebar-collapsed': !isSidebarOpen, }" + :erase-path="job.erase_path" :artifact-help-url="artifactHelpUrl" data-testid="job-sidebar" /> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 8e35fd91481..eb6a284dfaf 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale'; export default { i18n: { - eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToTopButtonLabel: s__('Job|Scroll to top'), showRawButtonLabel: s__('Job|Show complete raw'), @@ -18,11 +17,6 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - erasePath: { - type: String, - required: false, - default: null, - }, size: { type: Number, required: true, @@ -97,20 +91,6 @@ export default { data-testid="job-raw-link-controller" icon="doc-text" /> - - <gl-button - v-if="erasePath" - v-gl-tooltip.body - :title="$options.i18n.eraseLogButtonLabel" - :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" - :data-confirm="__('Are you sure you want to erase this build?')" - class="gl-ml-3" - data-testid="job-log-erase-link" - data-confirm-btn-variant="danger" - data-method="post" - icon="remove" - /> <!-- eo links --> <!-- scroll buttons --> diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue index a43b3297d75..a7bf365d35c 100644 --- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { JOB_SIDEBAR } from '../constants'; @@ -10,7 +10,6 @@ export default { }, components: { GlButton, - GlLink, }, directives: { GlModal: GlModalDirective, @@ -37,9 +36,18 @@ export default { :aria-label="$options.i18n.retryLabel" category="primary" variant="confirm" - >{{ $options.i18n.retryLabel }}</gl-button - > - <gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow" - >{{ $options.i18n.retryLabel }} - </gl-link> + icon="retry" + data-testid="retry-job-button" + /> + + <gl-button + v-else + :href="href" + :aria-label="$options.i18n.retryLabel" + category="primary" + variant="confirm" + icon="retry" + data-method="post" + data-testid="retry-job-link" + /> </template> diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue index 3bb1f58573c..c72d488f844 100644 --- a/app/assets/javascripts/jobs/components/log/line_header.vue +++ b/app/assets/javascripts/jobs/components/log/line_header.vue @@ -43,7 +43,7 @@ export default { <template> <div - class="log-line collapsible-line d-flex justify-content-between ws-normal" + class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start" role="button" @click="handleOnClick" > diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 9aa1503c7c3..1b4c9ebdf7d 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,7 +1,8 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_SIDEBAR } from '../constants'; import ArtifactsBlock from './artifacts_block.vue'; @@ -18,10 +19,17 @@ export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; export default { name: 'JobSidebar', i18n: { + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), + eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), + cancelJobButtonLabel: s__('Job|Cancel'), + retryJobButtonLabel: s__('Job|Retry'), ...JOB_SIDEBAR, }, borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, components: { ArtifactsBlock, CommitBlock, @@ -41,6 +49,11 @@ export default { required: false, default: '', }, + erasePath: { + type: String, + required: false, + default: null, + }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -81,8 +94,24 @@ export default { </h4> </tooltip-on-truncate> <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-button + v-if="erasePath" + v-gl-tooltip.left + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="erasePath" + :data-confirm="$options.i18n.eraseLogConfirmText" + class="gl-mr-2" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> <job-sidebar-retry-button v-if="job.retry_path" + v-gl-tooltip.left + :title="$options.i18n.retryJobButtonLabel" + :aria-label="$options.i18n.retryJobButtonLabel" :category="retryButtonCategory" :href="job.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" @@ -92,12 +121,15 @@ export default { /> <gl-button v-if="job.cancel_path" + v-gl-tooltip.left + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" :href="job.cancel_path" + icon="cancel" data-method="post" data-testid="cancel-button" rel="nofollow" - >{{ $options.i18n.cancel }} - </gl-button> + /> </div> <gl-button diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 1780afd39e8..7c4811b2d6f 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -1,8 +1,12 @@ <script> -import { GlLink, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +import Mousetrap from 'mousetrap'; +import { s__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; +import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings'; export default { components: { @@ -11,6 +15,7 @@ export default { GlDropdown, GlDropdownItem, GlLink, + GlSprintf, }, props: { pipeline: { @@ -36,11 +41,43 @@ export default { isMergeRequestPipeline() { return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); }, + pipelineInfo() { + if (!this.hasRef) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id}'); + } else if (!this.isTriggeredByMergeRequest) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}'); + } else if (!this.isMergeRequestPipeline) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source}'); + } + + return s__( + 'Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}', + ); + }, + }, + mounted() { + Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), this.handleKeyboardCopy); + }, + beforeDestroy() { + Mousetrap.unbind(keysFor(MR_COPY_SOURCE_BRANCH_NAME)); }, methods: { onStageClick(stage) { this.$emit('requestSidebarStageDropdown', stage); }, + handleKeyboardCopy() { + let button; + + if (!this.hasRef) { + return; + } else if (!this.isTriggeredByMergeRequest) { + button = this.$refs['copy-source-ref-link']; + } else { + button = this.$refs['copy-source-branch-link']; + } + + clickCopyToClipboardButton(button.$el); + }, }, }; </script> @@ -48,54 +85,72 @@ export default { <div class="dropdown"> <div class="js-pipeline-info" data-testid="pipeline-info"> <ci-icon :status="pipeline.details.status" /> - - <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span> - <gl-link - :href="pipeline.path" - class="js-pipeline-path link-commit" - data-testid="pipeline-path" - data-qa-selector="pipeline_path" - >#{{ pipeline.id }}</gl-link - > - <template v-if="hasRef"> - {{ s__('Job|for') }} - - <template v-if="isTriggeredByMergeRequest"> + <gl-sprintf :message="pipelineInfo"> + <template #bold="{ content }"> + <span class="font-weight-bold">{{ content }}</span> + </template> + <template #id> + <gl-link + :href="pipeline.path" + class="js-pipeline-path link-commit" + data-testid="pipeline-path" + data-qa-selector="pipeline_path" + >#{{ pipeline.id }}</gl-link + > + </template> + <template #mrId> <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name" data-testid="mr-link" >!{{ pipeline.merge_request.iid }}</gl-link > - {{ s__('Job|with') }} + </template> + <template #ref> + <gl-link + :href="pipeline.ref.path" + class="link-commit ref-name" + data-testid="source-ref-link" + >{{ pipeline.ref.name }}</gl-link + ><clipboard-button + ref="copy-source-ref-link" + :text="pipeline.ref.name" + :title="__('Copy reference')" + category="tertiary" + size="small" + data-testid="copy-source-ref-link" + /> + </template> + <template #source> <gl-link :href="pipeline.merge_request.source_branch_path" class="link-commit ref-name" data-testid="source-branch-link" >{{ pipeline.merge_request.source_branch }}</gl-link - > - - <template v-if="isMergeRequestPipeline"> - {{ s__('Job|into') }} - <gl-link - :href="pipeline.merge_request.target_branch_path" - class="link-commit ref-name" - data-testid="target-branch-link" - >{{ pipeline.merge_request.target_branch }}</gl-link - > - </template> + ><clipboard-button + ref="copy-source-branch-link" + :text="pipeline.merge_request.source_branch" + :title="__('Copy branch name')" + category="tertiary" + size="small" + data-testid="copy-source-branch-link" + /> + </template> + <template #target> + <gl-link + :href="pipeline.merge_request.target_branch_path" + class="link-commit ref-name" + data-testid="target-branch-link" + >{{ pipeline.merge_request.target_branch }}</gl-link + ><clipboard-button + :text="pipeline.merge_request.target_branch" + :title="__('Copy branch name')" + category="tertiary" + size="small" + data-testid="copy-target-branch-link" + /> </template> - <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{ - pipeline.ref.name - }}</gl-link - ><clipboard-button - :text="pipeline.ref.name" - :title="__('Copy reference')" - category="tertiary" - size="small" - data-testid="copy-source-ref-link" - /> - </template> + </gl-sprintf> </div> <gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3"> diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index 962979ba573..951d9324813 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -1,16 +1,6 @@ import { s__, __ } from '~/locale'; import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; -export const GRAPHQL_PAGE_SIZE = 30; - -export const initialPaginationState = { - currentPage: 1, - prevPageCursor: '', - nextPageCursor: '', - first: GRAPHQL_PAGE_SIZE, - last: null, -}; - /* Error constants */ export const POST_FAILURE = 'post_failure'; export const DEFAULT = 'default'; diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js new file mode 100644 index 00000000000..b9946925c95 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js @@ -0,0 +1,30 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Project: { + fields: { + jobs: { + keyArgs: false, + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + let nodes; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses, + pageInfo: incoming.pageInfo, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index 88937185a8c..151e49af87e 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -1,25 +1,22 @@ -query getJobs( - $fullPath: ID! - $first: Int - $last: Int - $after: String - $before: String - $statuses: [CiJobStatus!] -) { +query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) { project(fullPath: $fullPath) { id - jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) { + __typename + jobs(after: $after, first: 30, statuses: $statuses) { pageInfo { endCursor hasNextPage hasPreviousPage startCursor + __typename } nodes { + __typename artifacts { nodes { downloadPath fileType + __typename } } allowFailure diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js index f24daf90815..1b9c7cdcfdd 100644 --- a/app/assets/javascripts/jobs/components/table/index.js +++ b/app/assets/javascripts/jobs/components/table/index.js @@ -4,12 +4,18 @@ import VueApollo from 'vue-apollo'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; +import cacheConfig from './graphql/cache_config'; Vue.use(VueApollo); Vue.use(GlToast); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + cacheConfig, + }, + ), }); export default (containerId = 'js-jobs-table') => { diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 81f42c1e293..864e322eecd 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -1,7 +1,6 @@ <script> -import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; -import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants'; import eventHub from './event_hub'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; @@ -11,14 +10,16 @@ import JobsTableTabs from './jobs_table_tabs.vue'; export default { i18n: { errorMsg: __('There was an error fetching the jobs for your project.'), + loadingAriaLabel: __('Loading'), }, components: { GlAlert, - GlPagination, GlSkeletonLoader, JobsTable, JobsTableEmptyState, JobsTableTabs, + GlIntersectionObserver, + GlLoadingIcon, }, inject: { fullPath: { @@ -31,10 +32,6 @@ export default { variables() { return { fullPath: this.fullPath, - first: this.pagination.first, - last: this.pagination.last, - after: this.pagination.nextPageCursor, - before: this.pagination.prevPageCursor, }; }, update(data) { @@ -57,7 +54,7 @@ export default { hasError: false, isAlertDismissed: false, scope: null, - pagination: initialPaginationState, + firstLoad: true, }; }, computed: { @@ -67,14 +64,8 @@ export default { showEmptyState() { return this.jobs.list.length === 0 && !this.scope; }, - prevPage() { - return Math.max(this.pagination.currentPage - 1, 0); - }, - nextPage() { - return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null; - }, - showPaginationControls() { - return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading; + hasNextPage() { + return this.jobs?.pageInfo?.hasNextPage; }, }, mounted() { @@ -88,26 +79,22 @@ export default { this.$apollo.queries.jobs.refetch({ statuses: this.scope }); }, fetchJobsByStatus(scope) { + this.firstLoad = true; + this.scope = scope; this.$apollo.queries.jobs.refetch({ statuses: scope }); }, - handlePageChange(page) { - const { startCursor, endCursor } = this.jobs.pageInfo; + fetchMoreJobs() { + this.firstLoad = false; - if (page > this.pagination.currentPage) { - this.pagination = { - ...initialPaginationState, - nextPageCursor: endCursor, - currentPage: page, - }; - } else { - this.pagination = { - last: GRAPHQL_PAGE_SIZE, - first: null, - prevPageCursor: startCursor, - currentPage: page, - }; + if (!this.$apollo.queries.jobs.loading) { + this.$apollo.queries.jobs.fetchMore({ + variables: { + fullPath: this.fullPath, + after: this.jobs?.pageInfo?.endCursor, + }, + }); } }, }, @@ -128,7 +115,7 @@ export default { <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> - <div v-if="$apollo.loading" class="gl-mt-5"> + <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> <gl-skeleton-loader :width="1248" :height="73"> <circle cx="748.031" cy="37.7193" r="15.0307" /> <circle cx="787.241" cy="37.7193" r="15.0307" /> @@ -149,14 +136,12 @@ export default { <jobs-table v-else :jobs="jobs.list" /> - <gl-pagination - v-if="showPaginationControls" - :value="pagination.currentPage" - :prev-page="prevPage" - :next-page="nextPage" - align="center" - class="gl-mt-3" - @input="handlePageChange" - /> + <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon + v-if="$apollo.loading" + size="md" + :aria-label="$options.i18n.loadingAriaLabel" + /> + </gl-intersection-observer> </div> </template> diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 6e958ea1842..26dd38bbe08 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -14,7 +14,6 @@ const initializeJobPage = (element) => { const { artifactHelpUrl, deploymentHelpUrl, - codeQualityHelpUrl, runnerSettingsUrl, subscriptionsMoreMinutesUrl, endpoint, @@ -39,7 +38,6 @@ const initializeJobPage = (element) => { props: { artifactHelpUrl, deploymentHelpUrl, - codeQualityHelpUrl, runnerSettingsUrl, subscriptionsMoreMinutesUrl, endpoint, diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js index d4a6d70c62c..f7cdc564538 100644 --- a/app/assets/javascripts/lib/utils/accessor.js +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -50,8 +50,16 @@ function canUseLocalStorage() { return safe; } +/** + * Determines if `window.crypto` is available. + */ +function canUseCrypto() { + return window.crypto?.subtle !== undefined; +} + const AccessorUtilities = { canUseLocalStorage, + canUseCrypto, }; export default AccessorUtilities; diff --git a/app/assets/javascripts/lib/utils/array_utility.js b/app/assets/javascripts/lib/utils/array_utility.js index 197e7790ed7..04f9cb1cdb5 100644 --- a/app/assets/javascripts/lib/utils/array_utility.js +++ b/app/assets/javascripts/lib/utils/array_utility.js @@ -18,3 +18,13 @@ export const swapArrayItems = (array, leftIndex = 0, rightIndex = 0) => { copy[rightIndex] = temp; return copy; }; + +/** + * Return an array with all duplicate items from the given array + * + * @param {Array} array - The source array + * @returns {Array} new array with all duplicate items + */ +export const getDuplicateItemsFromArray = (array) => [ + ...new Set(array.filter((value, index) => array.indexOf(value) !== index)), +]; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index cf6ce2c4889..96d019f62f2 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -130,19 +130,6 @@ export const isInViewport = (el, offset = {}) => { ); }; -export const parseUrl = (url) => { - const parser = document.createElement('a'); - parser.href = url; - return parser; -}; - -export const parseUrlPathname = (url) => { - const parsedUrl = parseUrl(url); - // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11 - // We have to make sure we always have an absolute path. - return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`; -}; - export const isMetaKey = (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; // Identify following special clicks diff --git a/app/assets/javascripts/lib/utils/ignore_while_pending.js b/app/assets/javascripts/lib/utils/ignore_while_pending.js new file mode 100644 index 00000000000..e85a573c8f2 --- /dev/null +++ b/app/assets/javascripts/lib/utils/ignore_while_pending.js @@ -0,0 +1,26 @@ +/** + * This will wrap the given function to make sure that it is only triggered once + * while executing asynchronously + * + * @param {Function} fn some function that returns a promise + * @returns A function that will only be triggered *once* while the promise is executing + */ +export const ignoreWhilePending = (fn) => { + const isPendingMap = new WeakMap(); + const defaultContext = {}; + + // We need this to be a function so we get the `this` + return function ignoreWhilePendingInner(...args) { + const context = this || defaultContext; + + if (isPendingMap.get(context)) { + return Promise.resolve(); + } + + isPendingMap.set(context, true); + + return fn.apply(this, args).finally(() => { + isPendingMap.delete(context); + }); + }; +}; diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js index 6b1985a23ba..b4f425da871 100644 --- a/app/assets/javascripts/lib/utils/rails_ujs.js +++ b/app/assets/javascripts/lib/utils/rails_ujs.js @@ -1,5 +1,6 @@ import Rails from '@rails/ujs'; import { confirmViaGlModal } from './confirm_via_gl_modal/confirm_via_gl_modal'; +import { ignoreWhilePending } from './ignore_while_pending'; function monkeyPatchConfirmModal() { /** @@ -18,8 +19,10 @@ function monkeyPatchConfirmModal() { * @param element {HTMLElement} Element that was clicked on * @returns {boolean} */ + const safeConfirm = ignoreWhilePending(confirmViaGlModal); + function confirmViaModal(message, element) { - confirmViaGlModal(message, element) + safeConfirm(message, element) .then((confirmed) => { if (confirmed) { Rails.confirm = () => true; diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js index e72c6fe1679..5d194340b9e 100644 --- a/app/assets/javascripts/lib/utils/resize_observer.js +++ b/app/assets/javascripts/lib/utils/resize_observer.js @@ -10,22 +10,30 @@ export function createResizeObserver() { }); } -// watches for change in size of a container element (e.g. for lazy-loaded images) -// and scroll the target element to the top of the content area -// stop watching after any user input. So if user opens sidebar or manually -// scrolls the page we don't hijack their scroll position +/** + * Watches for change in size of a container element (e.g. for lazy-loaded images) + * and scrolls the target note to the top of the content area. + * Stops watching after any user input. So if user opens sidebar or manually + * scrolls the page we don't hijack their scroll position + * + * @param {Object} options + * @param {string} options.targetId - id of element to scroll to + * @param {string} options.container - Selector of element containing target + * + * @return {ResizeObserver|null} - ResizeObserver instance if target looks like a note DOM ID + */ export function scrollToTargetOnResize({ - target = window.location.hash, + targetId = window.location.hash.slice(1), container = '#content-body', } = {}) { - if (!target) return null; + if (!targetId) return null; const ro = createResizeObserver(); const containerEl = document.querySelector(container); let interactionListenersAdded = false; function keepTargetAtTop() { - const anchorEl = document.querySelector(target); + const anchorEl = document.getElementById(targetId); if (!anchorEl) return; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index ec6789d81ec..ac2eb34260c 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -9,7 +9,7 @@ const LINK_TAG_PATTERN = '[{text}](url)'; // a bullet point character (*+-) and an optional checkbox ([ ] [x]) // OR a number with a . after it and an optional checkbox ([ ] [x]) // followed by one or more whitespace characters -const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isOl>[*+-])|(?<isUl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/; +const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/; function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); @@ -31,8 +31,19 @@ function lineBefore(text, textarea, trimNewlines = true) { return split[split.length - 1]; } -function lineAfter(text, textarea) { - return text.substring(textarea.selectionEnd).trim().split('\n')[0]; +function lineAfter(text, textarea, trimNewlines = true) { + let split = text.substring(textarea.selectionEnd); + + if (trimNewlines) { + split = split.trim(); + } else { + // remove possible leading newline to get at the real line + split = split.replace(/^\n/, ''); + } + + split = split.split('\n'); + + return split[0]; } function convertMonacoSelectionToAceFormat(sel) { @@ -329,6 +340,25 @@ function handleSurroundSelectedText(e, textArea) { } /* eslint-enable @gitlab/require-i18n-strings */ +/** + * Returns the content for a new line following a list item. + * + * @param {Object} result - regex match of the current line + * @param {Object?} nextLineResult - regex match of the next line + * @returns string with the new list item + */ +function continueOlText(result, nextLineResult) { + const { indent, leader } = result.groups; + const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {}; + + const [numStr, postfix = ''] = leader.split('.'); + + const incrementBy = nextIsOl && nextIndent === indent ? 0 : 1; + const num = parseInt(numStr, 10) + incrementBy; + + return `${indent}${num}.${postfix}`; +} + function handleContinueList(e, textArea) { if (!gon.features?.markdownContinueLists) return; if (!(e.key === 'Enter')) return; @@ -339,7 +369,7 @@ function handleContinueList(e, textArea) { const result = currentLine.match(LIST_LINE_HEAD_PATTERN); if (result) { - const { indent, content, leader } = result.groups; + const { leader, indent, content, isOl } = result.groups; const prevLineEmpty = !content; if (prevLineEmpty) { @@ -349,12 +379,22 @@ function handleContinueList(e, textArea) { return; } - const itemInsert = `${indent}${leader}`; + let itemToInsert; + + if (isOl) { + const nextLine = lineAfter(textArea.value, textArea, false); + const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN); + + itemToInsert = continueOlText(result, nextLineResult); + } else { + // isUl + itemToInsert = `${indent}${leader}`; + } e.preventDefault(); updateText({ - tag: itemInsert, + tag: itemToInsert, textArea, blockTag: '', wrap: false, @@ -367,6 +407,8 @@ function handleContinueList(e, textArea) { export function keypressNoteText(e) { const textArea = this; + if ($(textArea).atwho?.('isSelecting')) return; + handleContinueList(e, textArea); handleSurroundSelectedText(e, textArea); } diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 12462a2575e..335cd6a16e5 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -18,6 +18,20 @@ function resetRegExp(regex) { return regex; } +/** + * Returns the absolute pathname for a relative or absolute URL string. + * + * A few examples of inputs and outputs: + * 1) 'http://a.com/b/c/d' => '/b/c/d' + * 2) '/b/c/d' => '/b/c/d' + * 3) 'b/c/d' => '/b/c/d' or '[path]/b/c/d' depending of the current path of the + * document.location + */ +export const parseUrlPathname = (url) => { + const { pathname } = new URL(url, document.location.href); + return pathname; +}; + // Returns a decoded url parameter value // - Treats '+' as '%20' function decodeUrlParameter(val) { diff --git a/app/assets/javascripts/loading_icon_for_legacy_js.js b/app/assets/javascripts/loading_icon_for_legacy_js.js new file mode 100644 index 00000000000..d50a4275424 --- /dev/null +++ b/app/assets/javascripts/loading_icon_for_legacy_js.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const defaultValue = (prop) => GlLoadingIcon.props[prop]?.default; + +/** + * Returns a loading icon/spinner element. + * + * This should *only* be used in existing legacy areas of code where Vue is not + * in use, as part of the migration strategy defined in + * https://gitlab.com/groups/gitlab-org/-/epics/7626. + * + * @param {object} props - The props to configure the spinner. + * @param {boolean} props.inline - Display the spinner inline; otherwise, as a block. + * @param {string} props.color - The color of the spinner ('dark' or 'light') + * @param {string} props.size - The size of the spinner ('sm', 'md', 'lg', 'xl') + * @param {string[]} props.classes - Additional classes to apply to the element. + * @param {string} props.label - The ARIA label to apply to the spinner. + * @returns {HTMLElement} + */ +export const loadingIconForLegacyJS = ({ + inline = defaultValue('inline'), + color = defaultValue('color'), + size = defaultValue('size'), + classes = [], + label = __('Loading'), +} = {}) => { + const mountEl = document.createElement('div'); + + const vm = new Vue({ + el: mountEl, + render(h) { + return h(GlLoadingIcon, { + class: classes, + props: { + inline, + color, + size, + label, + }, + }); + }, + }); + + // Ensure it's rendered + vm.$forceUpdate(); + + const el = vm.$el.cloneNode(true); + vm.$destroy(); + + return el; +}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index f78b4da181e..b3cb93e74f2 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -116,16 +116,18 @@ function deferredInitialisation() { ); } - const search = document.querySelector('#search'); - if (search) { - search.addEventListener( + const searchInputBox = document.querySelector('#search'); + if (searchInputBox) { + searchInputBox.addEventListener( 'focus', () => { if (gon.features?.newHeaderSearch) { import(/* webpackChunkName: 'globalSearch' */ '~/header_search') .then(async ({ initHeaderSearchApp }) => { - await initHeaderSearchApp(); - document.querySelector('#search').focus(); + // In case the user started searching before we bootstrapped, let's pass the search along. + const initialSearchValue = searchInputBox.value; + await initHeaderSearchApp(initialSearchValue); + searchInputBox.focus(); }) .catch(() => {}); } else { @@ -159,6 +161,12 @@ function deferredInitialisation() { // Adding a helper class to activate animations only after all is rendered setTimeout(() => $body.addClass('page-initialised'), 1000); + + if (window.gon?.features?.mrAttentionRequests) { + import('~/attention_requests') + .then((module) => module.default()) + .catch(() => {}); + } } const $body = $('body'); diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js deleted file mode 100644 index a28427eb9ac..00000000000 --- a/app/assets/javascripts/member_expiration_date.js +++ /dev/null @@ -1,54 +0,0 @@ -import $ from 'jquery'; -import Pikaday from 'pikaday'; -import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; - -// Add datepickers to all `js-access-expiration-date` elements. If those elements are -// children of an element with the `clearable-input` class, and have a sibling -// `js-clear-input` element, then show that element when there is a value in the -// datepicker, and make clicking on that element clear the field. -// -export default function memberExpirationDate(selector = '.js-access-expiration-date') { - function toggleClearInput() { - $(this) - .closest('.clearable-input') - .toggleClass('has-value', $(this).val() !== ''); - } - const inputs = $(selector); - - inputs.each((i, el) => { - const $input = $(el); - - const calendar = new Pikaday({ - field: $input.get(0), - theme: 'gitlab-theme animate-picker', - format: 'yyyy-mm-dd', - minDate: new Date(), - container: $input.parent().get(0), - parse: (dateString) => parsePikadayDate(dateString), - toString: (date) => pikadayToString(date), - onSelect(dateText) { - $input.val(calendar.toString(dateText)); - - toggleClearInput.call($input); - }, - firstDay: gon.first_day_of_week, - }); - - calendar.setDate(parsePikadayDate($input.val())); - $input.data('pikaday', calendar); - }); - - inputs.next('.js-clear-input').on('click', function clicked(event) { - event.preventDefault(); - - const input = $(this).closest('.clearable-input').find(selector); - const calendar = input.data('pikaday'); - - calendar.setDate(null); - toggleClearInput.call(input); - }); - - inputs.on('blur', toggleClearInput); - - inputs.each(toggleClearInput); -} diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index 01606d07554..27c67e84675 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -25,7 +25,8 @@ export default { }, title: { type: String, - required: true, + required: false, + default: null, }, icon: { type: String, diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 594da7f68cc..122e0a142a9 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -61,7 +61,7 @@ export default { }; }, removeMemberButtonText() { - return this.isInvitedUser ? null : __('Remove user'); + return this.isInvitedUser ? null : __('Remove member'); }, removeMemberButtonIcon() { return this.isInvitedUser ? 'remove' : ''; @@ -86,7 +86,6 @@ export default { :icon="removeMemberButtonIcon" :button-text="removeMemberButtonText" :button-category="removeMemberButtonCategory" - :title="s__('Member|Remove member')" /> </div> <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1"> diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index 633dee75237..ca60f876c6f 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,5 +1,4 @@ <script> -import { GlFilteredSearchToken } from '@gitlab/ui'; import { mapState } from 'vuex'; import { getParameterByName, @@ -7,46 +6,24 @@ import { queryToObject, redirectTo, } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; import { SEARCH_TOKEN_TYPE, SORT_QUERY_PARAM_NAME, ACTIVE_TAB_QUERY_PARAM_NAME, -} from '~/members/constants'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; + AVAILABLE_FILTERED_SEARCH_TOKENS, +} from 'ee_else_ce/members/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; export default { name: 'MembersFilteredSearchBar', components: { FilteredSearchBar }, - availableTokens: [ - { - type: 'two_factor', - icon: 'lock', - title: s__('Members|2FA'), - token: GlFilteredSearchToken, - unique: true, - operators: OPERATOR_IS_ONLY, - options: [ - { value: 'enabled', title: s__('Members|Enabled') }, - { value: 'disabled', title: s__('Members|Disabled') }, - ], - requiredPermissions: 'canManageMembers', - }, - { - type: 'with_inherited_permissions', - icon: 'group', - title: s__('Members|Membership'), - token: GlFilteredSearchToken, - unique: true, - operators: OPERATOR_IS_ONLY, - options: [ - { value: 'exclude', title: s__('Members|Direct') }, - { value: 'only', title: s__('Members|Inherited') }, - ], - }, - ], - inject: ['namespace', 'sourceId', 'canManageMembers'], + availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS, + inject: { + namespace: {}, + sourceId: {}, + canManageMembers: {}, + canFilterByEnterprise: { default: false }, + }, data() { return { initialFilterValue: [], diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 273f1acebc7..49ce00a1689 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -1,4 +1,7 @@ -import { __ } from '~/locale'; +import { GlFilteredSearchToken } from '@gitlab/ui'; + +import { __, s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; export const FIELD_KEY_ACCOUNT = 'account'; export const FIELD_KEY_SOURCE = 'source'; @@ -82,6 +85,38 @@ export const DEFAULT_SORT = { sortDesc: false, }; +export const FILTERED_SEARCH_TOKEN_TWO_FACTOR = { + type: 'two_factor', + icon: 'lock', + title: s__('Members|2FA'), + token: GlFilteredSearchToken, + unique: true, + operators: OPERATOR_IS_ONLY, + options: [ + { value: 'enabled', title: s__('Members|Enabled') }, + { value: 'disabled', title: s__('Members|Disabled') }, + ], + requiredPermissions: 'canManageMembers', +}; + +export const FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS = { + type: 'with_inherited_permissions', + icon: 'group', + title: s__('Members|Membership'), + token: GlFilteredSearchToken, + unique: true, + operators: OPERATOR_IS_ONLY, + options: [ + { value: 'exclude', title: s__('Members|Direct') }, + { value: 'only', title: s__('Members|Inherited') }, + ], +}; + +export const AVAILABLE_FILTERED_SEARCH_TOKENS = [ + FILTERED_SEARCH_TOKEN_TWO_FACTOR, + FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS, +]; + export const AVATAR_SIZE = 48; export const MEMBER_TYPES = { diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 510e89240f4..0df876cabd7 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -18,6 +18,7 @@ export const initMembersApp = (el, options) => { sourceId, canManageMembers, canExportMembers, + canFilterByEnterprise, exportCsvPath, ...vuexStoreAttributes } = parseDataAttributes(el); @@ -60,6 +61,7 @@ export const initMembersApp = (el, options) => { currentUserId: gon.current_user_id || null, sourceId, canManageMembers, + canFilterByEnterprise, canExportMembers, exportCsvPath, }, diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue index 5fcc778a714..fdcb99351a7 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { GlSprintf, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; import { mapGetters, mapState, mapActions } from 'vuex'; import { __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -23,6 +23,7 @@ export default { GlButton, GlButtonGroup, GlSprintf, + GlLoadingIcon, FileIcon, DiffFileEditor, InlineConflictLines, @@ -72,9 +73,7 @@ export default { </script> <template> <div id="conflicts"> - <div v-if="isLoading" class="loading"> - <div class="spinner spinner-md"></div> - </div> + <gl-loading-icon v-if="isLoading" size="md" data-testid="loading-spinner" /> <div v-if="hasError" class="nothing-here-block"> {{ conflictsData.errorMessage }} </div> diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index ad0117844cd..61f7a079d77 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -2,13 +2,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Vue from 'vue'; -import { - getCookie, - parseUrlPathname, - isMetaClick, - parseBoolean, - scrollToElement, -} from '~/lib/utils/common_utils'; +import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils'; +import { parseUrlPathname } from '~/lib/utils/url_utility'; import createEventHub from '~/helpers/event_hub_factory'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import Diff from './diff'; @@ -70,6 +65,103 @@ const FAST_DELAY_FOR_RERENDER = 75; // Store the `location` object, allowing for easier stubbing in tests let { location } = window; +function scrollToContainer(container) { + if (location.hash) { + const $el = $(`${container} ${location.hash}:not(.match)`); + + if ($el.length) { + scrollToElement($el[0]); + } + } +} + +function computeTopOffset(tabs) { + const navbar = document.querySelector('.navbar-gitlab'); + const peek = document.getElementById('js-peek'); + let stickyTop; + + stickyTop = navbar ? navbar.offsetHeight : 0; + stickyTop = peek ? stickyTop + peek.offsetHeight : stickyTop; + stickyTop = tabs ? stickyTop + tabs.offsetHeight : stickyTop; + + return stickyTop; +} + +function mountPipelines() { + const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); + const { mrWidgetData } = gl; + const table = new Vue({ + components: { + CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'), + }, + provide: { + artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint, + artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, + targetProjectFullPath: mrWidgetData?.target_project_full_path || '', + }, + render(createElement) { + return createElement('commit-pipelines-table', { + props: { + endpoint: pipelineTableViewEl.dataset.endpoint, + emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, + errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, + canCreatePipelineInTargetProject: Boolean( + mrWidgetData?.can_create_pipeline_in_target_project, + ), + sourceProjectFullPath: mrWidgetData?.source_project_full_path || '', + targetProjectFullPath: mrWidgetData?.target_project_full_path || '', + projectId: pipelineTableViewEl.dataset.projectId, + mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, + }, + }); + }, + }).$mount(); + + // $mount(el) replaces the el with the new rendered component. We need it in order to mount + // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount + pipelineTableViewEl.appendChild(table.$el); + + return table; +} + +function destroyPipelines(app) { + if (app && app.$destroy) { + app.$destroy(); + + document.querySelector('#commit-pipeline-table-view').innerHTML = ''; + } + + return null; +} + +function loadDiffs({ url, sticky }) { + return axios.get(`${url}.json${location.search}`).then(({ data }) => { + const $container = $('#diffs'); + $container.html(data.html); + initDiffStatsDropdown(sticky); + + localTimeAgo(document.querySelectorAll('#diffs .js-timeago')); + syntaxHighlight($('#diffs .js-syntax-highlight')); + + new Diff(); + scrollToContainer('#diffs'); + + $('.diff-file').each((i, el) => { + new BlobForkSuggestion({ + openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), + forkButtons: $(el).find('.js-fork-suggestion-button'), + cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), + suggestionSections: $(el).find('.js-file-fork-suggestion-section'), + actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), + }).init(); + }); + }); +} + +function toggleLoader(state) { + $('.mr-loading-status .loading').toggleClass('hide', !state); +} + export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container'); @@ -107,13 +199,7 @@ export default class MergeRequestTabs { } this.bindEvents(); - if ( - this.mergeRequestTabs && - this.mergeRequestTabs.querySelector(`a[data-action='${action}']`) && - this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click - ) { - this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click(); - } + this.mergeRequestTabs?.querySelector(`a[data-action='${action}']`)?.click?.(); } bindEvents() { @@ -132,15 +218,6 @@ export default class MergeRequestTabs { $('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab); } - destroyPipelinesView() { - if (this.commitPipelinesTable) { - this.commitPipelinesTable.$destroy(); - this.commitPipelinesTable = null; - - document.querySelector('#commit-pipeline-table-view').innerHTML = ''; - } - } - storeScroll() { if (this.currentTab) { this.scrollPositions[this.currentTab] = document.documentElement.scrollTop; @@ -207,11 +284,11 @@ export default class MergeRequestTabs { this.loadCommits(href); this.expandView(); this.resetViewContainer(); - this.destroyPipelinesView(); + this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } else if (action === 'new') { this.expandView(); this.resetViewContainer(); - this.destroyPipelinesView(); + this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } else if (this.isDiffAction(action)) { if (!isInVueNoteablePage()) { /* @@ -228,7 +305,7 @@ export default class MergeRequestTabs { this.shrinkView(); } this.expandViewContainer(); - this.destroyPipelinesView(); + this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); this.commitsTab.classList.remove('active'); } else if (action === 'pipelines') { this.resetViewContainer(); @@ -247,7 +324,7 @@ export default class MergeRequestTabs { this.expandView(); } this.resetViewContainer(); - this.destroyPipelinesView(); + this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } $('.detail-page-description').renderGFM(); @@ -280,16 +357,6 @@ export default class MergeRequestTabs { this.eventHub.$emit('MergeRequestTabChange', action); } - scrollToContainerElement(container) { - if (location.hash) { - const $el = $(`${container} ${location.hash}:not(.match)`); - - if ($el.length) { - scrollToElement($el[0]); - } - } - } - // Replaces the current merge request-specific action in the URL with a new one // // If the action is "notes", the URL is reset to the standard @@ -356,7 +423,7 @@ export default class MergeRequestTabs { return; } - this.toggleLoading(true); + toggleLoader(true); axios .get(`${source}.json`) @@ -365,15 +432,15 @@ export default class MergeRequestTabs { commitsDiv.innerHTML = data.html; localTimeAgo(commitsDiv.querySelectorAll('.js-timeago')); this.commitsLoaded = true; - this.scrollToContainerElement('#commits'); + scrollToContainer('#commits'); - this.toggleLoading(false); + toggleLoader(false); return import('./add_context_commits_modal'); }) .then((m) => m.default()) .catch(() => { - this.toggleLoading(false); + toggleLoader(false); createFlash({ message: __('An error occurred while fetching this tab.'), }); @@ -381,39 +448,7 @@ export default class MergeRequestTabs { } mountPipelinesView() { - const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - const { mrWidgetData } = gl; - - this.commitPipelinesTable = new Vue({ - components: { - CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'), - }, - provide: { - artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint, - artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, - targetProjectFullPath: mrWidgetData?.target_project_full_path || '', - }, - render(createElement) { - return createElement('commit-pipelines-table', { - props: { - endpoint: pipelineTableViewEl.dataset.endpoint, - emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, - errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, - canCreatePipelineInTargetProject: Boolean( - mrWidgetData?.can_create_pipeline_in_target_project, - ), - sourceProjectFullPath: mrWidgetData?.source_project_full_path || '', - targetProjectFullPath: mrWidgetData?.target_project_full_path || '', - projectId: pipelineTableViewEl.dataset.projectId, - mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, - }, - }); - }, - }).$mount(); - - // $mount(el) replaces the el with the new rendered component. We need it in order to mount - // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount - pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el); + this.commitPipelinesTable = mountPipelines(); } // load the diff tab content from the backend @@ -423,57 +458,31 @@ export default class MergeRequestTabs { return; } - // We extract pathname for the current Changes tab anchor href - // some pages like MergeRequestsController#new has query parameters on that anchor - const urlPathname = parseUrlPathname(source); - - this.toggleLoading(true); - - axios - .get(`${urlPathname}.json${location.search}`) - .then(({ data }) => { - const $container = $('#diffs'); - $container.html(data.html); - initDiffStatsDropdown(this.stickyTop); - - localTimeAgo(document.querySelectorAll('#diffs .js-timeago')); - syntaxHighlight($('#diffs .js-syntax-highlight')); + toggleLoader(true); + loadDiffs({ + // We extract pathname for the current Changes tab anchor href + // some pages like MergeRequestsController#new has query parameters on that anchor + url: parseUrlPathname(source), + sticky: computeTopOffset(this.mergeRequestTabs), + }) + .then(() => { if (this.isDiffAction(this.currentAction)) { this.expandViewContainer(); } - this.diffsLoaded = true; - new Diff(); - this.scrollToContainerElement('#diffs'); - - $('.diff-file').each((i, el) => { - new BlobForkSuggestion({ - openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), - forkButtons: $(el).find('.js-fork-suggestion-button'), - cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), - suggestionSections: $(el).find('.js-file-fork-suggestion-section'), - actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), - }).init(); - }); - - this.toggleLoading(false); + this.diffsLoaded = true; }) .catch(() => { - this.toggleLoading(false); createFlash({ message: __('An error occurred while fetching this tab.'), }); + }) + .finally(() => { + toggleLoader(false); }); } - // Show or hide the loading spinner - // - // status - Boolean, true to show, false to hide - toggleLoading(status) { - $('.mr-loading-status .loading').toggleClass('hide', !status); - } - diffViewType() { return $('.js-diff-view-buttons button.active').data('viewType'); } @@ -529,18 +538,4 @@ export default class MergeRequestTabs { } }, 0); } - - get stickyTop() { - let stickyTop = this.navbar ? this.navbar.offsetHeight : 0; - - if (this.peek) { - stickyTop += this.peek.offsetHeight; - } - - if (this.mergeRequestTabs) { - stickyTop += this.mergeRequestTabs.offsetHeight; - } - - return stickyTop; - } } diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue index a4cef5ea256..1e0f4b10297 100644 --- a/app/assets/javascripts/monitoring/components/charts/bar.vue +++ b/app/assets/javascripts/monitoring/components/charts/bar.vue @@ -1,5 +1,4 @@ <script> -import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlBarChart } from '@gitlab/ui/dist/charts'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { chartHeight } from '../../constants'; @@ -9,9 +8,6 @@ export default { components: { GlBarChart, }, - directives: { - GlResizeObserverDirective, - }, props: { graphData: { type: Object, @@ -60,11 +56,6 @@ export default { formatLegendLabel(query) { return query.label; }, - onResize() { - if (!this.$refs.barChart) return; - const { width } = this.$refs.barChart.$el.getBoundingClientRect(); - this.width = width; - }, setSvg(name) { getSvgIconPathContent(name) .then((path) => { @@ -81,17 +72,16 @@ export default { }; </script> <template> - <div v-gl-resize-observer-directive="onResize"> - <gl-bar-chart - ref="barChart" - v-bind="$attrs" - :data="chartData" - :option="chartOptions" - :width="width" - :height="height" - :x-axis-title="xAxisTitle" - :y-axis-title="yAxisTitle" - :x-axis-type="xAxisType" - /> - </div> + <gl-bar-chart + ref="barChart" + v-bind="$attrs" + :responsive="true" + :data="chartData" + :option="chartOptions" + :width="width" + :height="height" + :x-axis-title="xAxisTitle" + :y-axis-title="yAxisTitle" + :x-axis-type="xAxisType" + /> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index 37251af2049..e8f54b1fa34 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -1,5 +1,4 @@ <script> -import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; @@ -12,9 +11,6 @@ export default { components: { GlColumnChart, }, - directives: { - GlResizeObserverDirective, - }, props: { graphData: { type: Object, @@ -83,11 +79,6 @@ export default { formatLegendLabel(query) { return query.label; }, - onResize() { - if (!this.$refs.columnChart) return; - const { width } = this.$refs.columnChart.$el.getBoundingClientRect(); - this.width = width; - }, setSvg(name) { getSvgIconPathContent(name) .then((path) => { @@ -101,17 +92,16 @@ export default { }; </script> <template> - <div v-gl-resize-observer-directive="onResize"> - <gl-column-chart - ref="columnChart" - v-bind="$attrs" - :bars="barChartData" - :option="chartOptions" - :width="width" - :height="height" - :x-axis-title="xAxisTitle" - :y-axis-title="yAxisTitle" - :x-axis-type="xAxisType" - /> - </div> + <gl-column-chart + ref="columnChart" + v-bind="$attrs" + :responsive="true" + :bars="barChartData" + :option="chartOptions" + :width="width" + :height="height" + :x-axis-title="xAxisTitle" + :y-axis-title="yAxisTitle" + :x-axis-type="xAxisType" + /> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue index 461ff06be72..0477ff19ffe 100644 --- a/app/assets/javascripts/monitoring/components/charts/gauge.vue +++ b/app/assets/javascripts/monitoring/components/charts/gauge.vue @@ -1,5 +1,4 @@ <script> -import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlGaugeChart } from '@gitlab/ui/dist/charts'; import { isFinite, isArray, isInteger } from 'lodash'; import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; @@ -10,9 +9,6 @@ export default { components: { GlGaugeChart, }, - directives: { - GlResizeObserverDirective, - }, props: { graphData: { type: Object, @@ -96,27 +92,19 @@ export default { return this.queryResult || NaN; }, }, - methods: { - onResize() { - if (!this.$refs.gaugeChart) return; - const { width } = this.$refs.gaugeChart.$el.getBoundingClientRect(); - this.width = width; - }, - }, }; </script> <template> - <div v-gl-resize-observer-directive="onResize"> - <gl-gauge-chart - ref="gaugeChart" - v-bind="$attrs" - :value="value" - :min="rangeValues.min" - :max="rangeValues.max" - :thresholds="thresholdsValue" - :text="textValue" - :split-number="splitValue" - :width="width" - /> - </div> + <gl-gauge-chart + ref="gaugeChart" + v-bind="$attrs" + :responsive="true" + :value="value" + :min="rangeValues.min" + :max="rangeValues.max" + :thresholds="thresholdsValue" + :text="textValue" + :split-number="splitValue" + :width="width" + /> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index ed888ef022c..12add274a90 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -1,5 +1,4 @@ <script> -import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlHeatmap } from '@gitlab/ui/dist/charts'; import { formatDate, timezones, formats } from '../../format_date'; import { graphDataValidatorForValues } from '../../utils'; @@ -8,9 +7,6 @@ export default { components: { GlHeatmap, }, - directives: { - GlResizeObserverDirective, - }, props: { graphData: { type: Object, @@ -61,26 +57,18 @@ export default { return this.graphData.metrics[0]; }, }, - methods: { - onResize() { - if (this.$refs.heatmapChart) return; - const { width } = this.$refs.heatmapChart.$el.getBoundingClientRect(); - this.width = width; - }, - }, }; </script> <template> - <div v-gl-resize-observer-directive="onResize"> - <gl-heatmap - ref="heatmapChart" - v-bind="$attrs" - :data-series="chartData" - :x-axis-name="xAxisName" - :y-axis-name="yAxisName" - :x-axis-labels="xAxisLabels" - :y-axis-labels="yAxisLabels" - :width="width" - /> - </div> + <gl-heatmap + ref="heatmapChart" + v-bind="$attrs" + :responsive="true" + :data-series="chartData" + :x-axis-name="xAxisName" + :y-axis-name="yAxisName" + :x-axis-labels="xAxisLabels" + :y-axis-labels="yAxisLabels" + :width="width" + /> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue index a53f899f752..0cf39448d6b 100644 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -1,5 +1,4 @@ <script> -import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlStackedColumnChart } from '@gitlab/ui/dist/charts'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { s__ } from '~/locale'; @@ -12,9 +11,6 @@ export default { components: { GlStackedColumnChart, }, - directives: { - GlResizeObserverDirective, - }, props: { graphData: { type: Object, @@ -125,32 +121,26 @@ export default { console.error('SVG could not be rendered correctly: ', e); }); }, - onResize() { - if (!this.$refs.chart) return; - const { width } = this.$refs.chart.$el.getBoundingClientRect(); - this.width = width; - }, }, }; </script> <template> - <div v-gl-resize-observer-directive="onResize"> - <gl-stacked-column-chart - ref="chart" - v-bind="$attrs" - :bars="chartData" - :option="chartOptions" - :x-axis-title="xAxisTitle" - :y-axis-title="yAxisTitle" - :x-axis-type="xAxisType" - :group-by="groupBy" - :width="width" - :height="height" - :legend-layout="legendLayout" - :legend-average-text="legendAverageText" - :legend-current-text="legendCurrentText" - :legend-max-text="legendMaxText" - :legend-min-text="legendMinText" - /> - </div> + <gl-stacked-column-chart + ref="chart" + v-bind="$attrs" + :responsive="true" + :bars="chartData" + :option="chartOptions" + :x-axis-title="xAxisTitle" + :y-axis-title="yAxisTitle" + :x-axis-type="xAxisType" + :group-by="groupBy" + :width="width" + :height="height" + :legend-layout="legendLayout" + :legend-average-text="legendAverageText" + :legend-current-text="legendCurrentText" + :legend-max-text="legendMaxText" + :legend-min-text="legendMinText" + /> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 5529a94874b..a95b143920b 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlTooltip, GlResizeObserverDirective, GlIcon } from '@gitlab/ui'; +import { GlLink, GlTooltip, GlIcon } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { isEmpty, omit, throttle } from 'lodash'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -28,9 +28,6 @@ export default { GlLink, GlIcon, }, - directives: { - GlResizeObserverDirective, - }, inheritAttrs: false, props: { graphData: { @@ -366,64 +363,58 @@ export default { eChart.off('datazoom'); eChart.on('datazoom', this.throttledDatazoom); }, - onResize() { - if (!this.$refs.chart) return; - const { width } = this.$refs.chart.$el.getBoundingClientRect(); - this.width = width; - }, }, }; </script> <template> - <div v-gl-resize-observer-directive="onResize"> - <component - :is="glChartComponent" - ref="chart" - v-bind="$attrs" - :group-id="groupId" - :data="chartData" - :option="chartOptions" - :format-tooltip-text="formatTooltipText" - :format-annotations-tooltip-text="formatAnnotationsTooltipText" - :width="width" - :height="height" - :legend-layout="legendLayout" - :legend-average-text="legendAverageText" - :legend-current-text="legendCurrentText" - :legend-max-text="legendMaxText" - :legend-min-text="legendMinText" - @created="onChartCreated" - @updated="onChartUpdated" - > - <template #tooltip-title> - <template v-if="tooltip.type === 'deployments'"> - {{ __('Deployed') }} - </template> - <div v-else class="text-nowrap"> - {{ tooltip.title }} - </div> + <component + :is="glChartComponent" + ref="chart" + v-bind="$attrs" + :responsive="true" + :group-id="groupId" + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :format-annotations-tooltip-text="formatAnnotationsTooltipText" + :width="width" + :height="height" + :legend-layout="legendLayout" + :legend-average-text="legendAverageText" + :legend-current-text="legendCurrentText" + :legend-max-text="legendMaxText" + :legend-min-text="legendMinText" + @created="onChartCreated" + @updated="onChartUpdated" + > + <template #tooltip-title> + <template v-if="tooltip.type === 'deployments'"> + {{ __('Deployed') }} </template> - <template #tooltip-content> - <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center"> - <gl-icon name="commit" class="mr-2" /> - <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> - </div> - <template v-else> - <div - v-for="(content, key) in tooltip.content" - :key="key" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> - {{ content.name }} - </gl-chart-series-label> - <div class="gl-ml-7"> - {{ content.value }} - </div> + <div v-else class="text-nowrap"> + {{ tooltip.title }} + </div> + </template> + <template #tooltip-content> + <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center"> + <gl-icon name="commit" class="mr-2" /> + <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> + </div> + <template v-else> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="gl-ml-7"> + {{ content.value }} </div> - </template> + </div> </template> - </component> - </div> + </template> + </component> </template> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 102afaf308f..d5a7fc36ace 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -116,7 +116,7 @@ export default { <gl-dropdown v-if="displayFilters" id="discussion-filter-dropdown" - class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container" + class="full-width-mobile discussion-filter-container js-discussion-filter-container" data-qa-selector="discussion_filter_dropdown" :text="currentFilter.title" :disabled="isLoading" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 0925195d4bb..71d767c3b95 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -6,6 +6,7 @@ import { GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import { __ } from '~/locale'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue'; @@ -139,6 +140,10 @@ export default { return selectedAuthor?.availability || ''; }, }, + i18n: { + showThread: __('Show thread'), + hideThread: __('Hide thread'), + }, }; </script> @@ -148,10 +153,16 @@ export default { <button class="note-action-button discussion-toggle-button js-vue-toggle-button" type="button" + data-testid="thread-toggle" @click="handleToggle" > <gl-icon ref="chevronIcon" :name="toggleChevronIconName" /> - {{ __('Toggle thread') }} + <template v-if="expanded"> + {{ $options.i18n.hideThread }} + </template> + <template v-else> + {{ $options.i18n.showThread }} + </template> </button> </div> <template v-if="hasAuthor"> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index ddf72587ba3..c4602363da1 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -6,6 +6,7 @@ import createFlash from '~/flash'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { s__, __ } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -171,7 +172,7 @@ export default { this.expandDiscussion({ discussionId: this.discussion.id }); } }, - async cancelReplyForm(shouldConfirm, isDirty) { + cancelReplyForm: ignoreWhilePending(async function cancelReplyForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); @@ -188,7 +189,7 @@ export default { this.isReplying = false; clearDraft(this.autosaveKey); - }, + }), saveReply(noteText, form, callback) { if (!noteText) { this.cancelReplyForm(); diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 7bad10616cc..a271ac91f6e 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -7,6 +7,7 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import createFlash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; +import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '../../locale'; @@ -350,7 +351,10 @@ export default { parent: this.$el, }); }, - async formCancelHandler({ shouldConfirm, isDirty }) { + formCancelHandler: ignoreWhilePending(async function formCancelHandler({ + shouldConfirm, + isDirty, + }) { if (shouldConfirm && isDirty) { const msg = __('Are you sure you want to cancel editing this comment?'); const confirmed = await confirmAction(msg); @@ -364,7 +368,7 @@ export default { } 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 diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue index 56d2ff86fb7..1b7d5af6134 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue @@ -1,7 +1,11 @@ <script> import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; -import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index'; +import { + ALERT_MESSAGES, + ADMIN_GARBAGE_COLLECTION_TIP, + ALERT_DANGER_IMPORTING, +} from '../../constants/index'; export default { components: { @@ -23,6 +27,7 @@ export default { }, }, garbageCollectionHelpPagePath: { type: String, required: false, default: '' }, + containerRegistryImportingHelpPagePath: { type: String, required: false, default: '' }, isAdmin: { type: Boolean, default: false, @@ -48,6 +53,11 @@ export default { } return config; }, + alertHref() { + return this.deleteAlertType === ALERT_DANGER_IMPORTING + ? this.containerRegistryImportingHelpPagePath + : this.garbageCollectionHelpPagePath; + }, }, }; </script> @@ -61,7 +71,7 @@ export default { > <gl-sprintf :message="deleteAlertConfig.message"> <template #docLink="{ content }"> - <gl-link :href="garbageCollectionHelpPagePath" target="_blank"> + <gl-link :href="alertHref" target="_blank"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index 29c181f04fb..ab0418388cd 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -4,6 +4,7 @@ import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; import { UPDATED_AT, CLEANUP_UNSCHEDULED_TEXT, @@ -23,7 +24,7 @@ import { ROOT_IMAGE_TOOLTIP, } from '../../constants/index'; -import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql'; +import getContainerRepositoryMetadata from '../../graphql/queries/get_container_repository_metadata.query.graphql'; export default { name: 'DetailsHeader', @@ -50,7 +51,7 @@ export default { }, apollo: { containerRepository: { - query: getContainerRepositoryTagsCountQuery, + query: getContainerRepositoryMetadata, variables() { return { id: this.image.id, @@ -101,6 +102,10 @@ export default { imageName() { return this.imageDetails.name || ROOT_IMAGE_TEXT; }, + formattedSize() { + const { size } = this.imageDetails; + return size ? numberToHumanSize(Number(size)) : null; + }, }, }; </script> @@ -119,10 +124,15 @@ export default { :aria-label="rootImageTooltip" /> </template> + <template #metadata-tags-count> <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> </template> + <template v-if="formattedSize" #metadata-size> + <metadata-item icon="disk" :text="formattedSize" data-testid="image-size" /> + </template> + <template #metadata-cleanup> <metadata-item icon="expire" 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 8b8769a884d..3c7f7ca9aa8 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 @@ -93,6 +93,10 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', ); +export const DETAILS_IMPORTING_ERROR_MESSAGE = s__( + 'ContainerRegistry|Tags temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}.', +); + export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}', @@ -133,6 +137,7 @@ export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; export const ALERT_DANGER_TAGS = 'danger_tags'; export const ALERT_DANGER_IMAGE = 'danger_image'; +export const ALERT_DANGER_IMPORTING = 'danger_importing'; export const DELETE_SCHEDULED = 'DELETE_SCHEDULED'; export const DELETE_FAILED = 'DELETE_FAILED'; @@ -143,6 +148,7 @@ export const ALERT_MESSAGES = { [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, [ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE, + [ALERT_DANGER_IMPORTING]: DETAILS_IMPORTING_ERROR_MESSAGE, }; export const UNFINISHED_STATUS = 'UNFINISHED'; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql index 9092a71edb0..f1f67b98407 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql @@ -1,6 +1,7 @@ -query getContainerRepositoryTagsCount($id: ID!) { +query getContainerRepositoryMetadata($id: ID!) { containerRepository(id: $id) { id tagsCount + size } } diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index 931849c9918..71a85d8885e 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -20,6 +20,7 @@ import { ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, ALERT_DANGER_IMAGE, + ALERT_DANGER_IMPORTING, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, @@ -32,6 +33,8 @@ import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_c import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; +const REPOSITORY_IMPORTING_ERROR_MESSAGE = 'repository importing'; + export default { name: 'RegistryDetailsPage', components: { @@ -147,12 +150,17 @@ export default { }); if (data?.destroyContainerRepositoryTags?.errors[0]) { - throw new Error(); + throw new Error(data.destroyContainerRepositoryTags.errors[0]); } this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; } catch (e) { - this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; + if (e.message === REPOSITORY_IMPORTING_ERROR_MESSAGE) { + this.deleteAlertType = ALERT_DANGER_IMPORTING; + } else { + this.deleteAlertType = + itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; + } } this.mutationLoading = false; @@ -188,6 +196,7 @@ export default { <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" + :container-registry-importing-help-page-path="config.containerRegistryImportingHelpPagePath" :is-admin="config.isAdmin" class="gl-my-2" /> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index e2acebf39d6..5f9e614bebb 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -13,9 +13,8 @@ import getContainerRepositoriesQuery from 'shared_queries/container_registry/get import createFlash from '~/flash'; import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; -import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import Tracking from '~/tracking'; -import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import DeleteImage from '../components/delete_image.vue'; import RegistryHeader from '../components/list_page/registry_header.vue'; @@ -61,8 +60,8 @@ export default { GlSkeletonLoader, RegistryHeader, DeleteImage, - RegistrySearch, CleanupPolicyEnabledAlert, + PersistedSearch, }, directives: { GlTooltip: GlTooltipDirective, @@ -130,8 +129,7 @@ export default { containerRepositoriesCount: 0, itemToDelete: {}, deleteAlertType: null, - filter: [], - sorting: { orderBy: 'UPDATED', sort: 'desc' }, + sorting: null, name: null, mutationLoading: false, fetchBaseQuery: false, @@ -154,7 +152,7 @@ export default { queryVariables() { return { name: this.name, - sort: this.sortBy, + sort: this.sorting, fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, isGroupPage: this.config.isGroupPage, first: GRAPHQL_PAGE_SIZE, @@ -182,24 +180,6 @@ export default { ? DELETE_IMAGE_SUCCESS_MESSAGE : DELETE_IMAGE_ERROR_MESSAGE; }, - sortBy() { - const { orderBy, sort } = this.sorting; - return `${orderBy}_${sort}`.toUpperCase(); - }, - }, - mounted() { - const { sorting, filters } = extractFilterAndSorting(this.$route.query); - - this.filter = [...filters]; - this.name = filters[0]?.value.data; - this.sorting = { ...this.sorting, ...sorting }; - - // If the two graphql calls - which are not batched - resolve togheter we will have a race - // condition when apollo sets the cache, with this we give the 'base' call an headstart - this.fetchBaseQuery = true; - setTimeout(() => { - this.fetchAdditionalDetails = true; - }, 200); }, methods: { deleteImage(item) { @@ -258,18 +238,20 @@ export default { this.track('confirm_delete'); this.mutationLoading = true; }, - updateSorting(value) { - this.sorting = { - ...this.sorting, - ...value, - }; - }, - doFilter() { - const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM); + handleSearchUpdate({ sort, filters }) { + this.sorting = sort; + + const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM); this.name = search?.value?.data; - }, - updateUrlQueryString(query) { - this.$router.push({ query }); + + if (!this.fetchBaseQuery && !this.fetchAdditionalDetails) { + // If the two graphql calls - which are not batched - resolve together we will have a race + // condition when apollo sets the cache, with this we give the 'base' call an headstart + this.fetchBaseQuery = true; + setTimeout(() => { + this.fetchAdditionalDetails = true; + }, 200); + } }, }, }; @@ -332,16 +314,12 @@ export default { /> </template> </registry-header> - - <registry-search - :filter="filter" - :sorting="sorting" - :tokens="[]" + <persisted-search + class="gl-mb-5" :sortable-fields="$options.searchConfig" - @sorting:changed="updateSorting" - @filter:changed="filter = $event" - @filter:submit="doFilter" - @query:changed="updateUrlQueryString" + :default-order="$options.searchConfig[0].orderBy" + default-sort="desc" + @update="handleSearchUpdate" /> <div v-if="isLoading" class="gl-mt-5"> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue index 6030af9d2c3..ae2d5f4fbc5 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue @@ -13,7 +13,6 @@ import { REMOVE_INFO_TEXT, EXPIRATION_SCHEDULE_LABEL, NAME_REGEX_LABEL, - NAME_REGEX_PLACEHOLDER, NAME_REGEX_DESCRIPTION, CADENCE_LABEL, EXPIRATION_POLICY_FOOTER_NOTE, @@ -68,7 +67,6 @@ export default { REMOVE_INFO_TEXT, EXPIRATION_SCHEDULE_LABEL, NAME_REGEX_LABEL, - NAME_REGEX_PLACEHOLDER, NAME_REGEX_DESCRIPTION, CADENCE_LABEL, EXPIRATION_POLICY_FOOTER_NOTE, @@ -141,6 +139,17 @@ export default { [model]: state, }; }, + encapsulateError(path, message) { + return { + graphQLErrors: [ + { + extensions: { + problems: [{ path: [path], message }], + }, + }, + ], + }; + }, submit() { this.track('submit_form'); this.apiErrors = {}; @@ -156,7 +165,8 @@ export default { .then(({ data }) => { const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; if (errorMessage) { - this.$toast.show(errorMessage); + const customError = this.encapsulateError('nameRegex', errorMessage); + throw customError; } else { this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); } @@ -273,7 +283,6 @@ export default { :error="apiErrors.nameRegex" :disabled="isFieldDisabled" :label="$options.i18n.NAME_REGEX_LABEL" - :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER" :description="$options.i18n.NAME_REGEX_DESCRIPTION" name="remove-regex" data-testid="remove-regex-input" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 4d477fbd05d..841585c5646 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -32,7 +32,6 @@ export const REMOVE_INFO_TEXT = s__( ); export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:'); export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:'); -export const NAME_REGEX_PLACEHOLDER = '.*'; export const NAME_REGEX_DESCRIPTION = s__( 'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}', ); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js index c4b2af13862..5e0be3834cb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js @@ -10,6 +10,7 @@ export const updateContainerExpirationPolicy = (projectPath) => (client, { data: const data = produce(sourceData, (draftState) => { draftState.project.containerExpirationPolicy = { + ...draftState.project.containerExpirationPolicy, ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy, }; }); diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js new file mode 100644 index 00000000000..3397b02aeba --- /dev/null +++ b/app/assets/javascripts/pages/admin/applications/index.js @@ -0,0 +1,3 @@ +import initApplicationDeleteButtons from '~/admin/applications'; + +initApplicationDeleteButtons(); diff --git a/app/assets/javascripts/pages/admin/clusters/new/index.js b/app/assets/javascripts/pages/admin/clusters/connect/index.js index de9ded87ef3..de9ded87ef3 100644 --- a/app/assets/javascripts/pages/admin/clusters/new/index.js +++ b/app/assets/javascripts/pages/admin/clusters/connect/index.js diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js index c4e05bbd092..f5e6d044865 100644 --- a/app/assets/javascripts/pages/admin/topics/edit/index.js +++ b/app/assets/javascripts/pages/admin/topics/edit/index.js @@ -2,7 +2,10 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; import initFilePickers from '~/file_pickers'; import ZenMode from '~/zen_mode'; +import initRemoveAvatar from '~/admin/topics'; new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new initFilePickers(); new ZenMode(); // eslint-disable-line no-new + +initRemoveAvatar(); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index cabb1b24ae6..c4bbbdcd8ec 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -96,6 +96,8 @@ export default class Todos { target.setAttribute('disabled', true); target.classList.add('disabled'); + target.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); + axios[target.dataset.method](target.dataset.href) .then(({ data }) => { this.updateRowState(target); @@ -118,6 +120,8 @@ export default class Todos { target.removeAttribute('disabled'); target.classList.remove('disabled'); + target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2'); + if (isInactive === true) { restoreBtn.classList.add('hidden'); doneBtn.classList.remove('hidden'); @@ -140,6 +144,8 @@ export default class Todos { target.setAttribute('disabled', true); target.classList.add('disabled'); + target.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); + axios[target.dataset.method](target.dataset.href, { ids: this.todo_ids, }) @@ -163,6 +169,8 @@ export default class Todos { target.removeAttribute('disabled'); target.classList.remove('disabled'); + target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2'); + this.todo_ids = target === markAllDoneBtn ? data.updated_ids : []; undoAllBtn.classList.toggle('hidden'); markAllDoneBtn.classList.toggle('hidden'); diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/connect/index.js index de9ded87ef3..de9ded87ef3 100644 --- a/app/assets/javascripts/pages/groups/clusters/new/index.js +++ b/app/assets/javascripts/pages/groups/clusters/connect/index.js diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 14ce3f775b1..280b544af3c 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,16 +1,12 @@ import { groupMemberRequestFormatter } from '~/groups/members/utils'; -import groupsSelect from '~/groups_select'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; -import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; -import memberExpirationDate from '~/member_expiration_date'; import { initMembersApp } from '~/members'; import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; -import UsersSelect from '~/users_select'; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; @@ -22,7 +18,7 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { show: true, - tokens: ['two_factor', 'with_inherited_permissions'], + tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'], searchParam: 'search', placeholder: s__('Members|Filter members'), recentSearchesStorageKey: 'group_members', @@ -53,16 +49,7 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { }, }); -groupsSelect(); -memberExpirationDate(); -memberExpirationDate('.js-access-expiration-date-groups'); initInviteMembersModal(); initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); - -// This is only used when `invite_members_group_modal` feature flag is disabled. -// This can be removed when `invite_members_group_modal` feature flag is removed. -initInviteMembersForm(); - -new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js new file mode 100644 index 00000000000..3fe238dcb35 --- /dev/null +++ b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js @@ -0,0 +1,28 @@ +function getOriginURL() { + const origin = new URL(window.opener.location); + origin.hash = ''; + origin.search = ''; + + return origin; +} + +function postMessageToJiraConnectApp(data) { + window.opener.postMessage(data, getOriginURL().toString()); +} + +function initOAuthCallbacks() { + const params = new URLSearchParams(window.location.search); + if (params.has('code') && params.has('state')) { + postMessageToJiraConnectApp({ + success: true, + code: params.get('code'), + state: params.get('state'), + }); + } else { + postMessageToJiraConnectApp({ success: false }); + } + + window.close(); +} + +initOAuthCallbacks(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 2fc9a111405..740fdb8a96a 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import TableOfContents from '~/blob/components/table_contents.vue'; @@ -11,7 +12,9 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; +import createStore from '~/code_navigation/store'; +Vue.use(Vuex); Vue.use(VueApollo); Vue.use(VueRouter); @@ -29,6 +32,7 @@ if (viewBlobEl) { // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, + store: createStore(), router, apolloProvider, provide: { @@ -78,7 +82,7 @@ GpgBadges.fetch(); const codeNavEl = document.getElementById('js-code-navigation'); -if (codeNavEl) { +if (codeNavEl && !viewBlobEl) { const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; // eslint-disable-next-line promise/catch-or-return diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index d279c4cbb08..f3530b46845 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -1,12 +1,9 @@ import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; -import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; 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'; -AjaxLoadingSpinner.init(); - const { divergingCountsEndpoint, defaultBranch } = document.querySelector( '.js-branch-list', ).dataset; diff --git a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js new file mode 100644 index 00000000000..61486606665 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js @@ -0,0 +1,3 @@ +import { initCiSecureFiles } from '~/ci_secure_files'; + +initCiSecureFiles(); diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/connect/index.js index de9ded87ef3..de9ded87ef3 100644 --- a/app/assets/javascripts/pages/projects/clusters/new/index.js +++ b/app/assets/javascripts/pages/projects/clusters/connect/index.js diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js index f0554d64ddc..8e0d9ee0eab 100644 --- a/app/assets/javascripts/pages/projects/environments/index/index.js +++ b/app/assets/javascripts/pages/projects/environments/index/index.js @@ -1,11 +1,5 @@ -import initEnvironments from '~/environments/'; -import initNewEnvironments from '~/environments/new_index'; +import initEnvironments from '~/environments/index'; -let el = document.getElementById('environments-list-view'); +const el = document.getElementById('environments-table'); -if (el) { - initEnvironments(el); -} else { - el = document.getElementById('environments-table'); - initNewEnvironments(el); -} +initEnvironments(el); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue index 7fb41c6e7b7..0995a2118b1 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/app.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue @@ -10,38 +10,6 @@ export default { type: String, required: true, }, - endpoint: { - type: String, - required: true, - }, - projectFullPath: { - type: String, - required: true, - }, - projectId: { - type: String, - required: true, - }, - projectName: { - type: String, - required: true, - }, - projectPath: { - type: String, - required: true, - }, - projectDescription: { - type: String, - required: true, - }, - projectVisibility: { - type: String, - required: true, - }, - restrictedVisibilityLevels: { - type: Array, - required: true, - }, }, }; </script> @@ -62,16 +30,7 @@ export default { </p> </div> <div class="col-lg-9"> - <fork-form - :endpoint="endpoint" - :project-full-path="projectFullPath" - :project-id="projectId" - :project-name="projectName" - :project-path="projectPath" - :project-description="projectDescription" - :project-visibility="projectVisibility" - :restricted-visibility-levels="restrictedVisibilityLevels" - /> + <fork-form /> </div> </div> </template> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 25b62e6c971..701bf0c1e1d 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -72,40 +72,29 @@ export default { visibilityHelpPath: { default: '', }, - }, - props: { endpoint: { - type: String, - required: true, + default: '', }, projectFullPath: { - type: String, - required: true, + default: '', }, projectId: { - type: String, - required: true, + default: '', }, projectName: { - type: String, - required: true, + default: '', }, projectPath: { - type: String, - required: true, + default: '', }, projectDescription: { - type: String, - required: false, default: '', }, projectVisibility: { - type: String, - required: true, + default: '', }, restrictedVisibilityLevels: { - type: Array, - required: true, + default: [], }, }, data() { diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue deleted file mode 100644 index 10753de6cd0..00000000000 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue +++ /dev/null @@ -1,93 +0,0 @@ -<script> -import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import ForkGroupsListItem from './fork_groups_list_item.vue'; - -export default { - components: { - GlTabs, - GlTab, - GlLoadingIcon, - GlSearchBoxByType, - ForkGroupsListItem, - }, - props: { - endpoint: { - type: String, - required: true, - }, - }, - data() { - return { - namespaces: null, - filter: '', - }; - }, - computed: { - filteredNamespaces() { - return this.namespaces.filter((n) => - n.name.toLowerCase().includes(this.filter.toLowerCase()), - ); - }, - }, - - mounted() { - this.loadGroups(); - }, - - methods: { - loadGroups() { - axios - .get(this.endpoint) - .then((response) => { - this.namespaces = response.data.namespaces; - }) - .catch(() => - createFlash({ - message: __('There was a problem fetching groups.'), - }), - ); - }, - }, - - i18n: { - searchPlaceholder: __('Search by name'), - }, -}; -</script> -<template> - <gl-tabs class="fork-groups"> - <gl-tab :title="__('Groups and subgroups')"> - <gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" /> - <template v-else-if="namespaces.length === 0"> - <div class="gl-text-center"> - <div class="h5">{{ __('No available groups to fork the project.') }}</div> - <p class="gl-mt-5"> - {{ __('You must have permission to create a project in a group before forking.') }} - </p> - </div> - </template> - <div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3"> - {{ s__('GroupsTree|No groups matched your search') }} - </div> - <ul v-else class="groups-list group-list-tree"> - <fork-groups-list-item - v-for="(namespace, index) in filteredNamespaces" - :key="index" - :group="namespace" - /> - </ul> - </gl-tab> - <template #tabs-end> - <gl-search-box-by-type - v-if="namespaces && namespaces.length" - v-model="filter" - :placeholder="$options.i18n.searchPlaceholder" - class="gl-align-self-center gl-ml-auto fork-filtered-search" - data-qa-selector="fork_groups_list_search_field" - /> - </template> - </gl-tabs> -</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue deleted file mode 100644 index d41488acf46..00000000000 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue +++ /dev/null @@ -1,148 +0,0 @@ -<script> -import { - GlLink, - GlButton, - GlIcon, - GlAvatar, - GlTooltipDirective, - GlTooltip, - GlBadge, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants'; -import csrf from '~/lib/utils/csrf'; -import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; - -export default { - components: { - GlIcon, - GlAvatar, - GlBadge, - GlButton, - GlTooltip, - GlLink, - UserAccessRoleBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - SafeHtml, - }, - props: { - group: { - type: Object, - required: true, - }, - }, - data() { - return { namespaces: null, isForking: false }; - }, - - computed: { - rowClass() { - return { - 'has-description': this.group.description, - 'being-removed': this.isGroupPendingRemoval, - }; - }, - isGroupPendingRemoval() { - return this.group.marked_for_deletion; - }, - hasForkedProject() { - return Boolean(this.group.forked_project_path); - }, - visibilityIcon() { - return VISIBILITY_TYPE_ICON[this.group.visibility]; - }, - visibilityTooltip() { - return GROUP_VISIBILITY_TYPE[this.group.visibility]; - }, - isSelectButtonDisabled() { - return !this.group.can_create_project; - }, - }, - - methods: { - fork() { - this.isForking = true; - this.$refs.form.submit(); - }, - }, - - csrf, -}; -</script> -<template> - <li :class="rowClass" class="group-row"> - <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5"> - <div - class="folder-toggle-wrap gl-mr-3 gl-display-flex gl-align-items-center gl-text-gray-500" - > - <gl-icon name="folder-o" /> - </div> - <gl-link - :href="group.relative_path" - class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3" - > - <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" /> - </gl-link> - <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> - <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> - <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3"> - <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!"> - {{ group.full_name }} - </gl-link> - <gl-icon - v-gl-tooltip.hover.bottom - class="gl-display-inline-flex gl-mt-3 gl-mr-3 gl-text-gray-500" - :name="visibilityIcon" - :title="visibilityTooltip" - /> - <gl-badge - v-if="isGroupPendingRemoval" - variant="warning" - class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1" - >{{ __('pending deletion') }}</gl-badge - > - <user-access-role-badge v-if="group.permission" class="gl-mt-3"> - {{ group.permission }} - </user-access-role-badge> - </div> - <div v-if="group.description" class="description gl-line-height-20"> - <span v-safe-html="group.markdown_description"> </span> - </div> - </div> - <div class="gl-display-flex gl-flex-shrink-0"> - <gl-button - v-if="hasForkedProject" - class="gl-h-7 gl-text-decoration-none!" - :href="group.forked_project_path" - >{{ __('Go to fork') }}</gl-button - > - <template v-else> - <div ref="selectButtonWrapper"> - <form ref="form" method="POST" :action="group.fork_path"> - <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <gl-button - type="submit" - class="gl-h-7" - :data-qa-name="group.full_name" - category="secondary" - variant="success" - :disabled="isSelectButtonDisabled" - :loading="isForking" - @click="fork" - >{{ __('Select') }}</gl-button - > - </form> - </div> - <gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper"> - {{ - __('You must have permission to create a project in a namespace before forking.') - }} - </gl-tooltip> - </template> - </div> - </div> - </div> - </li> -</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index 1a171252048..cbf74f755e7 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,61 +1,42 @@ import Vue from 'vue'; import App from './components/app.vue'; -import ForkGroupsList from './components/fork_groups_list.vue'; const mountElement = document.getElementById('fork-groups-mount-element'); -if (gon.features.forkProjectForm) { - const { - forkIllustration, - endpoint, +const { + forkIllustration, + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + restrictedVisibilityLevels, +} = mountElement.dataset; + +// eslint-disable-next-line no-new +new Vue({ + el: mountElement, + provide: { newGroupPath, - projectFullPath, visibilityHelpPath, + endpoint, + projectFullPath, projectId, projectName, projectPath, projectDescription, projectVisibility, - restrictedVisibilityLevels, - } = mountElement.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: mountElement, - provide: { - newGroupPath, - visibilityHelpPath, - }, - render(h) { - return h(App, { - props: { - forkIllustration, - endpoint, - newGroupPath, - projectFullPath, - visibilityHelpPath, - projectId, - projectName, - projectPath, - projectDescription, - projectVisibility, - restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels), - }, - }); - }, - }); -} else { - const { endpoint } = mountElement.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: mountElement, - render(h) { - return h(ForkGroupsList, { - props: { - endpoint, - }, - }); - }, - }); -} + restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels), + }, + render(h) { + return h(App, { + props: { + forkIllustration, + }, + }); + }, +}); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index adae97c6b6f..67962d69fa5 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -27,11 +27,6 @@ export default { required: true, type: Object, }, - inviteMembers: { - type: Boolean, - required: false, - default: false, - }, project: { required: true, type: Object, @@ -54,7 +49,7 @@ export default { }, }, mounted() { - if (this.inviteMembers && this.getCookieForInviteMembers()) { + if (this.getCookieForInviteMembers()) { this.openInviteMembersModal('celebrate'); } diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue index ad6dfbf41ca..09cc0032871 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue @@ -64,15 +64,7 @@ export default { <img :src="svg" :alt="actionLabel" /> <h6>{{ title }}</h6> <p class="gl-font-sm gl-text-gray-700">{{ description }}</p> - <gl-link - :href="url" - target="_blank" - rel="noopener noreferrer" - data-track-action="click_link" - :data-track-label="actionLabel" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - >{{ actionLabel }}</gl-link - > + <gl-link :href="url" target="_blank" rel="noopener noreferrer" /> </div> </gl-card> </template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index d0ec02bbd0c..573f996a254 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -32,7 +32,7 @@ export default { ); }, openInNewTab() { - return ACTION_LABELS[this.action]?.openInNewTab === true; + return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true; }, }, methods: { @@ -65,8 +65,6 @@ export default { data-testid="uncompleted-learn-gitlab-link" data-track-action="click_link" :data-track-label="$options.i18n.ACTION_LABELS[action].title" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - data-track-experiment="change_continuous_onboarding_link_urls" > {{ $options.i18n.ACTION_LABELS[action].title }} </gl-link> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 880cf699e5e..1887c48dd1b 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -62,7 +62,6 @@ export const ACTION_LABELS = { description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), section: 'deploy', position: 1, - openInNewTab: true, }, issueCreated: { title: s__('LearnGitLab|Create an issue'), diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index c62cab1a425..63357ea9c72 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import LearnGitlab from '../components/learn_gitlab.vue'; function initLearnGitlab() { @@ -13,13 +13,12 @@ function initLearnGitlab() { const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project)); - const { inviteMembers } = el.dataset; return new Vue({ el, render(createElement) { return createElement(LearnGitlab, { - props: { actions, sections, project, inviteMembers: parseBoolean(inviteMembers) }, + props: { actions, sections, project }, }); }, }); diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js index 169530685ad..6836d399fa4 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/form.js +++ b/app/assets/javascripts/pages/projects/pages_domains/form.js @@ -1,4 +1,4 @@ -import setupToggleButtons from '~/toggle_buttons'; +import { initToggle } from '~/toggles'; function updateVisibility(selector, isVisible) { Array.from(document.querySelectorAll(selector)).forEach((el) => { @@ -11,12 +11,12 @@ function updateVisibility(selector, isVisible) { } export default () => { - const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container'); + const sslToggle = initToggle(document.querySelector('.js-enable-ssl-gl-toggle')); + const sslToggleInput = document.querySelector('.js-project-feature-toggle-input'); - if (toggleContainer) { - const onToggleButtonClicked = (isAutoSslEnabled) => { + if (sslToggle) { + sslToggle.$on('change', (isAutoSslEnabled) => { updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled); - updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled); Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach((el) => { @@ -26,8 +26,9 @@ export default () => { el.removeAttribute('disabled'); } }); - }; - setupToggleButtons(toggleContainer, onToggleButtonClicked); + sslToggleInput.setAttribute('value', isAutoSslEnabled); + }); } + return sslToggle; }; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js deleted file mode 100644 index 6017cd653e4..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js +++ /dev/null @@ -1,54 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; - -export default class TargetBranchDropdown { - constructor() { - this.$dropdown = $('.js-target-branch-dropdown'); - this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); - this.$input = $('#schedule_ref'); - this.initDefaultBranch(); - this.initDropdown(); - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.formatBranchesList(), - filterable: true, - selectable: true, - toggleLabel: (item) => item.name, - search: { - fields: ['name'], - }, - clicked: (cfg) => this.updateInputValue(cfg), - text: (item) => item.name, - }); - - this.setDropdownToggle(); - } - - formatBranchesList() { - return this.$dropdown.data('data').map((val) => ({ name: val })); - } - - setDropdownToggle() { - const initialValue = this.$input.val(); - - this.$dropdownToggle.text(initialValue); - } - - initDefaultBranch() { - const initialValue = this.$input.val(); - const defaultBranch = this.$dropdown.data('defaultBranch'); - - if (!initialValue) { - this.$input.val(defaultBranch); - } - } - - updateInputValue({ selectedObj, e }) { - e.preventDefault(); - - this.$input.val(selectedObj.name); - gl.pipelineScheduleFieldErrors.updateFormValidityState(); - } -} diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 9056c76d6ca..9c039a6be81 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -1,10 +1,12 @@ import $ from 'jquery'; import Vue from 'vue'; +import { __ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; import GlFieldErrors from '../../../../gl_field_errors'; import Translate from '../../../../vue_shared/translate'; import intervalPatternInput from './components/interval_pattern_input.vue'; -import TargetBranchDropdown from './components/target_branch_dropdown'; import TimezoneDropdown from './components/timezone_dropdown'; Vue.use(Translate); @@ -30,6 +32,52 @@ function initIntervalPatternInput() { }); } +function getEnabledRefTypes() { + const refTypes = [REF_TYPE_BRANCHES]; + + if (gon.features.pipelineSchedulesWithTags) { + refTypes.push(REF_TYPE_TAGS); + } + + return refTypes; +} + +function initTargetRefDropdown() { + const $refField = document.getElementById('schedule_ref'); + const el = document.querySelector('.js-target-ref-dropdown'); + const { projectId, defaultBranch } = el.dataset; + + if (!$refField.value) { + $refField.value = defaultBranch; + } + + const refDropdown = new Vue({ + el, + render(h) { + return h(RefSelector, { + props: { + enabledRefTypes: getEnabledRefTypes(), + projectId, + value: $refField.value, + useSymbolicRefNames: true, + translations: { + dropdownHeader: gon.features.pipelineSchedulesWithTags + ? __('Select target branch or tag') + : __('Select target branch'), + }, + }, + class: 'gl-w-full', + }); + }, + }); + + refDropdown.$children[0].$on('input', (newRef) => { + $refField.value = newRef; + }); + + return refDropdown; +} + export default () => { /* Most of the form is written in haml, but for fields with more complex behaviors, * you should mount individual Vue components here. If at some point components need @@ -48,9 +96,10 @@ export default () => { gl.pipelineScheduleFieldErrors.updateFormValidityState(); }, }); - gl.targetBranchDropdown = new TargetBranchDropdown(); gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement); + initTargetRefDropdown(); + setupNativeFormVariableList({ container: $('.js-ci-variable-list-section'), formField: 'schedule', diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 26c42247cf7..2c0394dc12c 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,33 +1,20 @@ -import groupsSelect from '~/groups_select'; import initImportAProjectModal from '~/invite_members/init_import_a_project_modal'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; -import initInviteMembersForm from '~/invite_members/init_invite_members_form'; 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 memberExpirationDate from '~/member_expiration_date'; import { initMembersApp } from '~/members'; import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import { projectMemberRequestFormatter } from '~/projects/members/utils'; -import UsersSelect from '~/users_select'; -groupsSelect(); -memberExpirationDate(); -memberExpirationDate('.js-access-expiration-date-groups'); initImportAProjectModal(); initInviteMembersModal(); initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); -// This is only used when `invite_members_group_modal` feature flag is disabled. -// This can be removed when `invite_members_group_modal` feature flag is removed. -initInviteMembersForm(); - -new UsersSelect(); // eslint-disable-line no-new - const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { [MEMBER_TYPES.user]: { 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 c28de88554a..8ef31b9b983 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -60,7 +60,7 @@ export default { contentEditor: { renderFailed: { message: s__( - 'WikiPage|An error occured while trying to render the content editor. Please try again later.', + 'WikiPage|An error occurred while trying to render the content editor. Please try again later.', ), primaryAction: s__('WikiPage|Retry'), }, @@ -495,6 +495,7 @@ export default { :textarea-value="content" :markdown-docs-path="pageInfo.markdownHelpPath" :uploads-path="pageInfo.uploadsPath" + :enable-preview="isMarkdownFormat" class="bordered-box" > <template #textarea> diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 7f4e79976bc..996e12bc105 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime/date_format_utility'; import { n__, s__, __ } from '~/locale'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; const d3 = { select }; @@ -24,12 +25,6 @@ const CONTRIB_LEGENDS = [ { title: __('30+ contributions'), min: 30 }, ]; -const LOADING_HTML = ` - <div class="text-center"> - <div class="spinner spinner-md"></div> - </div> -`; - function getSystemDate(systemUtcOffsetSeconds) { const date = new Date(); const localUtcOffsetMinutes = 0 - date.getTimezoneOffset(); @@ -286,7 +281,9 @@ export default class ActivityCalendar { this.currentSelectedDate.getDate(), ].join('-'); - $(this.activitiesContainer).html(LOADING_HTML); + $(this.activitiesContainer) + .empty() + .append(loadingIconForLegacyJS({ size: 'lg' })); axios .get(this.calendarActivitiesPath, { diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 1bb82e1d8e6..0640faae8b7 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { sortOrders, sortOrderOptions } from '../constants'; @@ -9,8 +9,9 @@ export default { components: { RequestWarning, GlButton, + GlDropdown, + GlDropdownItem, GlModal, - GlSegmentedControl, }, directives: { 'gl-modal': GlModalDirective, @@ -156,13 +157,19 @@ export default { </div> </div> </div> - <gl-segmented-control + <gl-dropdown v-if="displaySortOrder" + :text="$options.sortOrderOptions[sortOrder]" + right data-testid="performance-bar-sort-order" - :options="$options.sortOrderOptions" - :checked="sortOrder" - @input="changeSortOrder" - /> + > + <gl-dropdown-item + v-for="option in Object.keys($options.sortOrderOptions)" + :key="option" + @click="changeSortOrder(option)" + >{{ $options.sortOrderOptions[option] }}</gl-dropdown-item + > + </gl-dropdown> </div> <hr /> <table class="table gl-table"> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 710f49b833c..0f744e858f2 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -134,6 +134,7 @@ export default { methods: { changeCurrentRequest(newRequestId) { this.currentRequest = newRequestId; + this.$emit('change-request', newRequestId); }, flamegraphPath(mode) { return mergeUrlParams( diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index a46ac620f48..ffc22c2113d 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -1,15 +1,5 @@ <script> -import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; -import { glEmojiTag } from '~/emoji'; -import { n__ } from '~/locale'; - export default { - components: { - GlPopover, - }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, props: { currentRequest: { type: Object, @@ -25,27 +15,11 @@ export default { currentRequestId: this.currentRequest.id, }; }, - computed: { - requestsWithWarnings() { - return this.requests.filter((request) => request.hasWarnings); - }, - warningMessage() { - return n__( - '%d request with warnings', - '%d requests with warnings', - this.requestsWithWarnings.length, - ); - }, - }, watch: { currentRequestId(newRequestId) { this.$emit('change-current-request', newRequestId); }, }, - methods: { - glEmojiTag, - }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> <template> @@ -58,19 +32,7 @@ export default { data-qa-selector="request_dropdown_option" > {{ request.truncatedUrl }} - <span v-if="request.hasWarnings">(!)</span> </option> </select> - <span v-if="requestsWithWarnings.length" class="gl-cursor-default"> - <span - id="performance-bar-request-selector-warning" - v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('warning')" - ></span> - <gl-popover - placement="bottom" - target="performance-bar-request-selector-warning" - :content="warningMessage" - /> - </span> </div> </template> diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js index 9659383edd9..09745797424 100644 --- a/app/assets/javascripts/performance_bar/constants.js +++ b/app/assets/javascripts/performance_bar/constants.js @@ -5,13 +5,7 @@ export const sortOrders = { CHRONOLOGICAL: 'chronological', }; -export const sortOrderOptions = [ - { - value: sortOrders.DURATION, - text: s__('PerformanceBar|Sort by duration'), - }, - { - value: sortOrders.CHRONOLOGICAL, - text: s__('PerformanceBar|Sort chronologically'), - }, -]; +export const sortOrderOptions = { + [sortOrders.DURATION]: s__('PerformanceBar|Sort by duration'), + [sortOrders.CHRONOLOGICAL]: s__('PerformanceBar|Sort chronologically'), +}; diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index eb5b50dd1ec..e7f84eacdca 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,5 +1,6 @@ import '../webpack'; +import { isEmpty } from 'lodash'; import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -37,9 +38,10 @@ const initPerformanceBar = (el) => { }; }, mounted() { - PerformanceBarService.registerInterceptor(this.peekUrl, this.loadRequestDetails); + PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest); - this.loadRequestDetails(this.requestId, window.location.href); + this.addRequest(this.requestId, window.location.href); + this.loadRequestDetails(this.requestId); }, beforeDestroy() { PerformanceBarService.removeInterceptor(); @@ -51,26 +53,32 @@ const initPerformanceBar = (el) => { // want to trace the request. axios.get(urlOrRequestId); } else { - this.loadRequestDetails(urlOrRequestId, urlOrRequestId); + this.addRequest(urlOrRequestId, urlOrRequestId); } }, - loadRequestDetails(requestId, requestUrl) { + addRequest(requestId, requestUrl) { if (!this.store.canTrackRequest(requestUrl)) { return; } this.store.addRequest(requestId, requestUrl); + }, + loadRequestDetails(requestId) { + const request = this.store.findRequest(requestId); + + if (request && isEmpty(request.details)) { + return PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId) + .then((res) => { + this.store.addRequestDetails(requestId, res.data); + if (this.requestId === requestId) this.collectFrontendPerformanceMetrics(); + }) + .catch(() => + // eslint-disable-next-line no-console + console.warn(`Error getting performance bar results for ${requestId}`), + ); + } - PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId) - .then((res) => { - this.store.addRequestDetails(requestId, res.data); - - if (this.requestId === requestId) this.collectFrontendPerformanceMetrics(); - }) - .catch(() => - // eslint-disable-next-line no-console - console.warn(`Error getting performance bar results for ${requestId}`), - ); + return Promise.resolve(); }, collectFrontendPerformanceMetrics() { if (performance) { @@ -82,7 +90,9 @@ const initPerformanceBar = (el) => { let summary = {}; if (navigationEntries.length > 0) { const backend = Math.round(navigationEntries[0].responseEnd); - const firstContentfulPaint = Math.round(paintEntries[1].startTime); + const firstContentfulPaint = Math.round( + paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime, + ); const domContentLoaded = Math.round(navigationEntries[0].domContentLoadedEventEnd); summary = { @@ -141,6 +151,7 @@ const initPerformanceBar = (el) => { }, on: { 'add-request': this.addRequestManually, + 'change-request': this.loadRequestDetails, }, }); }, diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js index 51a8eb5ca69..5a69960e4d9 100644 --- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -12,7 +12,6 @@ export default class PerformanceBarStore { url: requestUrl, truncatedUrl: shortUrl, details: {}, - hasWarnings: false, }); } @@ -27,7 +26,6 @@ export default class PerformanceBarStore { const request = this.findRequest(requestId); request.details = requestDetails.data; - request.hasWarnings = requestDetails.has_warnings; return request; } diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index b003302ec8e..7c424088c8b 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -13,23 +13,25 @@ export default class PersistentUserCallout { this.featureId = featureId; this.groupId = groupId; this.deferLinks = parseBoolean(deferLinks); + this.closeButtons = this.container.querySelectorAll('.js-close'); this.init(); } init() { - const closeButton = this.container.querySelector('.js-close'); const followLink = this.container.querySelector('.js-follow-link'); - if (closeButton) { - this.handleCloseButtonCallout(closeButton); + if (this.closeButtons.length) { + this.handleCloseButtonCallout(); } else if (followLink) { this.handleFollowLinkCallout(followLink); } } - handleCloseButtonCallout(closeButton) { - closeButton.addEventListener('click', (event) => this.dismiss(event)); + handleCloseButtonCallout() { + this.closeButtons.forEach((closeButton) => { + closeButton.addEventListener('click', this.dismiss); + }); if (this.deferLinks) { this.container.addEventListener('click', (event) => { @@ -47,7 +49,7 @@ export default class PersistentUserCallout { followLink.addEventListener('click', (event) => this.registerCalloutWithLink(event)); } - dismiss(event, deferredLinkOptions = null) { + dismiss = (event, deferredLinkOptions = null) => { event.preventDefault(); axios @@ -57,6 +59,9 @@ export default class PersistentUserCallout { }) .then(() => { this.container.remove(); + this.closeButtons.forEach((closeButton) => { + closeButton.removeEventListener('click', this.dismiss); + }); if (deferredLinkOptions) { const { href, target } = deferredLinkOptions; @@ -70,7 +75,7 @@ export default class PersistentUserCallout { ), }); }); - } + }; registerCalloutWithLink(event) { event.preventDefault(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 337c204c36a..f6de21ec0c5 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -11,6 +11,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-eoa-bronze-plan-banner', '.js-security-newsletter-callout', '.js-approaching-seats-count-threshold', + '.js-storage-enforcement-banner', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index ca78f194a82..8536db78dfb 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -31,6 +31,14 @@ export default { required: false, default: '', }, + hasUnsavedChanges: { + type: Boolean, + required: true, + }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, isSaving: { type: Boolean, required: false, @@ -50,11 +58,14 @@ export default { }; }, computed: { + isCommitFormFilledOut() { + return this.message && this.targetBranch; + }, isCurrentBranchTarget() { return this.targetBranch === this.currentBranch; }, - submitDisabled() { - return !(this.message && this.targetBranch); + isSubmitDisabled() { + return !this.isCommitFormFilledOut || (!this.hasUnsavedChanges && !this.isNewCiConfigFile); }, }, watch: { @@ -125,6 +136,7 @@ export default { v-if="!isCurrentBranchTarget" v-model="openMergeRequest" data-testid="new-mr-checkbox" + data-qa-selector="new_mr_checkbox" class="gl-mt-3" > <gl-sprintf :message="$options.i18n.startMergeRequest"> @@ -143,7 +155,7 @@ export default { category="primary" variant="confirm" data-qa-selector="commit_changes_button" - :disabled="submitDisabled" + :disabled="isSubmitDisabled" :loading="isSaving" > {{ $options.i18n.commitChanges }} diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 8ff1aea020f..4ef598d6ff3 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -37,6 +37,10 @@ export default { required: false, default: '', }, + hasUnsavedChanges: { + type: Boolean, + required: true, + }, isNewCiConfigFile: { type: Boolean, required: false, @@ -151,6 +155,8 @@ export default { <commit-form :current-branch="currentBranch" :default-message="defaultCommitMessage" + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" :is-saving="isSaving" :scroll-to-commit-form="scrollToCommitForm" v-on="$listeners" diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 7bc096ce2c8..9cb070a5517 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -2,7 +2,6 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { experiment } from '~/experimentation/utils'; import { DRAWER_EXPANDED_KEY } from '../../constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; @@ -50,29 +49,8 @@ export default { }, mounted() { this.setTopPosition(); - this.setInitialExpandState(); }, methods: { - setInitialExpandState() { - let isExpanded; - - experiment('pipeline_editor_walkthrough', { - control: () => { - isExpanded = true; - }, - candidate: () => { - isExpanded = false; - }, - }); - - // We check in the local storage and if no value is defined, we want the default - // to be true. We want to explicitly set it to true here so that the drawer - // animates to open on load. - const localValue = localStorage.getItem(this.$options.localDrawerKey); - if (localValue === null) { - this.isExpanded = isExpanded; - } - }, setTopPosition() { const navbarEl = document.querySelector('.js-navbar'); diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index 5177cea900c..255e3cb31f1 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -3,6 +3,7 @@ import { EDITOR_READY_EVENT } from '~/editor/constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { SOURCE_EDITOR_DEBOUNCE } from '../../constants'; export default { editorOptions: { @@ -10,6 +11,7 @@ export default { // autocomplete for keywords quickSuggestions: true, }, + debounceValue: SOURCE_EDITOR_DEBOUNCE, components: { SourceEditor, }, @@ -34,6 +36,7 @@ export default { <div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!"> <source-editor ref="editor" + :debounce-value="$options.debounceValue" :editor-options="$options.editorOptions" :file-name="ciConfigPath" v-bind="$attrs" diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index c75b1d4bb11..5cff93c884f 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -4,7 +4,6 @@ import { s__ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import { CREATE_TAB, EDITOR_APP_STATUS_EMPTY, @@ -66,7 +65,6 @@ export default { GlTabs, PipelineGraph, TextEditor, - GitlabExperiment, WalkthroughPopover, }, mixins: [glFeatureFlagsMixin()], @@ -158,11 +156,7 @@ export default { data-testid="editor-tab" @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > - <gitlab-experiment name="pipeline_editor_walkthrough"> - <template #candidate> - <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> - </template> - </gitlab-experiment> + <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> <ci-editor-header /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index dcd08c9de8d..aee71999373 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -41,7 +41,12 @@ export default { </template> </gl-sprintf> </p> - <gl-button variant="confirm" class="gl-mt-3" @click="createEmptyConfigFile"> + <gl-button + variant="confirm" + class="gl-mt-3" + data-qa-selector="create_new_ci_button" + @click="createEmptyConfigFile" + > {{ $options.i18n.btnText }} </gl-button> </div> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index a65463d02aa..2ebc4306405 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -1,3 +1,5 @@ +import { s__ } from '~/locale'; + // Values for CI_CONFIG_STATUS_* comes from lint graphQL export const CI_CONFIG_STATUS_INVALID = 'INVALID'; export const CI_CONFIG_STATUS_VALID = 'VALID'; @@ -47,6 +49,7 @@ export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded'; export const BRANCH_PAGINATION_LIMIT = 20; export const BRANCH_SEARCH_DEBOUNCE = '500'; +export const SOURCE_EDITOR_DEBOUNCE = 500; export const STARTER_TEMPLATE_NAME = 'Getting-Started'; @@ -61,3 +64,45 @@ export const TEMPLATE_REPOSITORY_URL = 'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates'; export const COMMIT_SHA_POLL_INTERVAL = 1000; + +export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section'; +export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked'; +export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked'; +export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked'; +export const I18N = { + title: s__('Pipelines|Get started with GitLab CI/CD'), + runners: { + title: s__('Pipelines|Runners are available to run your jobs now'), + subtitle: s__( + 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.', + ), + }, + noRunners: { + title: s__('Pipelines|No runners detected'), + subtitle: s__( + 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.', + ), + cta: s__('Pipelines|Install GitLab Runner'), + }, + learnBasics: { + title: s__('Pipelines|Learn the basics of pipelines and .yml files'), + subtitle: s__( + 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.', + ), + gettingStarted: { + title: s__('Pipelines|"Hello world" with GitLab CI'), + description: s__( + 'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.', + ), + cta: s__('Pipelines|Try test template'), + }, + }, + templates: { + title: s__('Pipelines|Ready to set up CI/CD for your project?'), + subtitle: s__( + "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.", + ), + description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + cta: s__('Pipelines|Use template'), + }, +}; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 04f91cb3d1e..732fc665c9e 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -2,7 +2,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; 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'; @@ -14,11 +13,6 @@ import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { - // Prevent issues loading syntax validation workers - // Fixes https://gitlab.com/gitlab-org/gitlab/-/issues/297252 - // TODO Remove when https://gitlab.com/gitlab-org/gitlab/-/issues/321656 is resolved - resetServiceWorkersPublicPath(); - const el = document.querySelector(selector); if (!el) { diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 1da50c55a68..a5436ca63cb 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -69,9 +69,10 @@ export default { // If it's a brand new file, we don't want to fetch the content. // Then when the user commits the first time, the query would run // to get the initial file content, but we already have it in `lastCommitedContent` - // so we skip the loading altogether. - skip({ isNewCiConfigFile, lastCommittedContent }) { - return isNewCiConfigFile || lastCommittedContent; + // so we skip the loading altogether. We also wait for the currentBranch + // to have been fetched + skip() { + return this.shouldSkipBlobContentQuery; }, variables() { return { @@ -128,8 +129,8 @@ export default { }, ciConfigData: { query: getCiConfigData, - skip({ currentCiFileContent }) { - return !currentCiFileContent; + skip() { + return this.shouldSkipCiConfigQuery; }, variables() { return { @@ -174,6 +175,9 @@ export default { }, commitSha: { query: getLatestCommitShaQuery, + skip({ currentBranch }) { + return !currentBranch; + }, variables() { return { projectPath: this.projectFullPath, @@ -181,7 +185,7 @@ export default { }; }, update(data) { - const latestCommitSha = data.project?.repository?.tree?.lastCommit?.sha; + const latestCommitSha = data?.project?.repository?.tree?.lastCommit?.sha; if (this.isFetchingCommitSha && latestCommitSha === this.commitSha) { this.$apollo.queries.commitSha.startPolling(COMMIT_SHA_POLL_INTERVAL); @@ -192,6 +196,9 @@ export default { this.$apollo.queries.commitSha.stopPolling(); return latestCommitSha; }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + }, }, currentBranch: { query: getCurrentBranch, @@ -234,6 +241,12 @@ export default { isEmpty() { return this.currentCiFileContent === ''; }, + shouldSkipBlobContentQuery() { + return this.isNewCiConfigFile || this.lastCommittedContent || !this.currentBranch; + }, + shouldSkipCiConfigQuery() { + return !this.currentCiFileContent || !this.commitSha; + }, }, i18n: { resetModal: { diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index bb759477e1e..631dd8a2c00 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -131,6 +131,7 @@ export default { :ref="$options.commitSectionRef" :ci-file-content="ciFileContent" :commit-sha="commitSha" + :has-unsaved-changes="hasUnsavedChanges" :is-new-ci-config-file="isNewCiConfigFile" :scroll-to-commit-form="scrollToCommitForm" @scrolled-to-commit-form="setScrollToCommitForm(false)" diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue index 518b41c66b1..e68458a494f 100644 --- a/app/assets/javascripts/pipeline_wizard/components/commit.vue +++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue @@ -195,7 +195,7 @@ export default { data-testid="branch_selector_group" label-for="branch" > - <ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" /> + <ref-selector id="branch" v-model="branch" :project-id="projectPath" data-testid="branch" /> </gl-form-group> <gl-alert v-if="!!commitError" @@ -206,7 +206,7 @@ export default { > {{ commitError }} </gl-alert> - <step-nav show-back-button v-bind="$props" @back="$emit('go-back')"> + <step-nav show-back-button v-bind="$props" @back="$emit('back')"> <template #after> <gl-button :disabled="isCommitButtonEnabled" diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input.vue new file mode 100644 index 00000000000..9a0c8026648 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/input.vue @@ -0,0 +1,99 @@ +<script> +import { isNode, isDocument, isSeq, visit } from 'yaml'; +import { capitalize } from 'lodash'; +import TextWidget from '~/pipeline_wizard/components/widgets/text.vue'; +import ListWidget from '~/pipeline_wizard/components/widgets/list.vue'; + +const widgets = { + TextWidget, + ListWidget, +}; + +function isNullOrUndefined(v) { + return [undefined, null].includes(v); +} + +export default { + components: { + ...widgets, + }, + props: { + template: { + type: Object, + required: true, + validator: (v) => isNode(v), + }, + compiled: { + type: Object, + required: true, + validator: (v) => isDocument(v) || isNode(v), + }, + target: { + type: String, + required: true, + validator: (v) => /^\$.*/g.test(v), + }, + widget: { + type: String, + required: true, + validator: (v) => { + return Object.keys(widgets).includes(`${capitalize(v)}Widget`); + }, + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + path() { + let res; + visit(this.template, (seqKey, node, path) => { + if (node && node.value === this.target) { + // `path` is an array of objects (all the node's parents) + // So this reducer will reduce it to an array of the path's keys, + // e.g. `[ 'foo', 'bar', '0' ]` + res = path.reduce((p, { key }) => (key ? [...p, `${key}`] : p), []); + const parent = path[path.length - 1]; + if (isSeq(parent)) { + res.push(seqKey); + } + } + }); + return res; + }, + }, + methods: { + compile(v) { + if (!this.path) return; + if (isNullOrUndefined(v)) { + this.compiled.deleteIn(this.path); + } + this.compiled.setIn(this.path, v); + }, + onModelChange(v) { + this.$emit('beforeUpdate:compiled'); + this.compile(v); + this.$emit('update:compiled', this.compiled); + this.$emit('highlight', this.path); + }, + onValidationStateChange(v) { + this.$emit('update:valid', v); + }, + }, +}; +</script> + +<template> + <div> + <component + :is="`${widget}-widget`" + ref="widget" + :validate="validate" + v-bind="$attrs" + @input="onModelChange" + @update:valid="onValidationStateChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue new file mode 100644 index 00000000000..c6f793e4cc5 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/step.vue @@ -0,0 +1,149 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { isNode, isDocument, parseDocument, Document } from 'yaml'; +import { merge } from '~/lib/utils/yaml'; +import { s__ } from '~/locale'; +import { logError } from '~/lib/logger'; +import InputWrapper from './input.vue'; +import StepNav from './step_nav.vue'; + +export default { + name: 'PipelineWizardStep', + i18n: { + errors: { + cloneErrorUserMessage: s__( + 'PipelineWizard|There was an unexpected error trying to set up the template. The error has been logged.', + ), + }, + }, + components: { + StepNav, + InputWrapper, + GlAlert, + }, + props: { + // As the inputs prop we expect to receive an array of instructions + // on how to display the input fields that will be used to obtain the + // user's input. Each input instruction needs a target prop, specifying + // the placeholder in the template that will be replaced by the user's + // input. The selected widget may require additional validation for the + // input object. + inputs: { + type: Array, + required: true, + validator: (value) => + value.every((i) => { + return i?.target && i?.widget; + }), + }, + template: { + type: null, + required: true, + validator: (v) => isNode(v), + }, + hasPreviousStep: { + type: Boolean, + required: false, + default: false, + }, + compiled: { + type: Object, + required: true, + validator: (v) => isDocument(v), + }, + }, + data() { + return { + wasCompiled: false, + validate: false, + inputValidStates: Array(this.inputs.length).fill(null), + error: null, + }; + }, + computed: { + inputValidStatesThatAreNotNull() { + return this.inputValidStates?.filter((s) => s !== null); + }, + areAllInputValidStatesNull() { + return !this.inputValidStatesThatAreNotNull?.length; + }, + isValid() { + return this.areAllInputValidStatesNull || this.inputValidStatesThatAreNotNull.every((s) => s); + }, + }, + methods: { + forceClone(yamlNode) { + try { + // document.clone() will only clone the root document object, + // but the references to the child nodes inside will be retained. + // So in order to ensure a full clone, we need to stringify + // and parse until there's a better implementation in the + // yaml package. + return parseDocument(new Document(yamlNode).toString()); + } catch (e) { + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('An unexpected error occurred while trying to clone a template', e); + this.error = this.$options.i18n.errors.cloneErrorUserMessage; + return null; + } + }, + compile() { + if (this.wasCompiled) return; + // NOTE: This modifies this.compiled without triggering reactivity. + // this is done on purpose, see + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81412#note_862972703 + // for more information + merge(this.compiled, this.forceClone(this.template)); + this.wasCompiled = true; + }, + onUpdate(c) { + this.$emit('update:compiled', c); + }, + onPrevClick() { + this.$emit('back'); + }, + async onNextClick() { + this.validate = true; + await this.$nextTick(); + if (this.isValid) { + this.$emit('next'); + } + }, + onInputValidationStateChange(inputId, value) { + this.$set(this.inputValidStates, inputId, value); + }, + onHighlight(path) { + this.$emit('update:highlight', path); + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="error" class="gl-mb-4" variant="danger"> + {{ error }} + </gl-alert> + <input-wrapper + v-for="(input, i) in inputs" + :key="input.target" + :compiled="compiled" + :target="input.target" + :template="template" + :validate="validate" + :widget="input.widget" + class="gl-mb-2" + v-bind="input" + @highlight="onHighlight" + @update:valid="(validationState) => onInputValidationStateChange(i, validationState)" + @update:compiled="onUpdate" + @beforeUpdate:compiled.once="compile" + /> + <step-nav + :next-button-enabled="isValid" + :show-back-button="hasPreviousStep" + show-next-button + @back="onPrevClick" + @next="onNextClick" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue new file mode 100644 index 00000000000..a5ce56daf07 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue @@ -0,0 +1,195 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +const VALIDATION_STATE = { + NO_VALIDATION: null, + INVALID: false, + VALID: true, +}; + +export const i18n = { + addStepButtonLabel: s__('PipelineWizardListWidget|add another step'), + removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'), + invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'), + errors: { + needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'), + }, +}; + +export default { + i18n, + name: 'ListWidget', + components: { + GlButton, + GlFormGroup, + GlFormInputGroup, + }, + props: { + label: { + type: String, + required: true, + }, + description: { + type: String, + required: false, + default: null, + }, + placeholder: { + type: String, + required: false, + default: null, + }, + default: { + type: Array, + required: false, + default: null, + }, + invalidFeedback: { + type: String, + required: false, + default: i18n.invalidFeedback, + }, + id: { + type: String, + required: false, + default: () => uniqueId('listWidget-'), + }, + pattern: { + type: String, + required: false, + default: null, + }, + required: { + type: Boolean, + required: false, + default: false, + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + touched: false, + value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)], + }; + }, + computed: { + sanitizedValue() { + // Filter out empty steps + return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || []; + }, + hasAnyValue() { + return this.value.some(({ value }) => Boolean(value)); + }, + needsAnyValue() { + return this.required && !this.value.some(({ value }) => Boolean(value)); + }, + inputFieldStates() { + return this.value.map(this.getValidationStateForValue); + }, + inputGroupState() { + return this.showValidationState + ? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID) + : VALIDATION_STATE.NO_VALIDATION; + }, + showValidationState() { + return this.touched || this.validate; + }, + feedback() { + return this.needsAnyValue + ? this.$options.i18n.errors.needsAnyValueError + : this.invalidFeedback; + }, + }, + async created() { + if (this.default) { + // emit an updated default value + await this.$nextTick(); + this.$emit('input', this.sanitizedValue); + } + }, + methods: { + addInputField() { + this.value.push(this.getAsValueEntry(null)); + }, + getAsValueEntry(value) { + return { + id: uniqueId('listValue-'), + value, + }; + }, + getValidationStateForValue({ value }, fieldIndex) { + // If we require a value to be set, mark the first + // field as invalid, but not all of them. + if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID; + if (!value) return VALIDATION_STATE.NO_VALIDATION; + return this.passesPatternValidation(value) + ? VALIDATION_STATE.VALID + : VALIDATION_STATE.INVALID; + }, + passesPatternValidation(v) { + return !this.pattern || new RegExp(this.pattern).test(v); + }, + async onValueUpdate() { + await this.$nextTick(); + this.$emit('input', this.sanitizedValue); + }, + onTouch() { + this.touched = true; + }, + removeValue(index) { + this.value.splice(index, 1); + this.onValueUpdate(); + }, + }, +}; +</script> + +<template> + <div class="gl-mb-6"> + <gl-form-group + :invalid-feedback="feedback" + :label="label" + :label-description="description" + :state="inputGroupState" + class="gl-mb-2" + > + <gl-form-input-group + v-for="(item, i) in value" + :key="item.id" + v-model.trim="value[i].value" + :placeholder="i === 0 ? placeholder : undefined" + :state="inputFieldStates[i]" + class="gl-mb-2" + type="text" + @blur="onTouch" + @input="onValueUpdate" + > + <template v-if="value.length > 1" #append> + <gl-button + :aria-label="$options.i18n.removeStepButtonLabel" + category="secondary" + data-testid="remove-step-button" + icon="remove" + @click="removeValue" + /> + </template> + </gl-form-input-group> + </gl-form-group> + <gl-button + category="tertiary" + data-testid="add-step-button" + icon="plus" + size="small" + variant="confirm" + @click="addInputField" + > + {{ $options.i18n.addStepButtonLabel }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue new file mode 100644 index 00000000000..b7207576ddc --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -0,0 +1,185 @@ +<script> +import { GlProgressBar } from '@gitlab/ui'; +import { Document } from 'yaml'; +import { merge } from '~/lib/utils/yaml'; +import { __ } from '~/locale'; +import { isValidStepSeq } from '~/pipeline_wizard/validators'; +import YamlEditor from './editor.vue'; +import WizardStep from './step.vue'; +import CommitStep from './commit.vue'; + +export const i18n = { + stepNofN: __('Step %{currentStep} of %{stepCount}'), + draft: __('Draft: %{filename}'), + overlayMessage: __(`Start inputting changes and we will generate a + YAML-file for you to add to your repository`), +}; + +export default { + name: 'PipelineWizardWrapper', + i18n, + components: { + GlProgressBar, + YamlEditor, + WizardStep, + CommitStep, + }, + props: { + steps: { + type: Object, + required: true, + validator: isValidStepSeq, + }, + projectPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + }, + data() { + return { + highlightPath: null, + currentStepIndex: 0, + // TODO: In order to support updating existing pipelines, the below + // should contain a parsed version of an existing .gitlab-ci.yml. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/355306 + compiled: new Document({}), + showPlaceholder: true, + pipelineBlob: null, + placeholder: this.getPlaceholder(), + }; + }, + computed: { + currentStepConfig() { + return this.steps.get(this.currentStepIndex); + }, + currentStepInputs() { + return this.currentStepConfig.get('inputs').toJSON(); + }, + currentStepTemplate() { + return this.currentStepConfig.get('template', true); + }, + currentStep() { + return this.currentStepIndex + 1; + }, + stepCount() { + return this.steps.items.length + 1; + }, + progress() { + return Math.ceil((this.currentStep / (this.stepCount + 1)) * 100); + }, + isLastStep() { + return this.currentStep === this.stepCount; + }, + }, + watch: { + isLastStep(value) { + if (value) this.resetHighlight(); + }, + }, + methods: { + resetHighlight() { + this.highlightPath = null; + }, + onUpdate() { + this.showPlaceholder = false; + }, + onEditorUpdate(blob) { + // TODO: In a later iteration, we could add a loopback allowing for + // changes from the editor to flow back into the model + // see https://gitlab.com/gitlab-org/gitlab/-/issues/355312 + this.pipelineBlob = blob; + }, + getPlaceholder() { + const doc = new Document({}); + this.steps.items.forEach((tpl) => { + merge(doc, tpl.get('template').clone()); + }); + return doc; + }, + }, +}; +</script> + +<template> + <div class="row gl-mt-8"> + <main class="col-md-6 gl-pr-8"> + <header class="gl-mb-5"> + <h3 class="text-secondary gl-mt-0" data-testid="step-count"> + {{ sprintf($options.i18n.stepNofN, { currentStep, stepCount }) }} + </h3> + <gl-progress-bar :value="progress" variant="success" /> + </header> + <section class="gl-mb-4"> + <commit-step + v-if="isLastStep" + ref="step" + :default-branch="defaultBranch" + :file-content="pipelineBlob" + :filename="filename" + :project-path="projectPath" + @back="currentStepIndex--" + /> + <wizard-step + v-else + :key="currentStepIndex" + ref="step" + :compiled.sync="compiled" + :has-next-step="currentStepIndex < steps.items.length" + :has-previous-step="currentStepIndex > 0" + :highlight.sync="highlightPath" + :inputs="currentStepInputs" + :template="currentStepTemplate" + @back="currentStepIndex--" + @next="currentStepIndex++" + @update:compiled="onUpdate" + /> + </section> + </main> + <aside class="col-md-6 gl-pt-3"> + <div + class="gl-border-1 gl-border-gray-100 gl-border-solid border-radius-default gl-bg-gray-10" + > + <h6 class="gl-p-2 gl-px-4 text-secondary" data-testid="editor-header"> + {{ sprintf($options.i18n.draft, { filename }) }} + </h6> + <div class="gl-relative gl-overflow-hidden"> + <yaml-editor + :aria-hidden="showPlaceholder" + :doc="showPlaceholder ? placeholder : compiled" + :filename="filename" + :highlight="highlightPath" + class="gl-w-full" + @update:yaml="onEditorUpdate" + /> + <div + v-if="showPlaceholder" + class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 gl-filter-blur-1" + data-testid="placeholder-overlay" + > + <div + class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 bg-white gl-opacity-5 gl-z-index-2" + ></div> + <div + class="gl-relative gl-h-full gl-display-flex gl-align-items-center gl-justify-content-center gl-z-index-3" + > + <div class="gl-max-w-34"> + <h4 data-testid="filename">{{ filename }}</h4> + <p data-testid="description"> + {{ $options.i18n.overlayMessage }} + </p> + </div> + </div> + </div> + </div> + </div> + </aside> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue new file mode 100644 index 00000000000..7200b4e3782 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue @@ -0,0 +1,65 @@ +<script> +import { parseDocument } from 'yaml'; +import WizardWrapper from './components/wrapper.vue'; + +export default { + name: 'PipelineWizard', + components: { + WizardWrapper, + }, + props: { + template: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + defaultFilename: { + type: String, + required: false, + default: '.gitlab-ci.yml', + }, + }, + computed: { + parsedTemplate() { + return this.template ? parseDocument(this.template) : null; + }, + title() { + return this.parsedTemplate?.get('title'); + }, + description() { + return this.parsedTemplate?.get('description'); + }, + filename() { + return this.parsedTemplate?.get('filename') || this.defaultFilename; + }, + steps() { + return this.parsedTemplate?.get('steps'); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-my-8"> + <h2 class="gl-mb-4" data-testid="title">{{ title }}</h2> + <p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description"> + {{ description }} + </p> + </div> + <wizard-wrapper + v-if="steps" + :default-branch="defaultBranch" + :filename="filename" + :project-path="projectPath" + :steps="steps" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/validators.js b/app/assets/javascripts/pipeline_wizard/validators.js new file mode 100644 index 00000000000..57cd56b23a5 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/validators.js @@ -0,0 +1,4 @@ +import { isSeq } from 'yaml'; + +export const isValidStepSeq = (v) => + isSeq(v) && v.items.every((s) => s.get('inputs') && s.get('template')); diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 6a4d1bb44f2..ac97c9d2743 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -174,6 +174,8 @@ export default { }); if (errors.length > 0) { + this.isRetrying = false; + this.reportFailure(POST_FAILURE); } else { await this.$apollo.queries.pipeline.refetch(); @@ -182,6 +184,8 @@ export default { } } } catch { + this.isRetrying = false; + this.reportFailure(POST_FAILURE); } }, diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue index 99fb5c146ba..b45f3e4f32c 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -60,6 +60,15 @@ export default { iid: this.pipelineIid, }; }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + showSkeletonLoader() { + return this.firstLoad && this.loading; + }, + showLoadingSpinner() { + return !this.firstLoad && this.loading; + }, }, mounted() { eventHub.$on('jobActionPerformed', this.handleJobAction); @@ -69,7 +78,7 @@ export default { }, methods: { handleJobAction() { - this.firstLoad = true; + this.firstLoad = false; this.$apollo.queries.jobs.refetch(); }, @@ -98,7 +107,7 @@ export default { <template> <div> - <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> + <div v-if="showSkeletonLoader" class="gl-mt-5"> <gl-skeleton-loader :width="1248" :height="73"> <circle cx="748.031" cy="37.7193" r="15.0307" /> <circle cx="787.241" cy="37.7193" r="15.0307" /> @@ -118,7 +127,7 @@ export default { <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" data-testid="jobs-tab-table" /> <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs"> - <gl-loading-icon v-if="$apollo.loading" size="md" /> + <gl-loading-icon v-if="showLoadingSpinner" size="md" /> </gl-intersection-observer> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 1ce6654e0e9..0380ba646cc 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,33 +1,15 @@ <script> -import { GlEmptyState, GlButton } from '@gitlab/ui'; -import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { getExperimentData } from '~/experimentation/utils'; -import { helpPagePath } from '~/helpers/help_page_helper'; +import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; import PipelinesCiTemplates from './pipelines_ci_templates.vue'; export default { i18n: { - title: s__('Pipelines|Build with confidence'), - description: s__(`Pipelines|GitLab CI/CD can automatically build, - test, and deploy your code. Let GitLab take care of time - consuming tasks, so you can spend more time creating.`), - aboutRunnersBtnText: s__('Pipelines|Learn about Runners'), - installRunnersBtnText: s__('Pipelines|Install GitLab Runners'), - codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'), - codeQualityDescription: s__(`Pipelines|To keep your codebase simple, - readable, and accessible to contributors, use GitLab CI/CD - to analyze your code quality with every push to your project.`), - codeQualityBtnText: s__('Pipelines|Add a code quality job'), noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'), }, name: 'PipelinesEmptyState', components: { GlEmptyState, - GlButton, - GitlabExperiment, PipelinesCiTemplates, }, props: { @@ -39,88 +21,26 @@ export default { type: Boolean, required: true, }, - codeQualityPagePath: { - type: String, - required: false, - default: null, - }, ciRunnerSettingsPath: { type: String, required: false, default: null, }, - }, - computed: { - ciHelpPagePath() { - return helpPagePath('ci/quick_start/index.md'); - }, - isCodeQualityExperimentActive() { - return this.canSetCi && Boolean(getExperimentData('code_quality_walkthrough')); - }, - isCiRunnerTemplatesExperimentActive() { - return this.canSetCi && Boolean(getExperimentData('ci_runner_templates')); - }, - }, - mounted() { - startCodeQualityWalkthrough(); - }, - methods: { - trackClick() { - track('cta_clicked'); - }, - trackCiRunnerTemplatesClick(action) { - const tracking = new ExperimentTracking('ci_runner_templates'); - tracking.event(action); + anyRunnersAvailable: { + type: Boolean, + required: false, + default: true, }, }, }; </script> <template> <div> - <gitlab-experiment v-if="isCodeQualityExperimentActive" name="code_quality_walkthrough"> - <template #control><pipelines-ci-templates /></template> - <template #candidate> - <gl-empty-state - :title="$options.i18n.codeQualityTitle" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.codeQualityDescription" - > - <template #actions> - <gl-button :href="codeQualityPagePath" variant="confirm" @click="trackClick()"> - {{ $options.i18n.codeQualityBtnText }} - </gl-button> - </template> - </gl-empty-state> - </template> - </gitlab-experiment> - <gitlab-experiment v-else-if="isCiRunnerTemplatesExperimentActive" name="ci_runner_templates"> - <template #control><pipelines-ci-templates /></template> - <template #candidate> - <gl-empty-state - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - > - <template #actions> - <gl-button - :href="ciRunnerSettingsPath" - variant="confirm" - @click="trackCiRunnerTemplatesClick('install_runners_button_clicked')" - > - {{ $options.i18n.installRunnersBtnText }} - </gl-button> - <gl-button - :href="ciHelpPagePath" - variant="default" - @click="trackCiRunnerTemplatesClick('learn_button_clicked')" - > - {{ $options.i18n.aboutRunnersBtnText }} - </gl-button> - </template> - </gl-empty-state> - </template> - </gitlab-experiment> - <pipelines-ci-templates v-else-if="canSetCi" /> + <pipelines-ci-templates + v-if="canSetCi" + :ci-runner-settings-path="ciRunnerSettingsPath" + :any-runners-available="anyRunnersAvailable" + /> <gl-empty-state v-else title="" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue new file mode 100644 index 00000000000..40b2454b8c1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue @@ -0,0 +1,170 @@ +<script> +import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { SCHEDULE_ORIGIN } from '../../constants'; + +export default { + components: { + GlBadge, + GlLink, + GlPopover, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + targetProjectFullPath: { + default: '', + }, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + pipelineScheduleUrl: { + type: String, + required: true, + }, + }, + computed: { + isScheduled() { + return this.pipeline.source === SCHEDULE_ORIGIN; + }, + isInFork() { + return Boolean( + this.targetProjectFullPath && + this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, + ); + }, + autoDevopsTagId() { + return `pipeline-url-autodevops-${this.pipeline.id}`; + }, + autoDevopsHelpPath() { + return helpPagePath('topics/autodevops/index.md'); + }, + }, +}; +</script> +<template> + <div class="label-container gl-mt-1"> + <gl-badge + v-if="isScheduled" + v-gl-tooltip + :href="pipelineScheduleUrl" + target="__blank" + :title="__('This pipeline was triggered by a schedule.')" + variant="info" + size="sm" + data-testid="pipeline-url-scheduled" + >{{ __('Scheduled') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.latest" + v-gl-tooltip + :title="__('Latest pipeline for the most recent commit on this branch')" + variant="success" + size="sm" + data-testid="pipeline-url-latest" + >{{ __('latest') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.merge_train_pipeline" + v-gl-tooltip + :title=" + s__( + 'Pipeline|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.', + ) + " + variant="info" + size="sm" + data-testid="pipeline-url-train" + >{{ s__('Pipeline|merge train') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.yaml_errors" + v-gl-tooltip + :title="pipeline.yaml_errors" + variant="danger" + size="sm" + data-testid="pipeline-url-yaml" + >{{ __('yaml invalid') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.failure_reason" + v-gl-tooltip + :title="pipeline.failure_reason" + variant="danger" + size="sm" + data-testid="pipeline-url-failure" + >{{ __('error') }}</gl-badge + > + <template v-if="pipeline.flags.auto_devops"> + <gl-link + :id="autoDevopsTagId" + tabindex="0" + data-testid="pipeline-url-autodevops" + role="button" + > + <gl-badge variant="info" size="sm"> + {{ __('Auto DevOps') }} + </gl-badge> + </gl-link> + <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top"> + <template #title> + <div class="gl-font-weight-normal gl-line-height-normal"> + <gl-sprintf + :message=" + __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', + ) + " + > + <template #strong="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </div> + </template> + <gl-link + :href="autoDevopsHelpPath" + data-testid="pipeline-url-autodevops-link" + target="_blank" + > + {{ __('Learn more about Auto DevOps') }} + </gl-link> + </gl-popover> + </template> + + <gl-badge + v-if="pipeline.flags.stuck" + variant="warning" + size="sm" + data-testid="pipeline-url-stuck" + >{{ __('stuck') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.detached_merge_request_pipeline" + v-gl-tooltip + :title=" + s__( + `Pipeline|This pipeline ran on the contents of this merge request's source branch, not the target branch.`, + ) + " + variant="info" + size="sm" + data-testid="pipeline-url-detached" + >{{ s__('Pipeline|merge request') }}</gl-badge + > + <gl-badge + v-if="isInFork" + v-gl-tooltip + :title="__('Pipeline ran in fork of project')" + variant="info" + size="sm" + data-testid="pipeline-url-fork" + >{{ __('fork') }}</gl-badge + > + </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 7c78abae77f..1dcbd77a92d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,29 +1,20 @@ <script> -import { GlIcon, GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { SCHEDULE_ORIGIN, ICONS } from '../../constants'; +import { ICONS } from '../../constants'; +import PipelineLabels from './pipeline_labels.vue'; export default { components: { GlIcon, GlLink, - GlPopover, - GlSprintf, - GlBadge, + PipelineLabels, TooltipOnTruncate, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], - inject: { - targetProjectFullPath: { - default: '', - }, - }, props: { pipeline: { type: Object, @@ -37,27 +28,8 @@ export default { type: String, required: true, }, - viewType: { - type: String, - required: true, - }, }, computed: { - isScheduled() { - return this.pipeline.source === SCHEDULE_ORIGIN; - }, - isInFork() { - return Boolean( - this.targetProjectFullPath && - this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, - ); - }, - autoDevopsTagId() { - return `pipeline-url-autodevops-${this.pipeline.id}`; - }, - autoDevopsHelpPath() { - return helpPagePath('topics/autodevops/index.md'); - }, mergeRequestRef() { return this.pipeline?.merge_request; }, @@ -139,205 +111,66 @@ export default { commitTitle() { return this.pipeline?.commit?.title; }, - hasAuthor() { - return ( - this.commitAuthor?.avatar_url && this.commitAuthor?.path && this.commitAuthor?.username - ); - }, - userImageAltDescription() { - return this.commitAuthor?.username - ? sprintf(__("%{username}'s avatar"), { username: this.commitAuthor.username }) - : null; - }, - rearrangePipelinesTable() { - return this.glFeatures?.rearrangePipelinesTable; - }, }, }; </script> <template> <div class="pipeline-tags" data-testid="pipeline-url-table-cell"> - <template v-if="rearrangePipelinesTable"> - <div class="commit-title gl-mb-2" data-testid="commit-title-container"> - <span v-if="commitTitle" class="gl-display-flex"> - <tooltip-on-truncate :title="commitTitle" class="flex-truncate-child gl-flex-grow-1"> - <gl-link - :href="commitUrl" - class="commit-row-message gl-text-gray-900" - data-testid="commit-title" - >{{ commitTitle }}</gl-link - > - </tooltip-on-truncate> - </span> - <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span> - </div> - <div class="gl-mb-2"> - <gl-link - :href="pipeline.path" - class="gl-text-decoration-underline gl-text-blue-600!" - data-testid="pipeline-url-link" - data-qa-selector="pipeline_url_link" - > - #{{ pipeline[pipelineKey] }} - </gl-link> - <!--Commit row--> - <div class="icon-container gl-display-inline-block"> - <gl-icon - v-gl-tooltip - :name="commitIcon" - :title="commitIconTooltipTitle" - data-testid="commit-icon-type" - /> - </div> - <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> + <div class="commit-title gl-mb-2" data-testid="commit-title-container"> + <span v-if="commitTitle" class="gl-display-flex"> + <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate"> <gl-link - v-if="mergeRequestRef" - :href="mergeRequestRef.path" - class="ref-name" - data-testid="merge-request-ref" - >{{ mergeRequestRef.iid }}</gl-link + :href="commitUrl" + class="commit-row-message gl-text-gray-900" + data-testid="commit-title" + >{{ commitTitle }}</gl-link > - <gl-link v-else :href="refUrl" class="ref-name" data-testid="commit-ref-name">{{ - commitRef.name - }}</gl-link> </tooltip-on-truncate> + </span> + <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span> + </div> + <div class="gl-mb-2"> + <gl-link + :href="pipeline.path" + class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3" + data-testid="pipeline-url-link" + data-qa-selector="pipeline_url_link" + > + #{{ pipeline[pipelineKey] }} + </gl-link> + <!--Commit row--> + <div class="icon-container gl-display-inline-block gl-mr-1"> <gl-icon v-gl-tooltip - name="commit" - class="commit-icon" - :title="__('Commit')" - data-testid="commit-icon" + :name="commitIcon" + :title="commitIconTooltipTitle" + data-testid="commit-icon-type" /> - - <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{ - commitShortSha - }}</gl-link> - <!--End of commit row--> </div> - </template> - <gl-link - v-if="!rearrangePipelinesTable" - :href="pipeline.path" - class="gl-text-decoration-underline" - data-testid="pipeline-url-link" - data-qa-selector="pipeline_url_link" - > - #{{ pipeline[pipelineKey] }} - </gl-link> - <div class="label-container gl-mt-1"> - <gl-badge - v-if="isScheduled" - v-gl-tooltip - :href="pipelineScheduleUrl" - target="__blank" - :title="__('This pipeline was triggered by a schedule.')" - variant="info" - size="sm" - data-testid="pipeline-url-scheduled" - >{{ __('Scheduled') }}</gl-badge - > - <gl-badge - v-if="pipeline.flags.latest" - v-gl-tooltip - :title="__('Latest pipeline for the most recent commit on this branch')" - variant="success" - size="sm" - data-testid="pipeline-url-latest" - >{{ __('latest') }}</gl-badge - > - <gl-badge - v-if="pipeline.flags.merge_train_pipeline" - v-gl-tooltip - :title="__('This is a merge train pipeline')" - variant="info" - size="sm" - data-testid="pipeline-url-train" - >{{ __('train') }}</gl-badge - > - <gl-badge - v-if="pipeline.flags.yaml_errors" - v-gl-tooltip - :title="pipeline.yaml_errors" - variant="danger" - size="sm" - data-testid="pipeline-url-yaml" - >{{ __('yaml invalid') }}</gl-badge - > - <gl-badge - v-if="pipeline.flags.failure_reason" - v-gl-tooltip - :title="pipeline.failure_reason" - variant="danger" - size="sm" - data-testid="pipeline-url-failure" - >{{ __('error') }}</gl-badge - > - <template v-if="pipeline.flags.auto_devops"> + <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> <gl-link - :id="autoDevopsTagId" - tabindex="0" - data-testid="pipeline-url-autodevops" - role="button" + v-if="mergeRequestRef" + :href="mergeRequestRef.path" + class="ref-name gl-mr-3" + data-testid="merge-request-ref" + >{{ mergeRequestRef.iid }}</gl-link > - <gl-badge variant="info" size="sm"> - {{ __('Auto DevOps') }} - </gl-badge> - </gl-link> - <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top"> - <template #title> - <div class="gl-font-weight-normal gl-line-height-normal"> - <gl-sprintf - :message=" - __( - 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', - ) - " - > - <template #strong="{ content }"> - <b>{{ content }}</b> - </template> - </gl-sprintf> - </div> - </template> - <gl-link - :href="autoDevopsHelpPath" - data-testid="pipeline-url-autodevops-link" - target="_blank" - > - {{ __('Learn more about Auto DevOps') }} - </gl-link> - </gl-popover> - </template> - - <gl-badge - v-if="pipeline.flags.stuck" - variant="warning" - size="sm" - data-testid="pipeline-url-stuck" - >{{ __('stuck') }}</gl-badge - > - <gl-badge - v-if="pipeline.flags.detached_merge_request_pipeline" - v-gl-tooltip - :title=" - __( - 'Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.', - ) - " - variant="info" - size="sm" - data-testid="pipeline-url-detached" - >{{ __('detached') }}</gl-badge - > - <gl-badge - v-if="isInFork" + <gl-link v-else :href="refUrl" class="ref-name gl-mr-3" data-testid="commit-ref-name">{{ + commitRef.name + }}</gl-link> + </tooltip-on-truncate> + <gl-icon v-gl-tooltip - :title="__('Pipeline ran in fork of project')" - variant="info" - size="sm" - data-testid="pipeline-url-fork" - >{{ __('fork') }}</gl-badge - > + 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">{{ + commitShortSha + }}</gl-link> + <!--End of commit row--> </div> + <pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index e7ff5449331..db9dc74863d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -98,19 +98,24 @@ export default { type: String, required: true, }, + defaultBranchName: { + type: String, + required: false, + default: null, + }, params: { type: Object, required: true, }, - codeQualityPagePath: { + ciRunnerSettingsPath: { type: String, required: false, default: null, }, - ciRunnerSettingsPath: { - type: String, + anyRunnersAvailable: { + type: Boolean, required: false, - default: null, + default: true, }, }, data() { @@ -347,6 +352,7 @@ export default { <pipelines-filtered-search class="gl-display-flex gl-flex-grow-1 gl-mr-4" :project-id="projectId" + :default-branch-name="defaultBranchName" :params="validatedParams" @filterPipelines="filterPipelines" /> @@ -380,8 +386,8 @@ export default { v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" - :code-quality-page-path="codeQualityPagePath" :ci-runner-settings-path="ciRunnerSettingsPath" + :any-runners-available="anyRunnersAvailable" /> <gl-empty-state diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue index 83f6356f31a..d50229e47c4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue @@ -1,8 +1,19 @@ <script> -import { GlAvatar, GlButton, GlCard, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { s__, sprintf } from '~/locale'; -import { STARTER_TEMPLATE_NAME } from '~/pipeline_editor/constants'; +import { sprintf } from '~/locale'; +import { + STARTER_TEMPLATE_NAME, + RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, + RUNNERS_SETTINGS_LINK_CLICKED_EVENT, + RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, + RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, + I18N, +} from '~/pipeline_editor/constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { isExperimentVariant } from '~/experimentation/utils'; import Tracking from '~/tracking'; export default { @@ -11,39 +22,37 @@ export default { GlButton, GlCard, GlSprintf, + GlIcon, + GlLink, + GitlabExperiment, }, mixins: [Tracking.mixin()], STARTER_TEMPLATE_NAME, - i18n: { - cta: s__('Pipelines|Use template'), - testTemplates: { - title: s__('Pipelines|Use a sample CI/CD template'), - subtitle: s__( - 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.', - ), - gettingStarted: { - title: s__('Pipelines|Get started with GitLab CI/CD'), - description: s__( - 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a basic 3 stage CI/CD pipeline.', - ), - }, + RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, + RUNNERS_SETTINGS_LINK_CLICKED_EVENT, + RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, + RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, + I18N, + inject: ['pipelineEditorPath', 'suggestedCiTemplates'], + props: { + ciRunnerSettingsPath: { + type: String, + required: false, + default: null, }, - templates: { - title: s__('Pipelines|Use a CI/CD template'), - subtitle: s__( - "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.", - ), - description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + anyRunnersAvailable: { + type: Boolean, + required: false, + default: true, }, }, - inject: ['pipelineEditorPath', 'suggestedCiTemplates'], data() { const templates = this.suggestedCiTemplates.map(({ name, logo }) => { return { name, logo, link: mergeUrlParams({ template: name }, this.pipelineEditorPath), - description: sprintf(this.$options.i18n.templates.description, { name }), + description: sprintf(this.$options.I18N.templates.description, { name }), }; }); @@ -53,39 +62,104 @@ export default { { template: STARTER_TEMPLATE_NAME }, this.pipelineEditorPath, ), + tracker: null, }; }, + computed: { + sharedRunnersHelpPagePath() { + return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' }); + }, + runnersAvailabilitySectionExperimentEnabled() { + return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME); + }, + }, + created() { + this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME); + }, methods: { trackEvent(template) { this.track('template_clicked', { label: template, }); }, + trackExperimentEvent(action) { + this.tracker.event(action); + }, }, }; </script> <template> <div> - <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2> - <p class="gl-text-gray-800 gl-mb-6"> - <gl-sprintf :message="$options.i18n.testTemplates.subtitle"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2> - <div class="row gl-mb-8"> - <div class="col-12"> + <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME"> + <template #candidate> + <div v-if="anyRunnersAvailable"> + <h2 class="gl-font-base gl-text-gray-900"> + <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" /> + {{ $options.I18N.runners.title }} + </h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.I18N.runners.subtitle"> + <template #settingsLink="{ content }"> + <gl-link + data-testid="settings-link" + :href="ciRunnerSettingsPath" + @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)" + >{{ content }}</gl-link + > + </template> + <template #docsLink="{ content }"> + <gl-link + data-testid="documentation-link" + :href="sharedRunnersHelpPagePath" + @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + </div> + + <div v-else> + <h2 class="gl-font-base gl-text-gray-900"> + <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" /> + {{ $options.I18N.noRunners.title }} + </h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p> + <gl-button + data-testid="settings-button" + category="primary" + variant="confirm" + :href="ciRunnerSettingsPath" + @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)" + > + {{ $options.I18N.noRunners.cta }} + </gl-button> + </div> + </template> + </gitlab-experiment> + + <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable"> + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.I18N.learnBasics.subtitle"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> <gl-card> <div class="gl-flex-direction-row"> <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> <div class="gl-mb-3"> - <strong class="gl-text-gray-800 gl-mb-2">{{ - $options.i18n.testTemplates.gettingStarted.title - }}</strong> + <strong class="gl-text-gray-800 gl-mb-2"> + {{ $options.I18N.learnBasics.gettingStarted.title }} + </strong> </div> - <p class="gl-font-sm">{{ $options.i18n.testTemplates.gettingStarted.description }}</p> + <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> </div> <gl-button @@ -95,51 +169,51 @@ export default { data-testid="test-template-link" @click="trackEvent($options.STARTER_TEMPLATE_NAME)" > - {{ $options.i18n.cta }} + {{ $options.I18N.learnBasics.gettingStarted.cta }} </gl-button> </gl-card> </div> - </div> - <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2> - <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p> + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> - <ul class="gl-list-style-none gl-pl-0"> - <li v-for="template in templates" :key="template.name"> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" - > - <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> - <gl-avatar - :src="template.logo" - :size="64" - class="gl-mr-6 gl-bg-white dark-mode-override" - shape="rect" - :alt="template.name" - data-testid="template-logo" - /> - <div class="gl-flex-direction-row"> - <div class="gl-mb-3"> - <strong class="gl-text-gray-800" data-testid="template-name">{{ - template.name - }}</strong> + <ul class="gl-list-style-none gl-pl-0"> + <li v-for="template in templates" :key="template.name"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" + > + <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> + <gl-avatar + :src="template.logo" + :size="48" + class="gl-mr-5 gl-bg-white dark-mode-override" + shape="rect" + :alt="template.name" + data-testid="template-logo" + /> + <div class="gl-flex-direction-row"> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800" data-testid="template-name"> + {{ template.name }} + </strong> + </div> + <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> + {{ template.description }} + </p> </div> - <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> - {{ template.description }} - </p> </div> + <gl-button + category="primary" + variant="confirm" + :href="template.link" + data-testid="template-link" + @click="trackEvent(template.name)" + > + {{ $options.I18N.templates.cta }} + </gl-button> </div> - <gl-button - category="primary" - variant="confirm" - :href="template.link" - data-testid="template-link" - @click="trackEvent(template.name)" - > - {{ $options.i18n.cta }} - </gl-button> - </div> - </li> - </ul> + </li> + </ul> + </template> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue deleted file mode 100644 index cc676883c1d..00000000000 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { CHILD_VIEW } from '~/pipelines/constants'; -import CommitComponent from '~/vue_shared/components/commit.vue'; - -export default { - components: { - CommitComponent, - }, - props: { - pipeline: { - type: Object, - required: true, - }, - viewType: { - type: String, - required: true, - }, - }, - computed: { - commitAuthor() { - let commitAuthorInformation; - - if (!this.pipeline || !this.pipeline.commit) { - return null; - } - - // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline.commit.author) { - // 2. if person who is an author of a commit is a GitLab user - // they can have a GitLab avatar - if (this.pipeline.commit.author.avatar_url) { - commitAuthorInformation = this.pipeline.commit.author; - - // 3. If GitLab user does not have avatar, they might have a Gravatar - } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = { - ...this.pipeline.commit.author, - avatar_url: this.pipeline.commit.author_gravatar_url, - }; - } - // 4. If committer is not a GitLab User, they can have a Gravatar - } else { - commitAuthorInformation = { - avatar_url: this.pipeline.commit.author_gravatar_url, - path: `mailto:${this.pipeline.commit.author_email}`, - username: this.pipeline.commit.author_name, - }; - } - - return commitAuthorInformation; - }, - commitTag() { - return this.pipeline?.ref?.tag; - }, - commitRef() { - return this.pipeline?.ref; - }, - commitUrl() { - return this.pipeline?.commit?.commit_path; - }, - commitShortSha() { - return this.pipeline?.commit?.short_id; - }, - commitTitle() { - return this.pipeline?.commit?.title; - }, - isChildView() { - return this.viewType === CHILD_VIEW; - }, - }, -}; -</script> - -<template> - <commit-component - :tag="commitTag" - :commit-ref="commitRef" - :commit-url="commitUrl" - :merge-request-ref="pipeline.merge_request" - :short-sha="commitShortSha" - :title="commitTitle" - :author="commitAuthor" - :show-ref-info="!isChildView" - /> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 2dfdaa0ea28..4d28545a035 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -24,6 +24,11 @@ export default { type: String, required: true, }, + defaultBranchName: { + type: String, + required: false, + default: null, + }, params: { type: Object, required: true, @@ -57,6 +62,7 @@ export default { token: PipelineBranchNameToken, operators: OPERATOR_IS_ONLY, projectId: this.projectId, + defaultBranchName: this.defaultBranchName, disabled: this.selectedTypes.includes(this.$options.tagType), }, { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index 54901c2d13f..e765a8cd86c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -1,18 +1,13 @@ <script> -import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue'; -import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants'; import { CHILD_VIEW } from '~/pipelines/constants'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PipelinesTimeago from './time_ago.vue'; export default { components: { - CodeQualityWalkthrough, CiBadge, PipelinesTimeago, }, - mixins: [glFeatureFlagsMixin()], props: { pipeline: { type: Object, @@ -30,23 +25,6 @@ export default { isChildView() { return this.viewType === CHILD_VIEW; }, - shouldRenderCodeQualityWalkthrough() { - return Object.values(PIPELINE_STATUSES).includes(this.pipelineStatus.group); - }, - codeQualityStep() { - const prefix = [PIPELINE_STATUSES.successWithWarnings, PIPELINE_STATUSES.failed].includes( - this.pipelineStatus.group, - ) - ? 'failed' - : this.pipelineStatus.group; - return `${prefix}_pipeline`; - }, - codeQualityBuildPath() { - return this.pipeline?.details?.code_quality_build_path; - }, - rearrangePipelinesTable() { - return this.glFeatures?.rearrangePipelinesTable; - }, }, }; </script> @@ -54,18 +32,12 @@ export default { <template> <div> <ci-badge - id="js-code-quality-walkthrough" class="gl-mb-3" :status="pipelineStatus" :show-text="!isChildView" :icon-classes="'gl-vertical-align-middle!'" data-qa-selector="pipeline_commit_status" /> - <pipelines-timeago v-if="rearrangePipelinesTable" class="gl-mt-3" :pipeline="pipeline" /> - <code-quality-walkthrough - v-if="shouldRenderCodeQualityWalkthrough" - :step="codeQualityStep" - :link="codeQualityBuildPath" - /> + <pipelines-timeago class="gl-mt-3" :pipeline="pipeline" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 9919a18cb99..6f0e67e1ae0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,16 +1,13 @@ <script> import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import PipelineMiniGraph from './pipeline_mini_graph.vue'; import PipelineOperations from './pipeline_operations.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelineUrl from './pipeline_url.vue'; -import PipelinesCommit from './pipelines_commit.vue'; import PipelinesStatusBadge from './pipelines_status_badge.vue'; -import PipelinesTimeago from './time_ago.vue'; const DEFAULT_TD_CLASS = 'gl-p-5!'; const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; @@ -22,19 +19,57 @@ export default { GlTableLite, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), - PipelinesCommit, PipelineMiniGraph, PipelineOperations, PipelinesStatusBadge, PipelineStopModal, - PipelinesTimeago, 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: [glFeatureFlagMixin()], props: { pipelines: { type: Array, @@ -67,76 +102,6 @@ export default { cancelingPipeline: null, }; }, - computed: { - tableFields() { - const fields = [ - { - key: 'status', - label: s__('Pipeline|Status'), - thClass: DEFAULT_TH_CLASSES, - columnClass: this.rearrangePipelinesTable ? 'gl-w-15p' : 'gl-w-10p', - tdClass: DEFAULT_TD_CLASS, - thAttr: { 'data-testid': 'status-th' }, - }, - { - key: 'pipeline', - label: this.rearrangePipelinesTable ? __('Pipeline') : this.pipelineKeyOption.label, - thClass: DEFAULT_TH_CLASSES, - tdClass: this.rearrangePipelinesTable - ? `${DEFAULT_TD_CLASS}` - : `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: this.rearrangePipelinesTable ? 'gl-w-30p' : 'gl-w-10p', - 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: 'commit', - label: s__('Pipeline|Commit'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-20p', - thAttr: { 'data-testid': 'commit-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: 'timeago', - label: s__('Pipeline|Duration'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: this.rearrangePipelinesTable ? 'gl-w-5p' : 'gl-w-15p', - thAttr: { 'data-testid': 'timeago-th' }, - }, - { - key: 'actions', - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', - thAttr: { 'data-testid': 'actions-th' }, - }, - ]; - - return !this.rearrangePipelinesTable - ? fields - : fields.filter((field) => !['commit', 'timeago'].includes(field.key)); - }, - rearrangePipelinesTable() { - return this.glFeatures?.rearrangePipelinesTable; - }, - }, watch: { pipelines() { this.cancelingPipeline = null; @@ -167,7 +132,7 @@ export default { <template> <div class="ci-table"> <gl-table-lite - :fields="tableFields" + :fields="$options.tableFields" :items="pipelines" tbody-tr-class="commit" :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }" @@ -192,7 +157,6 @@ export default { :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" :pipeline-key="pipelineKeyOption.key" - :view-type="viewType" /> </template> @@ -200,10 +164,6 @@ export default { <pipeline-triggerer :pipeline="item" /> </template> - <template #cell(commit)="{ item }"> - <pipelines-commit :pipeline="item" :view-type="viewType" /> - </template> - <template #cell(stages)="{ item }"> <div class="stage-cell"> <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 --> @@ -229,10 +189,6 @@ export default { </div> </template> - <template #cell(timeago)="{ item }"> - <pipelines-timeago :pipeline="item" /> - </template> - <template #cell(actions)="{ item }"> <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" /> </template> 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 c45e3f24567..cde963e4051 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,6 +1,5 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { @@ -8,7 +7,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { GlIcon }, - mixins: [timeagoMixin, glFeatureFlagMixin()], + mixins: [timeagoMixin], props: { pipeline: { type: Object, @@ -54,14 +53,11 @@ export default { showSkipped() { return !this.duration && !this.finishedTime && this.skipped; }, - shouldDisplayAsBlock() { - return this.glFeatures?.rearrangePipelinesTable; - }, }, }; </script> <template> - <div class="{ 'gl-display-block': shouldDisplayAsBlock }"> + <div class="gl-display-block"> <span v-if="showInProgress" data-testid="pipeline-in-progress"> <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" /> <gl-icon diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index 5409e68cdc4..1db2898b72a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -35,6 +35,13 @@ export default { Api.branches(this.config.projectId, searchterm) .then(({ data }) => { this.branches = data.map((branch) => branch.name); + if (!searchterm && this.config.defaultBranchName) { + // Shift the default branch to the top of the list + this.branches = this.branches.filter( + (branch) => branch !== this.config.defaultBranchName, + ); + this.branches.unshift(this.config.defaultBranchName); + } this.loading = false; }) .catch((err) => { diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index bfb95e5ab0c..801f71cb364 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -69,9 +69,7 @@ export default async function initPipelineDetailsBundle() { } try { - if (gon.features?.jobsTabVue) { - createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); - } + createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); } catch { createFlash({ message: __('An error occurred while loading the Jobs tab.'), diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index c4c2b5f2927..f4d9a44a754 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -36,9 +36,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { ciLintPath, resetCachePath, projectId, + defaultBranchName, params, - codeQualityPagePath, ciRunnerSettingsPath, + anyRunnersAvailable, } = el.dataset; return new Vue({ @@ -75,9 +76,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { ciLintPath, resetCachePath, projectId, + defaultBranchName, params: JSON.parse(params), - codeQualityPagePath, ciRunnerSettingsPath, + anyRunnersAvailable: parseBoolean(anyRunnersAvailable), }, }); }, diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index ff9b47cdcd6..25fefff219c 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import createFlash from '~/flash'; +import createFlash, { FLASH_TYPES } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import { Rails } from '~/lib/utils/rails_ujs'; @@ -86,7 +86,7 @@ export default class Profile { createFlash({ message: data.message, - type: 'notice', + type: data.status === 'error' ? FLASH_TYPES.ALERT : FLASH_TYPES.NOTICE, }); }) .then(() => { diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 94d32609e5d..28b77f6defd 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -11,7 +11,13 @@ const apolloProvider = new VueApollo({ }); const mountPipelineChartsApp = (el) => { - const { projectPath, failedPipelinesLink, coverageChartPath, defaultBranch } = el.dataset; + const { + projectPath, + failedPipelinesLink, + coverageChartPath, + defaultBranch, + testRunsEmptyStateImagePath, + } = el.dataset; const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary); @@ -30,6 +36,7 @@ const mountPipelineChartsApp = (el) => { shouldRenderQualitySummary, coverageChartPath, defaultBranch, + testRunsEmptyStateImagePath, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 62e2cec874a..f1b7e3df7d6 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -120,15 +120,6 @@ const bindHowToImport = () => { }); }); }); - - $('.how_to_import_link').on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).next('.modal').show(); - }); - - $('.modal-header .close').on('click', () => { - $('.modal').hide(); - }); }; const bindEvents = () => { @@ -153,8 +144,8 @@ const bindEvents = () => { bindHowToImport(); - $('.btn_import_gitlab_project').on('click', () => { - const importHref = $('a.btn_import_gitlab_project').attr('href'); + $('.btn_import_gitlab_project').on('click contextmenu', () => { + const importHref = $('a.btn_import_gitlab_project').attr('data-href'); $('.btn_import_gitlab_project').attr( 'href', `${importHref}?namespace_id=${$( diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index d4b52860261..16eb5c3de32 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -5,6 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import AccessDropdown from '~/projects/settings/access_dropdown'; +import { initToggle } from '~/toggles'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchCreate { @@ -15,25 +16,18 @@ export default class ProtectedBranchCreate { this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); this.currentProjectUserDefaults = {}; this.buildDropdowns(); - this.$forcePushToggle = this.$form.find('.js-force-push-toggle'); - this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle'); - this.bindEvents(); - } - bindEvents() { - this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this)); + this.forcePushToggle = initToggle(document.querySelector('.js-force-push-toggle')); + if (this.hasLicense) { - this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this)); + this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle')); } - this.$form.on('submit', this.onFormSubmit.bind(this)); - } - onForcePushToggleClick() { - this.$forcePushToggle.toggleClass('is-checked'); + this.bindEvents(); } - onCodeOwnerToggleClick() { - this.$codeOwnerToggle.toggleClass('is-checked'); + bindEvents() { + this.$form.on('submit', this.onFormSubmit.bind(this)); } buildDropdowns() { @@ -92,8 +86,8 @@ export default class ProtectedBranchCreate { authenticity_token: this.$form.find('input[name="authenticity_token"]').val(), protected_branch: { name: this.$form.find('input[name="protected_branch[name]"]').val(), - allow_force_push: this.$forcePushToggle.hasClass('is-checked'), - code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'), + allow_force_push: this.forcePushToggle.value, + code_owner_approval_required: this.codeOwnerToggle?.value ?? false, }, }; diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 86273cfdda6..15e706e38c6 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -3,6 +3,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import AccessDropdown from '~/projects/settings/access_dropdown'; +import { initToggle } from '~/toggles'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchEdit { @@ -14,8 +15,6 @@ export default class ProtectedBranchEdit { this.$wrap = options.$wrap; this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); - this.$forcePushToggle = this.$wrap.find('.js-force-push-toggle'); - this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle'); this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest( `.${ACCESS_LEVELS.MERGE}-container`, @@ -25,36 +24,47 @@ export default class ProtectedBranchEdit { ); this.buildDropdowns(); - this.bindEvents(); + this.initToggles(); } - bindEvents() { - this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this)); - if (this.hasLicense) { - this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this)); + initToggles() { + const wrap = this.$wrap.get(0); + + const forcePushToggle = initToggle(wrap.querySelector('.js-force-push-toggle')); + if (forcePushToggle) { + forcePushToggle.$on('change', (value) => { + forcePushToggle.isLoading = true; + forcePushToggle.disabled = true; + this.updateProtectedBranch( + { + allow_force_push: value, + }, + () => { + forcePushToggle.isLoading = false; + forcePushToggle.disabled = false; + }, + ); + }); } - } - - onForcePushToggleClick() { - this.$forcePushToggle.toggleClass('is-checked'); - this.$forcePushToggle.prop('disabled', true); - - const formData = { - allow_force_push: this.$forcePushToggle.hasClass('is-checked'), - }; - - this.updateProtectedBranch(formData, () => this.$forcePushToggle.prop('disabled', false)); - } - onCodeOwnerToggleClick() { - this.$codeOwnerToggle.toggleClass('is-checked'); - this.$codeOwnerToggle.prop('disabled', true); - - const formData = { - code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'), - }; - - this.updateProtectedBranch(formData, () => this.$codeOwnerToggle.prop('disabled', false)); + if (this.hasLicense) { + const codeOwnerToggle = initToggle(wrap.querySelector('.js-code-owner-toggle')); + if (codeOwnerToggle) { + codeOwnerToggle.$on('change', (value) => { + codeOwnerToggle.isLoading = true; + codeOwnerToggle.disabled = true; + this.updateProtectedBranch( + { + code_owner_approval_required: value, + }, + () => { + codeOwnerToggle.isLoading = false; + codeOwnerToggle.disabled = false; + }, + ); + }); + } + } } updateProtectedBranch(formData, callback) { diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index ce781c64006..d02526160fd 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -58,6 +58,11 @@ export default { required: false, default: () => ({}), }, + useSymbolicRefNames: { + type: Boolean, + required: false, + default: false, + }, /** The validation state of this component. */ state: { @@ -121,8 +126,15 @@ export default { query: this.lastQuery, }; }, + selectedRefForDisplay() { + if (this.useSymbolicRefNames && this.selectedRef) { + return this.selectedRef.replace(/^refs\/(tags|heads)\//, ''); + } + + return this.selectedRef; + }, buttonText() { - return this.selectedRef || this.i18n.noRefSelected; + return this.selectedRefForDisplay || this.i18n.noRefSelected; }, }, watch: { @@ -164,9 +176,20 @@ export default { }, { immediate: true }, ); + + this.$watch( + 'useSymbolicRefNames', + () => this.setUseSymbolicRefNames(this.useSymbolicRefNames), + { immediate: true }, + ); }, methods: { - ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']), + ...mapActions([ + 'setEnabledRefTypes', + 'setUseSymbolicRefNames', + 'setProjectId', + 'setSelectedRef', + ]), ...mapActions({ storeSearch: 'search' }), focusSearchBox() { this.$refs.searchBox.$el.querySelector('input').focus(); diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js index 3832cc0c21d..a6019f21e73 100644 --- a/app/assets/javascripts/ref/stores/actions.js +++ b/app/assets/javascripts/ref/stores/actions.js @@ -5,6 +5,9 @@ import * as types from './mutation_types'; export const setEnabledRefTypes = ({ commit }, refTypes) => commit(types.SET_ENABLED_REF_TYPES, refTypes); +export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) => + commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames); + export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); export const setSelectedRef = ({ commit }, selectedRef) => diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js index c26f4fa00c7..4c602908cae 100644 --- a/app/assets/javascripts/ref/stores/mutation_types.js +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -1,4 +1,5 @@ export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES'; +export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES'; export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_SELECTED_REF = 'SET_SELECTED_REF'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js index f91cbae8462..e078d3333d4 100644 --- a/app/assets/javascripts/ref/stores/mutations.js +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -7,6 +7,9 @@ export default { [types.SET_ENABLED_REF_TYPES](state, refTypes) { state.enabledRefTypes = refTypes; }, + [types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) { + state.useSymbolicRefNames = useSymbolicRefNames; + }, [types.SET_PROJECT_ID](state, projectId) { state.projectId = projectId; }, @@ -28,6 +31,7 @@ export default { state.matches.branches = { list: convertObjectPropsToCamelCase(response.data).map((b) => ({ name: b.name, + value: state.useSymbolicRefNames ? `refs/heads/${b.name}` : undefined, default: b.default, })), totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), @@ -46,6 +50,7 @@ export default { state.matches.tags = { list: convertObjectPropsToCamelCase(response.data).map((b) => ({ name: b.name, + value: state.useSymbolicRefNames ? `refs/tags/${b.name}` : undefined, })), totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), error: null, 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 bc97fab9ad2..eeb4c254a1b 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -85,6 +85,16 @@ export default { required: false, default: true, }, + autoCompleteEpics: { + type: Boolean, + required: false, + default: true, + }, + autoCompleteIssues: { + type: Boolean, + required: false, + default: true, + }, }, computed: { hasRelatedIssues() { @@ -198,6 +208,8 @@ export default { :input-value="inputValue" :pending-references="pendingReferences" :auto-complete-sources="autoCompleteSources" + :auto-complete-epics="autoCompleteEpics" + :auto-complete-issues="autoCompleteIssues" :path-id-separator="pathIdSeparator" @pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event)" @addIssuableFormInput="$emit('addIssuableFormInput', $event)" @@ -210,6 +222,7 @@ export default { <related-issues-list v-for="category in categorisedIssues" :key="category.linkType" + :list-link-type="category.linkType" :heading="$options.linkedIssueTypesTextMap[category.linkType]" :can-admin="canAdmin" :can-reorder="canReorder" 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 8b39851405e..174049b15fe 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -21,6 +21,11 @@ export default { required: false, default: false, }, + listLinkType: { + type: String, + required: false, + default: '', + }, heading: { type: String, required: false, @@ -91,7 +96,7 @@ export default { </script> <template> - <div> + <div :data-link-type="listLinkType"> <h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4> <div class="related-issues-token-body bordered-box bg-white" diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index 7e2fda8495c..40d58c04753 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -71,6 +71,16 @@ export default { required: false, default: true, }, + autoCompleteEpics: { + type: Boolean, + required: false, + default: true, + }, + autoCompleteIssues: { + type: Boolean, + required: false, + default: true, + }, pathIdSeparator: { type: String, required: false, @@ -241,6 +251,8 @@ export default { :is-form-visible="isFormVisible" :input-value="inputValue" :auto-complete-sources="autoCompleteSources" + :auto-complete-epics="autoCompleteEpics" + :auto-complete-issues="autoCompleteIssues" :issuable-type="issuableType" :path-id-separator="pathIdSeparator" :show-categorized-issues="showCategorizedIssues" diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js index 35858be90b2..b61f1cf2470 100644 --- a/app/assets/javascripts/related_issues/index.js +++ b/app/assets/javascripts/related_issues/index.js @@ -21,6 +21,7 @@ export default function initRelatedIssues() { showCategorizedIssues: parseBoolean( relatedIssuesRootElement.dataset.showCategorizedIssues, ), + autoCompleteEpics: false, }, }), }); diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index c2c91f406a1..e53bfea7389 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -68,7 +68,7 @@ export default { :href="newReleasePath" :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" - variant="success" + variant="confirm" data-testid="new-release-button" > {{ __('New release') }} diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index b9601428850..b81da399a7b 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -56,6 +56,9 @@ export default { hasDuplicateUrl(link) { return Boolean(this.getLinkErrors(link).isDuplicate); }, + hasDuplicateName(link) { + return Boolean(this.getLinkErrors(link).isTitleDuplicate); + }, hasBadFormat(link) { return Boolean(this.getLinkErrors(link).isBadFormat); }, @@ -72,7 +75,7 @@ export default { return !this.hasDuplicateUrl(link) && !this.hasBadFormat(link) && !this.hasEmptyUrl(link); }, isNameValid(link) { - return !this.hasEmptyName(link); + return !this.hasEmptyName(link) && !this.hasDuplicateName(link); }, /** @@ -121,7 +124,7 @@ export default { <p> {{ __( - 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed.', + 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Each URL and link title must be unique.', ) }} </p> @@ -165,7 +168,7 @@ export default { </gl-sprintf> </span> <span v-else-if="hasDuplicateUrl(link)" class="invalid-feedback d-inline"> - {{ __('This URL is already used for another link; duplicate URLs are not allowed') }} + {{ __('This URL already exists.') }} </span> </template> </gl-form-group> @@ -191,6 +194,9 @@ export default { <span v-if="hasEmptyName(link)" class="invalid-feedback d-inline"> {{ __('Link title is required') }} </span> + <span v-else-if="hasDuplicateName(link)" class="invalid-feedback d-inline"> + {{ __('This title already exists.') }} + </span> </template> </gl-form-group> diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 576f099248e..b3ba4f9263a 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -162,7 +162,7 @@ const createReleaseLink = async ({ state, link }) => { input: { projectPath: state.projectPath, tagName: state.tagName, - name: link.name, + name: link.name.trim(), url: link.url, linkType: link.linkType.toUpperCase(), directAssetPath: link.directAssetPath, diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index d83ec05872a..d4f49e53619 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -1,5 +1,6 @@ import { isEmpty } from 'lodash'; import { hasContent } from '~/lib/utils/text_utility'; +import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility'; /** * @returns {Boolean} `true` if the app is editing an existing release. @@ -95,6 +96,17 @@ export const validationErrors = (state) => { } }); + // check for duplicated Link Titles + const linkTitles = state.release.assets.links.map((link) => link.name.trim()); + const duplicatedTitles = getDuplicateItemsFromArray(linkTitles); + + // add a validation error for each link that shares Link Title + state.release.assets.links.forEach((link) => { + if (hasContent(link.name) && duplicatedTitles.includes(link.name.trim())) { + errors.assets.links[link.id].isTitleDuplicate = true; + } + }); + return errors; }; @@ -131,7 +143,7 @@ export const releaseCreateMutatationVariables = (state, getters) => { ref: state.createFrom, assets: { links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({ - name, + name: name.trim(), url, linkType: linkType.toUpperCase(), })), diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/reports/codequality_report/constants.js index 502977e714c..0c472b24471 100644 --- a/app/assets/javascripts/reports/codequality_report/constants.js +++ b/app/assets/javascripts/reports/codequality_report/constants.js @@ -15,3 +15,17 @@ export const SEVERITY_ICONS = { blocker: 'severity-critical', unknown: 'severity-unknown', }; + +// This is the icons mapping for the code Quality Merge-Request Widget Extension +// once the refactor_mr_widgets_extensions flag is activated the above SEVERITY_ICONS +// need be removed and this variable needs to be rename to SEVERITY_ICONS +// Rollout Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341759 + +export const SEVERITY_ICONS_EXTENSION = { + info: 'severityInfo', + minor: 'severityLow', + major: 'severityMedium', + critical: 'severityHigh', + blocker: 'severityCritical', + unknown: 'severityUnknown', +}; diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 53273aeff33..bad6fa1e7b9 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -18,6 +18,7 @@ export const ICON_WARNING = 'warning'; export const ICON_SUCCESS = 'success'; export const ICON_NOTFOUND = 'notfound'; export const ICON_PENDING = 'pending'; +export const ICON_FAILED = 'failed'; export const status = { LOADING, diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 857795c71b0..d79ccde61a8 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -7,6 +7,8 @@ import getRefMixin from '../mixins/get_ref'; import DeleteBlobModal from './delete_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue'; +const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob'; + export default { i18n: { replace: __('Replace'), @@ -76,9 +78,6 @@ export default { }, }, computed: { - replaceModalId() { - return uniqueId('replace-modal'); - }, replaceModalTitle() { return sprintf(__('Replace %{name}'), { name: this.name }); }, @@ -95,13 +94,14 @@ export default { methods: { showModal(modalId) { if (this.showForkSuggestion) { - this.$emit('fork'); + this.$emit('fork', 'view'); return; } this.$refs[modalId].show(); }, }, + replaceBlobModalId: REPLACE_BLOB_MODAL_ID, }; </script> @@ -118,7 +118,7 @@ export default { data-testid="lock" :data-qa-selector="lockBtnQASelector" /> - <gl-button data-testid="replace" @click="showModal(replaceModalId)"> + <gl-button data-testid="replace" @click="showModal($options.replaceBlobModalId)"> {{ $options.i18n.replace }} </gl-button> <gl-button data-testid="delete" @click="showModal(deleteModalId)"> @@ -126,8 +126,8 @@ export default { </gl-button> </gl-button-group> <upload-blob-modal - :ref="replaceModalId" - :modal-id="replaceModalId" + :ref="$options.replaceBlobModalId" + :modal-id="$options.replaceBlobModalId" :modal-title="replaceModalTitle" :commit-message="replaceModalTitle" :target-branch="targetBranch || ref" diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 52963b49f68..85652301f4d 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -10,11 +10,14 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import CodeIntelligence from '~/code_navigation/components/app.vue'; import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; +import userInfoQuery from '../queries/user_info.query.graphql'; +import applicationInfoQuery from '../queries/application_info.query.graphql'; import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants'; import BlobButtonGroup from './blob_button_group.vue'; -import BlobEdit from './blob_edit.vue'; import ForkSuggestion from './fork_suggestion.vue'; import { loadViewer } from './blob_viewers'; @@ -24,12 +27,13 @@ export default { }, components: { BlobHeader, - BlobEdit, BlobButtonGroup, BlobContent, GlLoadingIcon, GlButton, ForkSuggestion, + WebIdeLink, + CodeIntelligence, }, mixins: [getRefMixin, glFeatureFlagMixin()], inject: { @@ -38,6 +42,18 @@ export default { }, }, apollo: { + gitpodEnabled: { + query: applicationInfoQuery, + error() { + this.displayError(); + }, + }, + currentUser: { + query: userInfoQuery, + error() { + this.displayError(); + }, + }, project: { query: blobInfoQuery, variables() { @@ -78,8 +94,11 @@ export default { legacySimpleViewer: null, isBinary: false, isLoadingLegacyViewer: false, + isRenderingLegacyTextViewer: false, activeViewerType: SIMPLE_BLOB_VIEWER, - project: DEFAULT_BLOB_INFO, + project: DEFAULT_BLOB_INFO.project, + gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled, + currentUser: DEFAULT_BLOB_INFO.currentUser, }; }, computed: { @@ -142,9 +161,13 @@ export default { return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject; }, forkPath() { - return this.forkTarget === 'ide' - ? this.blobInfo.ideForkAndEditPath - : this.blobInfo.forkAndEditPath; + const forkPaths = { + ide: this.blobInfo.ideForkAndEditPath, + simple: this.blobInfo.forkAndEditPath, + view: this.blobInfo.forkAndViewPath, + }; + + return forkPaths[this.forkTarget]; }, isUsingLfs() { return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; @@ -163,7 +186,13 @@ export default { .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`) .then(({ data: { html, binary } }) => { if (type === SIMPLE_BLOB_VIEWER) { + this.isRenderingLegacyTextViewer = true; + this.legacySimpleViewer = html; + + window.requestIdleCallback(() => { + this.isRenderingLegacyTextViewer = false; + }); } else { this.legacyRichViewer = html; } @@ -213,26 +242,25 @@ export default { @viewer-changed="switchViewer" > <template #actions> - <blob-edit + <web-ide-link v-if="!blobInfo.archived" :show-edit-button="!isBinaryFileType" - :edit-path="blobInfo.editBlobPath" - :web-ide-path="blobInfo.ideEditPath" + class="gl-mr-3" + :edit-url="blobInfo.editBlobPath" + :web-ide-url="blobInfo.ideEditPath" :needs-to-fork="showForkSuggestion" + :show-pipeline-editor-button="Boolean(blobInfo.pipelineEditorPath)" + :pipeline-editor-url="blobInfo.pipelineEditorPath" + :gitpod-url="blobInfo.gitpodBlobUrl" + :show-gitpod-button="gitpodEnabled" + :gitpod-enabled="currentUser && currentUser.gitpodEnabled" + :user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath" + :user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath" + is-blob + disable-fork-modal @edit="editBlob" /> - <gl-button - v-if="blobInfo.pipelineEditorPath" - class="gl-mr-3" - category="secondary" - variant="confirm" - data-testid="pipeline-editor" - :href="blobInfo.pipelineEditorPath" - > - {{ $options.i18n.pipelineEditor }} - </gl-button> - <blob-button-group v-if="isLoggedIn && !blobInfo.archived" :path="path" @@ -246,7 +274,7 @@ export default { :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" :show-fork-suggestion="showForkSuggestion" - @fork="setForkTarget('ide')" + @fork="setForkTarget('view')" /> </template> </blob-header> @@ -265,8 +293,15 @@ export default { :active-viewer="viewer" :hide-line-numbers="true" :loading="isLoadingLegacyViewer" + :data-loading="isRenderingLegacyTextViewer" /> <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" /> + <code-intelligence + v-if="blobViewer || legacyViewerLoaded" + :code-navigation-path="blobInfo.codeNavigationPath" + :blob-path="blobInfo.path" + :path-prefix="blobInfo.projectBlobPathRoot" + /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue deleted file mode 100644 index 69e2bd563c9..00000000000 --- a/app/assets/javascripts/repository/components/blob_edit.vue +++ /dev/null @@ -1,78 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; -import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -export default { - i18n: { - edit: __('Edit'), - webIde: __('Web IDE'), - }, - components: { - GlButton, - WebIdeLink, - }, - mixins: [glFeatureFlagsMixin()], - props: { - showEditButton: { - type: Boolean, - required: true, - }, - editPath: { - type: String, - required: true, - }, - webIdePath: { - type: String, - required: true, - }, - needsToFork: { - type: Boolean, - required: false, - default: false, - }, - }, - methods: { - onEdit(target) { - this.$emit('edit', target); - }, - }, -}; -</script> - -<template> - <web-ide-link - v-if="glFeatures.consolidatedEditButton" - :show-edit-button="showEditButton" - class="gl-mr-3" - :edit-url="editPath" - :web-ide-url="webIdePath" - :needs-to-fork="needsToFork" - :is-blob="true" - disable-fork-modal - @edit="onEdit" - /> - <div v-else> - <gl-button - v-if="showEditButton" - class="gl-mr-2" - category="primary" - variant="confirm" - data-testid="edit" - @click="onEdit('simple')" - > - {{ $options.i18n.edit }} - </gl-button> - - <gl-button - class="gl-mr-3" - category="primary" - variant="confirm" - data-testid="web-ide" - @click="onEdit('ide')" - > - {{ $options.i18n.webIde }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue new file mode 100644 index 00000000000..048730c02c1 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue @@ -0,0 +1,20 @@ +<script> +export default { + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + src: this.blob.rawPath, + }; + }, +}; +</script> +<template> + <div class="gl-text-center gl-p-7"> + <audio :src="src" controls data-testid="audio"></audio> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue new file mode 100644 index 00000000000..86a0bb9fad0 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue @@ -0,0 +1,26 @@ +<script> +import CsvViewer from '~/blob/csv/csv_viewer.vue'; + +export default { + components: { + CsvViewer, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + url: this.blob.rawPath, + }; + }, +}; +</script> + +<template> + <div> + <csv-viewer :csv="url" remote-file data-testid="csv" /> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue index f7b318c64d9..be5e9685ccd 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue @@ -17,7 +17,7 @@ export default { data() { return { fileName: this.blob.name, - filePath: this.blob.rawPath, + filePath: this.blob.externalStorageUrl || this.blob.rawPath, fileSize: this.blob.rawSize || 0, }; }, diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue index 5027f7877aa..014f1abc121 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue @@ -16,6 +16,6 @@ export default { </script> <template> <div class="gl-text-center gl-p-7 gl-bg-gray-50"> - <img :src="url" :alt="alt" data-testid="image" /> + <img :src="url" :alt="alt" data-testid="image" class="gl-max-w-full" /> </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index e942f59e7d8..cbe18ea396e 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -1,4 +1,5 @@ const viewers = { + csv: () => import('./csv_viewer.vue'), download: () => import('./download_viewer.vue'), image: () => import('./image_viewer.vue'), video: () => import('./video_viewer.vue'), @@ -6,6 +7,7 @@ const viewers = { 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'), }; export const loadViewer = (type, isUsingLfs) => { diff --git a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue index 6dc7e10662e..9d39764e9a4 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue @@ -21,7 +21,7 @@ export default { data() { return { fileName: this.blob.name, - filePath: this.blob.rawPath, + filePath: this.blob.externalStorageUrl || this.blob.rawPath, }; }, }; diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue index c3df5984426..37c8f636757 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue @@ -18,7 +18,7 @@ export default { }, data() { return { - url: this.blob.rawPath, + url: this.blob.externalStorageUrl || this.blob.rawPath, fileSize: this.blob.rawSize, totalPages: 0, }; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index d3717f10ec7..08faf19d12a 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -148,11 +148,16 @@ export default { .reduce( (acc, name, i) => { const path = joinPaths(i > 0 ? acc[i].path : '', escapeFileUrl(name)); + const isLastPath = i === this.currentPath.split('/').length - 1; + const to = + this.isBlobPath && isLastPath + ? `/-/blob/${joinPaths(this.escapedRef, path)}` + : `/-/tree/${joinPaths(this.escapedRef, path)}`; return acc.concat({ name, path, - to: `/-/tree/${joinPaths(this.escapedRef, path)}`, + to, }); }, [ @@ -274,9 +279,11 @@ export default { return items; }, + isBlobPath() { + return this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded'; + }, renderAddToTreeDropdown() { - const isBlobPath = this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded'; - return !isBlobPath && (this.canCollaborate || this.canCreateMrFromFork); + return !this.isBlobPath && (this.canCollaborate || this.canCreateMrFromFork); }, }, methods: { diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index f3c9aea36f1..baf8449b188 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -87,7 +87,7 @@ export default { fields: { // fields key must match case of form name for validation directive to work commit_message: initFormField({ value: this.commitMessage }), - branch_name: initFormField({ value: this.targetBranch }), + branch_name: initFormField({ value: this.targetBranch, skipValidation: !this.canPushCode }), }, }; return { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index e206d9bfbd2..bb9d3180be8 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -27,6 +27,12 @@ export const PDF_MAX_PAGE_LIMIT = 50; export const ROW_APPEAR_DELAY = 150; export const DEFAULT_BLOB_INFO = { + gitpodEnabled: false, + currentUser: { + gitpodEnabled: false, + preferencesGitpodPath: null, + profileEnableGitpodPath: null, + }, userPermissions: { pushCode: false, downloadCode: false, @@ -49,9 +55,13 @@ export const DEFAULT_BLOB_INFO = { tooLarge: false, path: '', editBlobPath: '', + gitpodBlobUrl: '', ideEditPath: '', forkAndEditPath: '', ideForkAndEditPath: '', + codeNavigationPath: '', + projectBlobPathRoot: '', + forkAndViewPath: '', storedExternally: false, externalStorage: '', environmentFormattedExternalUrl: '', diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 120c32caefd..b38a1cfdc7b 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,10 +1,12 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; +import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; +import createStore from '~/code_navigation/store'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -19,6 +21,7 @@ import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; +Vue.use(Vuex); Vue.use(PerformancePlugin, { components: ['SimpleViewer', 'BlobContent'], }); @@ -200,6 +203,7 @@ export default function setupVueRepositoryList() { // eslint-disable-next-line no-new new Vue({ el, + store: createStore(), router, apolloProvider, render(h) { diff --git a/app/assets/javascripts/repository/queries/application_info.query.graphql b/app/assets/javascripts/repository/queries/application_info.query.graphql new file mode 100644 index 00000000000..fd69de39f75 --- /dev/null +++ b/app/assets/javascripts/repository/queries/application_info.query.graphql @@ -0,0 +1,3 @@ +query getApplicationInfo { + gitpodEnabled +} diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 78323fdc5f4..8baee80e5d6 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -28,9 +28,13 @@ query getBlobInfo( language path editBlobPath + gitpodBlobUrl ideEditPath forkAndEditPath ideForkAndEditPath + codeNavigationPath + projectBlobPathRoot + forkAndViewPath environmentFormattedExternalUrl environmentExternalUrlForRouteMap canModifyBlob diff --git a/app/assets/javascripts/repository/queries/user_info.query.graphql b/app/assets/javascripts/repository/queries/user_info.query.graphql new file mode 100644 index 00000000000..114947a423d --- /dev/null +++ b/app/assets/javascripts/repository/queries/user_info.query.graphql @@ -0,0 +1,8 @@ +query getUserInfo { + currentUser { + id + gitpodEnabled + preferencesGitpodPath + profileEnableGitpodPath + } +} diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue index 4d2ca9b0c58..c2db3b9facd 100644 --- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue +++ b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue @@ -5,7 +5,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '../components/runner_header.vue'; import RunnerUpdateForm from '../components/runner_update_form.vue'; import { I18N_FETCH_ERROR } from '../constants'; -import getRunnerQuery from '../graphql/get_runner.query.graphql'; +import runnerQuery from '../graphql/details/runner.query.graphql'; import { captureException } from '../sentry_utils'; export default { @@ -27,7 +27,7 @@ export default { }, apollo: { runner: { - query: getRunnerQuery, + query: runnerQuery, variables() { return { id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue index 2795ddbbbcb..86ad912f017 100644 --- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue @@ -8,7 +8,7 @@ import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import { I18N_FETCH_ERROR } from '../constants'; -import getRunnerQuery from '../graphql/get_runner.query.graphql'; +import runnerQuery from '../graphql/details/runner.query.graphql'; import { captureException } from '../sentry_utils'; export default { @@ -35,7 +35,7 @@ export default { }, apollo: { runner: { - query: getRunnerQuery, + query: runnerQuery, variables() { return { id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index a968d4029f8..8aba91eedf7 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; +import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; @@ -25,8 +26,8 @@ import { STATUS_STALE, I18N_FETCH_ERROR, } from '../constants'; -import getRunnersQuery from '../graphql/get_runners.query.graphql'; -import getRunnersCountQuery from '../graphql/get_runners_count.query.graphql'; +import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql'; +import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql'; import { fromUrlQueryToSearch, fromSearchToUrl, @@ -35,7 +36,7 @@ import { import { captureException } from '../sentry_utils'; const runnersCountSmartQuery = { - query: getRunnersCountQuery, + query: runnersAdminCountQuery, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, update(data) { return data?.runners?.count; @@ -57,6 +58,7 @@ export default { RunnerStats, RunnerPagination, RunnerTypeTabs, + RunnerActionsCell, }, props: { registrationToken: { @@ -75,7 +77,7 @@ export default { }, apollo: { runners: { - query: getRunnersQuery, + query: runnersAdminQuery, // Runners can be updated by users directly in this list. // A "cache and network" policy prevents outdated filtered // results. @@ -187,6 +189,7 @@ export default { deep: true, handler() { // TODO Implement back button response using onpopstate + // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333804 updateHistory({ url: fromSearchToUrl(this.search), title: document.title, @@ -221,6 +224,10 @@ export default { } return ''; }, + onDeleted({ message }) { + this.$root.$toast?.show(message); + this.$apollo.queries.runners.refetch(); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -278,6 +285,13 @@ export default { <runner-name :runner="runner" /> </gl-link> </template> + <template #runner-actions-cell="{ runner }"> + <runner-actions-cell + :runner="runner" + :edit-url="runner.editAdminUrl" + @deleted="onDeleted" + /> + </template> </runner-list> <runner-pagination v-model="search.pagination" diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index ae9c774f2a2..c69321de001 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,60 +1,30 @@ <script> -import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import { createAlert } from '~/flash'; -import { s__, sprintf } from '~/locale'; -import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import { captureException } from '~/runner/sentry_utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { GlButtonGroup } from '@gitlab/ui'; import RunnerEditButton from '../runner_edit_button.vue'; import RunnerPauseButton from '../runner_pause_button.vue'; -import RunnerDeleteModal from '../runner_delete_modal.vue'; - -const I18N_DELETE = s__('Runners|Delete runner'); -const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); +import RunnerDeleteButton from '../runner_delete_button.vue'; export default { name: 'RunnerActionsCell', components: { - GlButton, GlButtonGroup, RunnerEditButton, RunnerPauseButton, - RunnerDeleteModal, - }, - directives: { - GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, + RunnerDeleteButton, }, props: { runner: { type: Object, required: true, }, + editUrl: { + type: String, + default: null, + required: false, + }, }, - data() { - return { - updating: false, - deleting: false, - }; - }, + emits: ['deleted'], computed: { - deleteTitle() { - if (this.deleting) { - // Prevent a "sticky" tooltip: If this button is disabled, - // mouseout listeners don't run leaving the tooltip stuck - return ''; - } - return I18N_DELETE; - }, - runnerId() { - return getIdFromGraphQLId(this.runner.id); - }, - runnerName() { - return `#${this.runnerId} (${this.runner.shortSha})`; - }, - runnerDeleteModalId() { - return `delete-runner-modal-${this.runnerId}`; - }, canUpdate() { return this.runner.userPermissions?.updateRunner; }, @@ -63,79 +33,17 @@ export default { }, }, methods: { - async onDelete() { - // Deleting stays "true" until this row is removed, - // should only change back if the operation fails. - this.deleting = true; - try { - const { - data: { - runnerDelete: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnerDeleteMutation, - variables: { - input: { - id: this.runner.id, - }, - }, - awaitRefetchQueries: true, - refetchQueries: ['getRunners', 'getGroupRunners'], - }); - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } else { - // Use $root to have the toast message stay after this element is removed - this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName })); - } - } catch (e) { - this.deleting = false; - this.onError(e); - } - }, - - onError(error) { - const { message } = error; - createAlert({ message }); - - this.reportToSentry(error); - }, - reportToSentry(error) { - captureException({ error, component: this.$options.name }); + onDeleted(value) { + this.$emit('deleted', value); }, }, - I18N_DELETE, }; </script> <template> <gl-button-group> - <!-- - This button appears for administrators: those with - access to the adminUrl. More advanced permissions policies - will allow more granular permissions. - - See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 - --> - <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" /> <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> - <gl-button - v-if="canDelete" - v-gl-tooltip.hover.viewport="deleteTitle" - v-gl-modal="runnerDeleteModalId" - :aria-label="deleteTitle" - icon="close" - :loading="deleting" - variant="danger" - data-testid="delete-runner" - /> - - <runner-delete-modal - v-if="canDelete" - :ref="runnerDeleteModalId" - :modal-id="runnerDeleteModalId" - :runner-name="runnerName" - @primary="onDelete" - /> + <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue index 54c35e483dc..1234054c660 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -4,7 +4,7 @@ import { createAlert } from '~/flash'; import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; -import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; +import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; @@ -98,17 +98,14 @@ export default { }, onError(error) { const { message } = error; - createAlert({ message }); - this.reportToSentry(error); + createAlert({ message }); + captureException({ error, component: this.$options.name }); }, onSuccess(token) { this.$toast?.show(s__('Runners|New registration token generated!')); this.$emit('tokenReset', token); }, - reportToSentry(error) { - captureException({ error, component: this.$options.name }); - }, }, }; </script> diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue new file mode 100644 index 00000000000..854c983f4da --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_delete_button.vue @@ -0,0 +1,144 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import runnerDeleteMutation from '~/runner/graphql/shared/runner_delete.mutation.graphql'; +import { createAlert } from '~/flash'; +import { sprintf } from '~/locale'; +import { captureException } from '~/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; +import RunnerDeleteModal from './runner_delete_modal.vue'; + +export default { + name: 'RunnerDeleteButton', + components: { + GlButton, + RunnerDeleteModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + props: { + runner: { + type: Object, + required: true, + validator: (runner) => { + return runner?.id && runner?.shortSha; + }, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['deleted'], + data() { + return { + deleting: false, + }; + }, + computed: { + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; + }, + icon() { + if (this.compact) { + return 'close'; + } + return ''; + }, + buttonContent() { + if (this.compact) { + return null; + } + return I18N_DELETE_RUNNER; + }, + buttonClass() { + // Ensure a square button is shown when compact: true. + // Without this class we will have distorted/rectangular button. + if (this.compact) { + return 'btn-icon'; + } + return null; + }, + ariaLabel() { + if (this.compact) { + return I18N_DELETE_RUNNER; + } + return null; + }, + tooltip() { + // Only show tooltip when compact. + // Also prevent a "sticky" tooltip: If this button is + // disabled, mouseout listeners don't run leaving the tooltip stuck + if (this.compact && !this.deleting) { + return I18N_DELETE_RUNNER; + } + return ''; + }, + }, + methods: { + async onDelete() { + // Deleting stays "true" until this row is removed, + // should only change back if the operation fails. + this.deleting = true; + try { + const { + data: { + runnerDelete: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerDeleteMutation, + variables: { + input: { + id: this.runner.id, + }, + }, + }); + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } else { + this.$emit('deleted', { + message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), + }); + } + } catch (e) { + this.deleting = false; + this.onError(e); + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover.viewport="tooltip" + v-gl-modal="runnerDeleteModalId" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="deleting" + variant="danger" + > + {{ buttonContent }} + <runner-delete-modal + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + @primary="onDelete" + /> + </gl-button> +</template> diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue index b115be09e69..33e0acaf5c0 100644 --- a/app/assets/javascripts/runner/components/runner_edit_button.vue +++ b/app/assets/javascripts/runner/components/runner_edit_button.vue @@ -1,8 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; - -const I18N_EDIT = __('Edit'); +import { I18N_EDIT } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue index c13e7e90168..eb77babcc57 100644 --- a/app/assets/javascripts/runner/components/runner_jobs.vue +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -1,7 +1,7 @@ <script> import { GlSkeletonLoading } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql'; +import runnerJobsQuery from '../graphql/details/runner_jobs.query.graphql'; import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; import { captureException } from '../sentry_utils'; import { getPaginationVariables } from '../utils'; @@ -34,7 +34,7 @@ export default { }, apollo: { jobs: { - query: getRunnerJobsQuery, + query: runnerJobsQuery, variables() { return this.variables; }, @@ -46,7 +46,7 @@ export default { }, error(error) { createAlert({ message: I18N_FETCH_ERROR }); - this.reportToSentry(error); + captureException({ error, component: this.$options.name }); }, }, }, @@ -62,11 +62,6 @@ export default { return this.$apollo.queries.jobs.loading; }, }, - methods: { - reportToSentry(error) { - captureException({ error, component: this.$options.name }); - }, - }, I18N_NO_JOBS_FOUND, }; </script> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index bb36882d3ae..51749b0255f 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,22 +1,20 @@ <script> -import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { formatJobCount, tableField } from '../utils'; -import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; export default { components: { - GlTable, + GlTableLite, GlSkeletonLoader, TooltipOnTruncate, TimeAgo, - RunnerActionsCell, RunnerSummaryCell, RunnerTags, RunnerStatusCell, @@ -35,6 +33,16 @@ export default { required: true, }, }, + computed: { + tableClass() { + // <gl-table-lite> does not provide a busy state, add + // simple support for it. + // See http://bootstrap-vue.org/docs/components/table#table-busy-state + return { + 'gl-opacity-6': this.loading, + }; + }, + }, methods: { formatJobCount(jobCount) { return formatJobCount(jobCount); @@ -62,8 +70,9 @@ export default { </script> <template> <div> - <gl-table - :busy="loading" + <gl-table-lite + :aria-busy="loading" + :class="tableClass" :items="runners" :fields="$options.fields" :tbody-tr-attr="runnerTrAttr" @@ -72,10 +81,6 @@ export default { primary-key="id" fixed > - <template v-if="!runners.length" #table-busy> - <gl-skeleton-loader v-for="i in 4" :key="i" /> - </template> - <template #cell(status)="{ item }"> <runner-status-cell :runner="item" /> </template> @@ -114,8 +119,12 @@ export default { </template> <template #cell(actions)="{ item }"> - <runner-actions-cell :runner="item" /> + <slot name="runner-actions-cell" :runner="item"></slot> </template> - </gl-table> + </gl-table-lite> + + <template v-if="!runners.length && loading"> + <gl-skeleton-loader v-for="i in 4" :key="i" /> + </template> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue index a8b259f5b90..c88634bfbd9 100644 --- a/app/assets/javascripts/runner/components/runner_pause_button.vue +++ b/app/assets/javascripts/runner/components/runner_pause_button.vue @@ -1,9 +1,9 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql'; +import runnerToggleActiveMutation from '~/runner/graphql/shared/runner_toggle_active.mutation.graphql'; import { createAlert } from '~/flash'; import { captureException } from '~/runner/sentry_utils'; -import { I18N_PAUSE, I18N_RESUME } from '../constants'; +import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants'; export default { name: 'RunnerPauseButton', @@ -52,11 +52,10 @@ export default { return null; }, tooltip() { - // Only show tooltip when compact. - // Also prevent a "sticky" tooltip: If this button is - // disabled, mouseout listeners don't run leaving the tooltip stuck - if (this.compact && !this.updating) { - return this.label; + // 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 ''; }, @@ -92,11 +91,8 @@ export default { }, onError(error) { const { message } = error; - createAlert({ message }); - this.reportToSentry(error); - }, - reportToSentry(error) { + createAlert({ message }); captureException({ error, component: this.$options.name }); }, }, @@ -105,7 +101,7 @@ export default { <template> <gl-button - v-gl-tooltip.hover.viewport="tooltip" + v-gl-tooltip="tooltip" v-bind="$attrs" :aria-label="ariaLabel" :icon="icon" diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue index d1e6fa05e4d..27618290ce6 100644 --- a/app/assets/javascripts/runner/components/runner_paused_badge.vue +++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue @@ -1,6 +1,6 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { I18N_PAUSED_RUNNER_DESCRIPTION } from '../constants'; +import { I18N_PAUSED_DESCRIPTION } from '../constants'; export default { components: { @@ -9,17 +9,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - i18n: { - I18N_PAUSED_RUNNER_DESCRIPTION, - }, + I18N_PAUSED_DESCRIPTION, }; </script> <template> - <gl-badge - v-gl-tooltip="$options.i18n.I18N_PAUSED_RUNNER_DESCRIPTION" - variant="danger" - v-bind="$attrs" - > + <gl-badge v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" variant="danger" v-bind="$attrs"> {{ s__('Runners|paused') }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index c4065a24ff2..f8ec29b8a24 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -2,7 +2,7 @@ import { GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, formatNumber } from '~/locale'; import { createAlert } from '~/flash'; -import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql'; +import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql'; import { I18N_ASSIGNED_PROJECTS, I18N_NONE, @@ -41,7 +41,7 @@ export default { }, apollo: { projects: { - query: getRunnerProjectsQuery, + query: runnerProjectsQuery, variables() { return this.variables; }, @@ -55,8 +55,7 @@ export default { }, error(error) { createAlert({ message: I18N_FETCH_ERROR }); - - this.reportToSentry(error); + captureException({ error, component: this.$options.name }); }, }, }, @@ -77,11 +76,6 @@ export default { }); }, }, - methods: { - reportToSentry(error) { - captureException({ error, component: this.$options.name }); - }, - }, I18N_NONE, }; </script> diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index e3deb94236e..e44450a2a8d 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -15,7 +15,7 @@ import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { __ } from '~/locale'; import { captureException } from '~/runner/sentry_utils'; import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; -import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql'; +import runnerUpdateMutation from '../graphql/details/runner_update.mutation.graphql'; export default { name: 'RunnerUpdateForm', @@ -82,9 +82,9 @@ export default { this.onSuccess(); } catch (error) { const { message } = error; - createAlert({ message }); - this.reportToSentry(error); + createAlert({ message }); + captureException({ error, component: this.$options.name }); } finally { this.saving = false; } @@ -93,9 +93,6 @@ export default { createAlert({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); this.model = runnerToModel(this.runner); }, - reportToSentry(error) { - captureException({ error, component: this.$options.name }); - }, }, ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 1544efaaae2..bd5be2175ad 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -35,12 +35,20 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__( 'Runners|No contact from this runner in over 3 months', ); -// Active flag +// Actions +export const I18N_EDIT = __('Edit'); + export const I18N_PAUSE = __('Pause'); +export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs'); +export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs'); + export const I18N_RESUME = __('Resume'); +export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs'); + +export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); +export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); -export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); // Runner details @@ -91,8 +99,8 @@ export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED'; // CiRunnerSort export const CREATED_DESC = 'CREATED_DESC'; -export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API -export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API +export const CREATED_ASC = 'CREATED_ASC'; +export const CONTACTED_DESC = 'CONTACTED_DESC'; export const CONTACTED_ASC = 'CONTACTED_ASC'; export const DEFAULT_SORT = CREATED_DESC; diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/details/runner.query.graphql index f6ce8281c64..4792a186160 100644 --- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner.query.graphql @@ -1,10 +1,9 @@ -#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" +#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql" query getRunner($id: CiRunnerID!) { # We have an id in deeply nested fragment # eslint-disable-next-line @graphql-eslint/require-id-when-available runner(id: $id) { - __typename ...RunnerDetails } } diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql index 2449ee0fc0f..2449ee0fc0f 100644 --- a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql index 74760bbaa07..d8c67728fac 100644 --- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql @@ -1,4 +1,5 @@ fragment RunnerDetailsShared on CiRunner { + __typename id runnerType active @@ -22,7 +23,7 @@ fragment RunnerDetailsShared on CiRunner { groups { # Only a single group can be loaded here, while projects # are loaded separately using the query with pagination - # parameters `get_runner_projects.query.graphql`. + # parameters `runner_projects.query.graphql`. nodes { id avatarUrl diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql index 2b1decd3ddd..2b1decd3ddd 100644 --- a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql index f97237b8267..f97237b8267 100644 --- a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql index 8d1b75828be..e4bf51e2c30 100644 --- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" +#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql" # Mutation for updates from the runner form, loads # attributes shown in the runner details. diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql index ed03a8c34ae..8df4c2fc65c 100644 --- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql @@ -1,4 +1,4 @@ -#import "~/runner/graphql/runner_node.fragment.graphql" +#import "~/runner/graphql/list/list_item.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" query getRunners( @@ -24,7 +24,7 @@ query getRunners( sort: $sort ) { nodes { - ...RunnerNode + ...ListItem adminUrl editAdminUrl } diff --git a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql index 181a4495cae..181a4495cae 100644 --- a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql index 986dd16b992..b517f5e89a8 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql @@ -1,4 +1,4 @@ -#import "~/runner/graphql/runner_node.fragment.graphql" +#import "~/runner/graphql/list/list_item.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" query getGroupRunners( @@ -27,9 +27,9 @@ query getGroupRunners( ) { edges { webUrl + editUrl node { - __typename - ...RunnerNode + ...ListItem } } pageInfo { diff --git a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql index 554eb09e372..554eb09e372 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql index fbdef817f2f..620c18c5bc0 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql @@ -1,4 +1,4 @@ -fragment RunnerNode on CiRunner { +fragment ListItem on CiRunner { __typename id description diff --git a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql b/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql index 9c2797732ad..9c2797732ad 100644 --- a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql diff --git a/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql index d580ea2785e..d580ea2785e 100644 --- a/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql index 9b15570dbc0..9b15570dbc0 100644 --- a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index c4ee0ad4dfb..35fd7fff6d3 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -12,19 +12,20 @@ import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; +import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { - I18N_FETCH_ERROR, GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_TYPE, PROJECT_TYPE, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE, + I18N_FETCH_ERROR, } from '../constants'; -import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql'; -import getGroupRunnersCountQuery from '../graphql/get_group_runners_count.query.graphql'; +import groupRunnersQuery from '../graphql/list/group_runners.query.graphql'; +import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql'; import { fromUrlQueryToSearch, fromSearchToUrl, @@ -33,7 +34,7 @@ import { import { captureException } from '../sentry_utils'; const runnersCountSmartQuery = { - query: getGroupRunnersCountQuery, + query: groupRunnersCountQuery, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, update(data) { return data?.group?.runners?.count; @@ -55,6 +56,7 @@ export default { RunnerStats, RunnerPagination, RunnerTypeTabs, + RunnerActionsCell, }, props: { registrationToken: { @@ -74,15 +76,15 @@ export default { return { search: fromUrlQueryToSearch(), runners: { - webUrls: [], items: [], + urlsById: {}, pageInfo: {}, }, }; }, apollo: { runners: { - query: getGroupRunnersQuery, + query: groupRunnersQuery, // Runners can be updated by users directly in this list. // A "cache and network" policy prevents outdated filtered // results. @@ -91,12 +93,23 @@ export default { return this.variables; }, update(data) { - const { runners } = data?.group || {}; + const { edges = [], pageInfo = {} } = data?.group?.runners || {}; + + const items = []; + const urlsById = {}; + + edges.forEach(({ node, webUrl, editUrl }) => { + items.push(node); + urlsById[node.id] = { + web: webUrl, + edit: editUrl, + }; + }); return { - webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [], - items: runners?.edges.map(({ node }) => node) || [], - pageInfo: runners?.pageInfo || {}, + items, + urlsById, + pageInfo, }; }, error(error) { @@ -190,6 +203,7 @@ export default { deep: true, handler() { // TODO Implement back button reponse using onpopstate + // See https://gitlab.com/gitlab-org/gitlab/-/issues/333804 updateHistory({ url: fromSearchToUrl(this.search), title: document.title, @@ -221,6 +235,16 @@ export default { } return null; }, + webUrl(runner) { + return this.runners.urlsById[runner.id]?.web; + }, + editUrl(runner) { + return this.runners.urlsById[runner.id]?.edit; + }, + onDeleted({ message }) { + this.$root.$toast?.show(message); + this.$apollo.queries.runners.refetch(); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -272,13 +296,20 @@ export default { </div> <template v-else> <runner-list :runners="runners.items" :loading="runnersLoading"> - <template #runner-name="{ runner, index }"> - <gl-link :href="runners.webUrls[index]"> + <template #runner-name="{ runner }"> + <gl-link :href="webUrl(runner)"> <runner-name :runner="runner" /> </gl-link> </template> + <template #runner-actions-cell="{ runner }"> + <runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" @deleted="onDeleted" /> + </template> </runner-list> - <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> + <runner-pagination + v-model="search.pagination" + class="gl-mt-3" + :page-info="runners.pageInfo" + /> </template> </div> </template> diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 65114ee066e..f27dae8249d 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,17 +1,20 @@ <script> -import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui'; +import { GlSearchBoxByClick } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; +import { s__ } from '~/locale'; import GroupFilter from './group_filter.vue'; import ProjectFilter from './project_filter.vue'; export default { name: 'GlobalSearchTopbar', + i18n: { + searchPlaceholder: s__(`GlobalSearch|Search for projects, issues, etc.`), + searchLabel: s__(`GlobalSearch|What are you searching for?`), + }, components: { - GlForm, - GlSearchBoxByType, + GlSearchBoxByClick, GroupFilter, ProjectFilter, - GlButton, }, props: { groupInitialData: { @@ -49,28 +52,24 @@ export default { </script> <template> - <gl-form class="search-page-form" @submit.prevent="applyQuery"> - <section class="gl-lg-display-flex gl-align-items-flex-end"> - <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> - <label>{{ __('What are you searching for?') }}</label> - <gl-search-box-by-type - id="dashboard_search" - v-model="search" - name="search" - :placeholder="__(`Search for projects, issues, etc.`)" - /> - </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Group') }}</label> - <group-filter :initial-data="groupInitialData" /> - </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Project') }}</label> - <project-filter :initial-data="projectInitialData" /> - </div> - <gl-button class="btn-search gl-lg-ml-2" category="primary" variant="confirm" type="submit" - >{{ __('Search') }} - </gl-button> - </section> - </gl-form> + <section class="search-page-form gl-lg-display-flex gl-align-items-flex-end"> + <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> + <label>{{ $options.i18n.searchLabel }}</label> + <gl-search-box-by-click + id="dashboard_search" + v-model="search" + name="search" + :placeholder="$options.i18n.searchPlaceholder" + @submit="applyQuery" + /> + </div> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> + <label class="gl-display-block">{{ __('Group') }}</label> + <group-filter :initial-data="groupInitialData" /> + </div> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> + <label class="gl-display-block">{{ __('Project') }}</label> + <project-filter :initial-data="projectInitialData" /> + </div> + </section> </template> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 81d222438e3..39a2939f52a 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -16,6 +16,8 @@ import { REPORT_TYPE_LICENSE_COMPLIANCE, } from '~/vue_shared/security_reports/constants'; +import kontraLogo from 'images/vulnerability/kontra-logo.svg'; +import scwLogo from 'images/vulnerability/scw-logo.svg'; 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'; @@ -222,14 +224,12 @@ export const securityFeatures = [ helpPath: COVERAGE_FUZZING_HELP_PATH, configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, type: REPORT_TYPE_COVERAGE_FUZZING, - secondary: gon?.features?.corpusManagementUi - ? { - type: REPORT_TYPE_CORPUS_MANAGEMENT, - name: CORPUS_MANAGEMENT_NAME, - description: CORPUS_MANAGEMENT_DESCRIPTION, - configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, - } - : {}, + secondary: { + type: REPORT_TYPE_CORPUS_MANAGEMENT, + name: CORPUS_MANAGEMENT_NAME, + description: CORPUS_MANAGEMENT_DESCRIPTION, + configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, + }, }, ]; @@ -281,3 +281,21 @@ export const featureToMutationMap = { export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = 'security_configuration_auto_devops_enabled_dismissed_projects'; + +// Fetch the svg path from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/346899 +export const TEMP_PROVIDER_LOGOS = { + Kontra: { + svg: kontraLogo, + }, + [__('Secure Code Warrior')]: { + svg: scwLogo, + }, +}; + +// Use the `url` field from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/356129 +export const TEMP_PROVIDER_URLS = { + Kontra: 'https://application.security/', + [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', +}; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 1c37d8008de..cd5ad86e1a8 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -31,13 +31,12 @@ export default { const button = this.enabled ? { text: this.$options.i18n.configureFeature, - category: 'secondary', } : { text: this.$options.i18n.enableFeature, - category: 'primary', }; + button.category = 'secondary'; button.text = sprintf(button.text, { feature: this.shortName }); return button; @@ -126,7 +125,7 @@ export default { v-else-if="showManageViaMr" :feature="feature" variant="confirm" - category="primary" + category="secondary" class="gl-mt-5" :data-qa-selector="`${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 539e2bff17c..bb540303cfd 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -1,15 +1,31 @@ <script> -import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { + GlAlert, + GlTooltipDirective, + GlCard, + GlToggle, + GlLink, + GlSkeletonLoader, + GlIcon, + GlSafeHtmlDirective, +} from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, + TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, + TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, } from '~/security_configuration/constants'; import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; -import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; -import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql'; +import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; +import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql'; +import { + updateSecurityTrainingCache, + updateSecurityTrainingOptimisticResponse, +} from '~/security_configuration/graphql/cache_utils'; +import { TEMP_PROVIDER_LOGOS, TEMP_PROVIDER_URLS } from './constants'; const i18n = { providerQueryErrorMessage: __( @@ -18,6 +34,10 @@ const i18n = { configMutationErrorMessage: __( 'Could not save configuration. Please refresh the page, or try again later.', ), + primaryTraining: s__('SecurityTraining|Primary Training'), + primaryTrainingDescription: s__( + 'SecurityTraining|Training from this partner takes precedence when more than one training partner is enabled.', + ), }; export default { @@ -27,6 +47,11 @@ export default { GlToggle, GlLink, GlSkeletonLoader, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, mixins: [Tracking.mixin()], inject: ['projectFullPath'], @@ -49,12 +74,14 @@ export default { data() { return { errorMessage: '', - providerLoadingId: null, securityTrainingProviders: [], hasTouchedConfiguration: false, }; }, computed: { + enabledProviders() { + return this.securityTrainingProviders.filter(({ isEnabled }) => isEnabled); + }, isLoading() { return this.$apollo.queries.securityTrainingProviders.loading; }, @@ -89,15 +116,41 @@ export default { Sentry.captureException(e); } }, - toggleProvider(provider) { - const { isEnabled } = provider; + async toggleProvider(provider) { + const { isEnabled, isPrimary } = provider; const toggledIsEnabled = !isEnabled; this.trackProviderToggle(provider.id, toggledIsEnabled); - this.storeProvider({ ...provider, isEnabled: toggledIsEnabled }); + + // when the current primary provider gets disabled then set the first enabled to be the new primary + if (!toggledIsEnabled && isPrimary && this.enabledProviders.length > 1) { + const firstOtherEnabledProvider = this.enabledProviders.find( + ({ id }) => id !== provider.id, + ); + this.setPrimaryProvider(firstOtherEnabledProvider); + } + + this.storeProvider({ + ...provider, + isEnabled: toggledIsEnabled, + }); }, - async storeProvider({ id, isEnabled, isPrimary }) { - this.providerLoadingId = id; + setPrimaryProvider(provider) { + this.storeProvider({ ...provider, isPrimary: true }); + }, + async storeProvider(provider) { + const { id, isEnabled, isPrimary } = provider; + let nextIsPrimary = isPrimary; + + // if the current provider has been disabled it can't be primary + if (!isEnabled) { + nextIsPrimary = false; + } + + // if the current provider is the only enabled provider it should be primary + if (isEnabled && !this.enabledProviders.length) { + nextIsPrimary = true; + } try { const { @@ -111,9 +164,18 @@ export default { projectPath: this.projectFullPath, providerId: id, isEnabled, - isPrimary, + isPrimary: nextIsPrimary, }, }, + optimisticResponse: updateSecurityTrainingOptimisticResponse({ + id, + isEnabled, + isPrimary: nextIsPrimary, + }), + update: updateSecurityTrainingCache({ + query: securityTrainingProvidersQuery, + variables: { fullPath: this.projectFullPath }, + }), }); if (errors.length > 0) { @@ -124,8 +186,6 @@ export default { this.hasTouchedConfiguration = true; } catch { this.errorMessage = this.$options.i18n.configMutationErrorMessage; - } finally { - this.providerLoadingId = null; } }, trackProviderToggle(providerId, providerIsEnabled) { @@ -137,8 +197,16 @@ export default { }, }); }, + trackProviderLearnMoreClick(providerId) { + this.track(TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, { + label: TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, + property: providerId, + }); + }, }, i18n, + TEMP_PROVIDER_LOGOS, + TEMP_PROVIDER_URLS, }; </script> @@ -165,15 +233,54 @@ export default { :value="provider.isEnabled" :label="__('Training mode')" label-position="hidden" - :is-loading="providerLoadingId === provider.id" @change="toggleProvider(provider)" /> - <div class="gl-ml-5"> + <div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4"> + <div + v-safe-html="$options.TEMP_PROVIDER_LOGOS[provider.name].svg" + data-testid="provider-logo" + style="width: 18px" + role="presentation" + ></div> + </div> + <div class="gl-ml-3"> <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3> <p> {{ provider.description }} - <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link> + <gl-link + v-if="$options.TEMP_PROVIDER_URLS[provider.name]" + :href="$options.TEMP_PROVIDER_URLS[provider.name]" + target="_blank" + @click="trackProviderLearnMoreClick(provider.id)" + > + {{ __('Learn more.') }} + </gl-link> </p> + <!-- Note: The following `div` and it's content will be replaced by 'GlFormRadio' once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1720#note_857342988 is resolved --> + <div + class="gl-form-radio custom-control custom-radio" + data-testid="primary-provider-radio" + > + <input + :id="`security-training-provider-${provider.id}`" + type="radio" + :checked="provider.isPrimary" + class="custom-control-input" + :disabled="!provider.isEnabled" + @change="setPrimaryProvider(provider)" + /> + <label + class="custom-control-label" + :for="`security-training-provider-${provider.id}`" + > + {{ $options.i18n.primaryTraining }} + </label> + <gl-icon + v-gl-tooltip="$options.i18n.primaryTrainingDescription" + name="information-o" + class="gl-ml-2 gl-cursor-help" + /> + </div> </div> </div> </gl-card> diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js index dc76436e91d..14eb10ac2aa 100644 --- a/app/assets/javascripts/security_configuration/constants.js +++ b/app/assets/javascripts/security_configuration/constants.js @@ -1,2 +1,8 @@ export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider'; export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider'; +export const TRACK_CLICK_TRAINING_LINK_ACTION = 'click_security_training_link'; +export const TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION = 'click_link'; +export const TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL = 'security_training_provider'; +export const TRACK_TRAINING_LOADED_ACTION = 'security_training_link_loaded'; +export const TRACK_PROMOTION_BANNER_CTA_CLICK_ACTION = 'click_button'; +export const TRACK_PROMOTION_BANNER_CTA_CLICK_LABEL = 'security_training_promotion_cta'; diff --git a/app/assets/javascripts/security_configuration/graphql/cache_utils.js b/app/assets/javascripts/security_configuration/graphql/cache_utils.js new file mode 100644 index 00000000000..6d5258b01dc --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/cache_utils.js @@ -0,0 +1,40 @@ +import produce from 'immer'; + +export const updateSecurityTrainingOptimisticResponse = (changes) => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + securityTrainingUpdate: { + __typename: 'SecurityTrainingUpdatePayload', + training: { + __typename: 'ProjectSecurityTraining', + ...changes, + }, + errors: [], + }, +}); + +export const updateSecurityTrainingCache = ({ query, variables }) => (cache, { data }) => { + const { + securityTrainingUpdate: { training: updatedProvider }, + } = data; + const { project } = cache.readQuery({ query, variables }); + if (!updatedProvider.isPrimary) { + return; + } + + // when we set a new primary provider, we need to unset the previous one(s) + const updatedProject = produce(project, (draft) => { + draft.securityTrainingProviders.forEach((provider) => { + // eslint-disable-next-line no-param-reassign + provider.isPrimary = provider.id === updatedProvider.id; + }); + }); + + // write to the cache + cache.writeQuery({ + query, + variables, + data: { project: updatedProject }, + }); +}; diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql new file mode 100644 index 00000000000..f0474614dab --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql @@ -0,0 +1,10 @@ +query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) { + project(fullPath: $projectFullPath) { + id + securityTrainingUrls(identifierExternalIds: $identifierExternalIds) { + name + status + url + } + } +} diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index da9ff407faf..240e12ee597 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; export default { @@ -31,10 +32,11 @@ export default { ); }, isMergeRequest() { - return this.issuableType === 'merge_request'; + return this.issuableType === IssuableType.MergeRequest; }, hasMergeIcon() { - return this.isMergeRequest && !this.user.can_merge; + const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; + return this.isMergeRequest && !canMerge; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 2a237e7ace0..578c344da02 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import AssigneeAvatar from './assignee_avatar.vue'; @@ -71,7 +72,8 @@ export default { }, computed: { cannotMerge() { - return this.issuableType === 'merge_request' && !this.user.can_merge; + const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; + return this.issuableType === IssuableType.MergeRequest && !canMerge; }, tooltipTitle() { const { name = '', availability = '' } = this.user; diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 6a74ab83c22..856687c00ae 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -58,7 +58,7 @@ export default { return this.users.length > 2; }, allAssigneesCanMerge() { - return this.users.every((user) => user.can_merge); + return this.users.every((user) => user.can_merge || user.mergeRequestInteraction?.canMerge); }, sidebarAvatarCounter() { if (this.users.length > DEFAULT_MAX_COUNTER) { @@ -77,7 +77,9 @@ export default { return ''; } - const mergeLength = this.users.filter((u) => u.can_merge).length; + const mergeLength = this.users.filter( + (u) => u.can_merge || u.mergeRequestInteraction?.canMerge, + ).length; if (mergeLength === this.users.length) { return ''; diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index a3379784bc1..59a4eb54bbe 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -44,7 +44,7 @@ export default { <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div v-if="emptyUsers" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed" + class="gl-display-flex gl-align-items-center gl-text-gray-500 hide-collapsed" data-testid="none" > <span> {{ __('None') }}</span> @@ -65,7 +65,7 @@ export default { v-else :users="users" :issuable-type="issuableType" - class="gl-text-gray-800 gl-mt-2 hide-collapsed" + class="gl-text-gray-800 hide-collapsed" @toggle-attention-requested="toggleAttentionRequested" /> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 453dd1b0580..e596d6292bf 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -63,7 +63,7 @@ export default { computed: { shouldEnableRealtime() { // Note: Realtime is only available on issues right now, future support for MR wil be built later. - return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; + return this.issuableType === 'issue'; }, queryVariables() { return { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 18654b73ab3..7743004a293 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,6 +1,5 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; import { IssuableType } from '~/issues/constants'; @@ -101,7 +100,10 @@ export default { } const issuable = data.workspace?.issuable; if (issuable) { - this.selected = cloneDeep(issuable.assignees.nodes); + this.selected = issuable.assignees.nodes.map((node) => ({ + ...node, + canMerge: node.mergeRequestInteraction?.canMerge || false, + })); } }, error() { @@ -112,7 +114,7 @@ export default { computed: { shouldEnableRealtime() { // Note: Realtime is only available on issues right now, future support for MR wil be built later. - return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue; + return this.issuableType === IssuableType.Issue; }, queryVariables() { return { @@ -141,6 +143,7 @@ export default { username: gon?.current_username, name: gon?.current_user_fullname, avatarUrl: gon?.current_user_avatar_url, + canMerge: this.issuable?.userPermissions?.canMerge || false, }; }, signedIn() { @@ -206,8 +209,8 @@ export default { expandWidget() { this.$refs.toggle.expand(); }, - focusSearch() { - this.$refs.userSelect.focusSearch(); + showDropdown() { + this.$refs.userSelect.showDropdown(); }, showError() { createFlash({ message: __('An error occurred while fetching participants.') }); @@ -236,11 +239,11 @@ export default { :initial-loading="isAssigneesLoading" :title="assigneeText" :is-dirty="isDirty" - @open="focusSearch" + @open="showDropdown" @close="saveAssignees" > <template #collapsed> - <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> + <slot name="collapsed" :users="assignees"></slot> <issuable-assignees :users="assignees" :issuable-type="issuableType" @@ -256,12 +259,13 @@ export default { :text="$options.i18n.assignees" :header-text="$options.i18n.assignTo" :iid="iid" + :issuable-id="issuableId" :full-path="fullPath" :allow-multiple-assignees="allowMultipleAssignees" :current-user="currentUser" :issuable-type="issuableType" :is-editing="edit" - class="gl-w-full dropdown-menu-user" + class="gl-w-full dropdown-menu-user gl-mt-n3" @toggle="collapseWidget" @error="showError" @input="setDirtyState" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index 8ef65ef7308..28bc5afc1a4 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -30,6 +30,6 @@ export default { :event="$options.dataTrackEvent" :label="$options.dataTrackLabel" :trigger-source="triggerSource" - classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!" /> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index e2a38a100b9..19f588b28be 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,17 +1,24 @@ <script> -import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; export default { components: { GlAvatarLabeled, GlAvatarLink, + GlIcon, }, props: { user: { type: Object, required: true, }, + issuableType: { + type: String, + required: false, + default: IssuableType.Issue, + }, }, computed: { userLabel() { @@ -22,6 +29,9 @@ export default { author: this.user.name, }); }, + hasCannotMergeIcon() { + return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge; + }, }, }; </script> @@ -31,9 +41,19 @@ export default { <gl-avatar-labeled :size="32" :label="userLabel" - :sub-label="user.username" + :sub-label="`@${user.username}`" :src="user.avatarUrl || user.avatar || user.avatar_url" - class="gl-align-items-center" - /> + class="gl-align-items-center gl-relative" + > + <template #meta> + <gl-icon + v-if="hasCannotMergeIcon" + name="warning-solid" + aria-hidden="true" + class="merge-icon" + :size="12" + /> + </template> + </gl-avatar-labeled> </gl-avatar-link> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index a27dbee31ec..558fe8ca2aa 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -114,7 +114,7 @@ export default { class="gl-display-inline-block" > <attention-requested-toggle - v-if="showVerticalList && user.can_update_merge_request" + v-if="showVerticalList" :user="user" type="assignee" @toggle-attention-requested="toggleAttentionRequested" diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue index 42e56906e2c..6ba88939373 100644 --- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue +++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue @@ -8,6 +8,8 @@ export default { attentionRequestedReviewer: __('Request attention to review'), attentionRequestedAssignee: __('Request attention'), removeAttentionRequested: __('Remove attention request'), + attentionRequestedNoPermission: __('Attention requested'), + noAttentionRequestedNoPermission: __('No attention request'), }, components: { GlButton, @@ -33,17 +35,25 @@ export default { computed: { tooltipTitle() { if (this.user.attention_requested) { - return this.$options.i18n.removeAttentionRequested; + if (this.user.can_update_merge_request) { + return this.$options.i18n.removeAttentionRequested; + } + + return this.$options.i18n.attentionRequestedNoPermission; + } + + if (this.user.can_update_merge_request) { + return this.type === 'reviewer' + ? this.$options.i18n.attentionRequestedReviewer + : this.$options.i18n.attentionRequestedAssignee; } - return this.type === 'reviewer' - ? this.$options.i18n.attentionRequestedReviewer - : this.$options.i18n.attentionRequestedAssignee; + return this.$options.i18n.noAttentionRequestedNoPermission; }, }, methods: { toggleAttentionRequired() { - if (this.loading) return; + if (this.loading || !this.user.can_update_merge_request) return; this.$root.$emit(BV_HIDE_TOOLTIP); this.loading = true; @@ -60,12 +70,16 @@ export default { </script> <template> - <span v-gl-tooltip.left.viewport="tooltipTitle"> + <span + v-gl-tooltip.left.viewport="tooltipTitle" + class="gl-display-inline-block js-attention-request-toggle" + > <gl-button :loading="loading" :variant="user.attention_requested ? 'warning' : 'default'" :icon="user.attention_requested ? 'attention-solid' : 'attention'" :aria-label="tooltipTitle" + :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }" size="small" category="tertiary" @click="toggleAttentionRequired" diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js new file mode 100644 index 00000000000..cd05a6099fd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/constants.js @@ -0,0 +1,25 @@ +import { s__ } from '~/locale'; + +export const STATUS_TRIGGERED = 'TRIGGERED'; +export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED'; +export const STATUS_RESOLVED = 'RESOLVED'; + +export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered'); +export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged'); +export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved'); + +export const STATUS_LABELS = { + [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL, + [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL, + [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL, +}; + +export const i18n = { + fetchError: s__( + 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.', + ), + title: s__('IncidentManagement|Status'), + updateError: s__( + 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.', + ), +}; diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue new file mode 100644 index 00000000000..2c32cf89387 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -0,0 +1,61 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants'; +import { getStatusLabel } from './utils'; + +const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED]; + +export default { + i18n, + STATUS_LIST, + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + value: { + type: String, + required: false, + default: null, + validator(value) { + return [...STATUS_LIST, null].includes(value); + }, + }, + }, + computed: { + currentStatusLabel() { + return this.getStatusLabel(this.value); + }, + }, + methods: { + show() { + this.$refs.dropdown.show(); + }, + hide() { + this.$refs.dropdown.hide(); + }, + getStatusLabel, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + block + :text="currentStatusLabel" + toggle-class="dropdown-menu-toggle gl-mb-2" + > + <slot name="header"> </slot> + <gl-dropdown-item + v-for="status in $options.STATUS_LIST" + :key="status" + data-testid="status-dropdown-item" + :is-check-item="true" + :is-checked="status === value" + @click="$emit('input', status)" + > + {{ getStatusLabel(status) }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue new file mode 100644 index 00000000000..67ae1e6fcab --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue @@ -0,0 +1,135 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants'; +import { createAlert } from '~/flash'; +import { logError } from '~/lib/logger'; +import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; +import { i18n } from './constants'; +import { getStatusLabel } from './utils'; + +export default { + i18n, + components: { + EscalationStatus, + SidebarEditableItem, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + iid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + status: null, + isUpdating: false, + }; + }, + apollo: { + status: { + query() { + return escalationStatusQuery; + }, + variables() { + return { + fullPath: this.projectPath, + iid: this.iid, + }; + }, + update(data) { + return data.workspace?.issuable?.escalationStatus; + }, + error(error) { + const message = this.$options.i18n.fetchError; + createAlert({ message }); + logError(message, error); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.status.loading; + }, + currentStatusLabel() { + return getStatusLabel(this.status); + }, + tooltipText() { + return `${this.$options.i18n.title}: ${this.currentStatusLabel}`; + }, + }, + methods: { + updateStatus(status) { + this.isUpdating = true; + this.closeSidebar(); + return this.$apollo + .mutate({ + mutation: escalationStatusMutation, + variables: { + status, + iid: this.iid, + projectPath: this.projectPath, + }, + }) + .then(({ data: { issueSetEscalationStatus } }) => { + this.status = issueSetEscalationStatus.issue.escalationStatus; + }) + .catch((error) => { + const message = this.$options.i18n.updateError; + createAlert({ message }); + logError(message, error); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + closeSidebar() { + this.close(); + this.$refs.editable.collapse(); + }, + open() { + this.$refs.escalationStatus.show(); + }, + close() { + this.$refs.escalationStatus.hide(); + }, + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.title" + :initial-loading="isLoading" + :loading="isUpdating" + @open="open" + @close="close" + > + <template #default> + <escalation-status ref="escalationStatus" :value="status" @input="updateStatus" /> + </template> + <template #collapsed> + <div + v-gl-tooltip.viewport.left="tooltipText" + class="sidebar-collapsed-icon" + data-testid="status-icon" + > + <gl-icon name="status" :size="16" /> + </div> + <span class="hide-collapsed text-secondary">{{ currentStatusLabel }}</span> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js new file mode 100644 index 00000000000..59bf1ea466c --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/utils.js @@ -0,0 +1,5 @@ +import { s__ } from '~/locale'; + +import { STATUS_LABELS } from './constants'; + +export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None'); diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index adaf1b65f3f..9485802d3da 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -98,7 +98,7 @@ export default { data-testid="reviewer" > <attention-requested-toggle - v-if="glFeatures.mrAttentionRequests && user.can_update_merge_request" + v-if="glFeatures.mrAttentionRequests" :user="user" type="reviewer" @toggle-attention-requested="toggleAttentionRequested" diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue index 7e7d62256c9..0db856543d0 100644 --- a/app/assets/javascripts/sidebar/components/severity/severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/severity.vue @@ -1,9 +1,11 @@ <script> import { GlIcon } from '@gitlab/ui'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { GlIcon, + TooltipOnTruncate, }, props: { severity: { @@ -30,13 +32,15 @@ export default { <template> <div - class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between" + class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full" > <gl-icon :size="iconSize" :name="`severity-${severity.icon}`" :class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]" /> - <span v-if="!iconOnly">{{ severity.label }}</span> + <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{ + severity.label + }}</tooltip-on-truncate> </div> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 0238fb8e8d5..989dc574bc3 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,7 +1,8 @@ import { s__, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; import { IssuableType, WorkspaceType } from '~/issues/constants'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; @@ -49,12 +50,12 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql'; +import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql'; import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; import projectMilestonesQuery from './queries/project_milestones.query.graphql'; -export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; - export const defaultEpicSort = 'TITLE_ASC'; export const epicIidPattern = /^&(?<iid>\d+)$/; @@ -91,6 +92,15 @@ export const participantsQueries = { }, }; +export const userSearchQueries = { + [IssuableType.Issue]: { + query: userSearchQuery, + }, + [IssuableType.MergeRequest]: { + query: userSearchWithMRPermissionsQuery, + }, +}; + export const confidentialityQueries = { [IssuableType.Issue]: { query: issueConfidentialQuery, @@ -305,3 +315,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) { ), }; } + +export const escalationStatusQuery = getEscalationStatusQuery; +export const escalationStatusMutation = updateEscalationStatusMutation; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index c29784aa328..2a7d967cb61 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -10,6 +10,7 @@ import { isInIssuePage, isInDesignPage, isInIncidentPage, + isInMRPage, parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -27,9 +28,11 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_v import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import eventHub from '~/sidebar/event_hub'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; +import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; @@ -134,6 +137,8 @@ function mountAssigneesComponent() { if (!el) return; const { id, iid, fullPath, editable } = getSidebarOptions(); + const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage(); + const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest; // eslint-disable-next-line no-new new Vue({ el, @@ -151,21 +156,16 @@ function mountAssigneesComponent() { props: { iid: String(iid), fullPath, - issuableType: - isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue - : IssuableType.MergeRequest, + issuableType, issuableId: id, allowMultipleAssignees: !el.dataset.maxAssignees, }, scopedSlots: { - collapsed: ({ users, onClick }) => + collapsed: ({ users }) => createElement(CollapsedAssigneeList, { props: { users, - }, - nativeOn: { - click: onClick, + issuableType, }, }), }, @@ -567,6 +567,36 @@ function mountSeverityComponent() { }); } +function mountEscalationStatusComponent() { + const statusContainerEl = document.querySelector('#js-escalation-status'); + + if (!statusContainerEl) { + return false; + } + + const { issuableType } = getSidebarOptions(); + const { canUpdate, issueIid, projectPath } = statusContainerEl.dataset; + + return new Vue({ + el: statusContainerEl, + apolloProvider, + components: { + SidebarEscalationStatus, + }, + provide: { + canUpdate: parseBoolean(canUpdate), + }, + render: (createElement) => + createElement('sidebar-escalation-status', { + props: { + iid: issueIid, + issuableType, + projectPath, + }, + }), + }); +} + function mountCopyEmailComponent() { const el = document.getElementById('issuable-copy-email'); @@ -584,7 +614,7 @@ function mountCopyEmailComponent() { } const isAssigneesWidgetShown = - (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; export function mountSidebar(mediator, store) { initInviteMembersModal(); @@ -618,10 +648,13 @@ export function mountSidebar(mediator, store) { mountSeverityComponent(); + mountEscalationStatusComponent(); + if (window.gon?.features?.mrAttentionRequests) { - eventHub.$on('removeCurrentUserAttentionRequested', () => - mediator.removeCurrentUserAttentionRequested(), - ); + eventHub.$on('removeCurrentUserAttentionRequested', () => { + mediator.removeCurrentUserAttentionRequested(); + refreshUserMergeRequestCounts(); + }); } } diff --git a/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql new file mode 100644 index 00000000000..cb7c5a0fbe7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql @@ -0,0 +1,9 @@ +query escalationStatusQuery($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + escalationStatus + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql new file mode 100644 index 00000000000..a4aff7968df --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql @@ -0,0 +1,10 @@ +mutation updateEscalationStatus($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) { + issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) { + errors + clientMutationId + issue { + id + escalationStatus + } + } +} diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 1be670f7590..74ab65e4e04 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -3,7 +3,17 @@ import Mediator from './sidebar_mediator'; export default (store) => { const mediator = new Mediator(getSidebarOptions()); - mediator.fetch(); + mediator + .fetch() + .then(() => { + if (window.gon?.features?.mrAttentionRequests) { + return import('~/attention_requests'); + } + + return null; + }) + .then((module) => module?.initSideNavPopover()) + .catch(() => {}); mountSidebar(mediator, store); }; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 4664bb56958..83fb8f31dfb 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -2,6 +2,7 @@ import Store from '~/sidebar/stores/sidebar_store'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { visitUrl } from '../lib/utils/url_utility'; import Service from './services/sidebar_service'; @@ -125,6 +126,7 @@ export default class SidebarMediator { this.store.updateReviewer(user.id, 'attention_requested'); this.store.updateAssignee(user.id, 'attention_requested'); + refreshUserMergeRequestCounts(); callback(); } catch (error) { callback(); diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index d2841156e55..b7159fd6835 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,6 +1,7 @@ /* eslint-disable consistent-return */ import $ from 'jquery'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { spriteIcon } from '~/lib/utils/common_utils'; import FilesCommentButton from './files_comment_button'; import createFlash from './flash'; @@ -10,7 +11,7 @@ import { __ } from './locale'; import syntaxHighlight from './syntax_highlight'; const WRAPPER = '<div class="diff-content"></div>'; -const LOADING_HTML = '<span class="spinner"></span>'; +const LOADING_HTML = loadingIconForLegacyJS().outerHTML; const ERROR_HTML = `<div class="nothing-here-block">${spriteIcon( 'warning-solid', 's16', diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue index a5a613b7282..fd9177bef3f 100644 --- a/app/assets/javascripts/terraform/components/empty_state.vue +++ b/app/assets/javascripts/terraform/components/empty_state.vue @@ -16,7 +16,7 @@ export default { }, computed: { docsUrl() { - return helpPagePath('user/infrastructure/terraform_state'); + return helpPagePath('user/infrastructure/iac/terraform_state'); }, }, }; diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js deleted file mode 100644 index 5b85107991a..00000000000 --- a/app/assets/javascripts/toggle_buttons.js +++ /dev/null @@ -1,63 +0,0 @@ -import $ from 'jquery'; -import createFlash from './flash'; -import { parseBoolean } from './lib/utils/common_utils'; -import { __ } from './locale'; - -/* - example HAML: - ``` - %button.js-project-feature-toggle.project-feature-toggle{ type: "button", - class: "#{'is-checked' if enabled?}", - 'aria-label': _('Toggle Kubernetes Cluster') } - %input{ type: "hidden", class: 'js-project-feature-toggle-input', value: enabled? } - ``` -*/ - -function updateToggle(toggle, isOn) { - toggle.classList.toggle('is-checked', isOn); -} - -function onToggleClicked(toggle, input, clickCallback) { - const previousIsOn = parseBoolean(input.value); - - // Visually change the toggle and start loading - updateToggle(toggle, !previousIsOn); - toggle.setAttribute('disabled', true); - toggle.classList.toggle('is-loading', true); - - Promise.resolve(clickCallback(!previousIsOn, toggle)) - .then(() => { - // Actually change the input value - input.setAttribute('value', !previousIsOn); - }) - .catch(() => { - // Revert the visuals if something goes wrong - updateToggle(toggle, previousIsOn); - }) - .then(() => { - // Remove the loading indicator in any case - toggle.removeAttribute('disabled'); - toggle.classList.toggle('is-loading', false); - - $(input).trigger('trigger-change'); - }) - .catch(() => { - createFlash({ - message: __('Something went wrong when toggling the button'), - }); - }); -} - -export default function setupToggleButtons(container, clickCallback = () => {}) { - const toggles = container.querySelectorAll('.js-project-feature-toggle'); - - toggles.forEach((toggle) => { - const input = toggle.querySelector('.js-project-feature-toggle-input'); - const isOn = parseBoolean(input.value); - - // Get the visible toggle in sync with the hidden input - updateToggle(toggle, isOn); - - toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback)); - }); -} diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js index 046b9fc7dcd..5848b3a424c 100644 --- a/app/assets/javascripts/toggles/index.js +++ b/app/assets/javascripts/toggles/index.js @@ -8,16 +8,12 @@ export const initToggle = (el) => { return false; } - const { - name, - isChecked, - disabled, - isLoading, - label, - help, - labelPosition, - ...dataset - } = el.dataset; + const { name, id, isChecked, disabled, isLoading, label, help, labelPosition, ...dataset } = + el.dataset || {}; + + const dataAttrs = Object.fromEntries( + Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]), + ); return new Vue({ el, @@ -50,9 +46,7 @@ export const initToggle = (el) => { labelPosition, }, class: el.className, - attrs: Object.fromEntries( - Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]), - ), + attrs: { id, ...dataAttrs }, on: { change: (newValue) => { this.value = newValue; diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js index bc9d7384ea4..7e596f5f36f 100644 --- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -10,7 +10,8 @@ export function dispatchSnowplowEvent( throw new Error('Tracking: no category provided for tracking.'); } - const { label, property, value, extra = {} } = data; + const { label, property, extra = {} } = data; + let { value } = data; const standardContext = getStandardContext({ extra }); const contexts = [standardContext]; @@ -19,5 +20,9 @@ export function dispatchSnowplowEvent( contexts.push(data.context); } + if (value !== undefined) { + value = Number(value); + } + return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); } diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js index c26abc261ed..173eef0646b 100644 --- a/app/assets/javascripts/tracking/tracking.js +++ b/app/assets/javascripts/tracking/tracking.js @@ -10,6 +10,8 @@ import { addReferrersCacheEntry, } from './utils'; +const ALLOWED_URL_HASHES = ['#diff', '#note']; + export default class Tracking { static queuedEvents = []; static initialized = false; @@ -183,7 +185,9 @@ export default class Tracking { originalUrl: window.location.href, }); - window.snowplow('setCustomUrl', pageLinks.url); + const appendHash = ALLOWED_URL_HASHES.some((prefix) => window.location.hash.startsWith(prefix)); + const customUrl = `${pageUrl}${appendHash ? window.location.hash : ''}`; + window.snowplow('setCustomUrl', customUrl); if (document.referrer) { const node = referrers.find((links) => links.originalUrl === document.referrer); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 8ed92e6b948..656c851aa3d 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -210,7 +210,7 @@ function UsersSelect(currentUser, els, options = {}) { return axios.put(issueURL, data).then(({ data }) => { let user = {}; - let tooltipTitle = user.name; + let tooltipTitle; $dropdown.trigger('loaded.gl.dropdown'); $loading.addClass('gl-display-none'); if (data.assignee) { @@ -806,7 +806,9 @@ UsersSelect.prototype.renderRow = function ( </strong> ${ username - ? `<span class="dropdown-menu-user-username gl-text-gray-400">${username}</span>` + ? `<span class="dropdown-menu-user-username gl-text-gray-400">${escape( + username, + )}</span>` : '' } ${this.renderApprovalRules(elsClassName, user.applicable_approval_rules)} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index a25b4ab54e5..684386883c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -2,21 +2,20 @@ import { GlButton, GlLoadingIcon, - GlLink, - GlBadge, GlSafeHtmlDirective, GlTooltipDirective, GlIntersectionObserver, } from '@gitlab/ui'; import { once } from 'lodash'; import * as Sentry from '@sentry/browser'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import api from '~/api'; import { sprintf, s__, __ } from '~/locale'; -import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import Poll from '~/lib/utils/poll'; import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; import StatusIcon from './status_icon.vue'; import Actions from './actions.vue'; +import ChildContent from './child_content.vue'; import { generateText } from './utils'; export const LOADING_STATES = { @@ -30,12 +29,12 @@ export default { components: { GlButton, GlLoadingIcon, - GlLink, - GlBadge, GlIntersectionObserver, - SmartVirtualList, StatusIcon, Actions, + ChildContent, + DynamicScroller, + DynamicScrollerItem, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -188,7 +187,7 @@ export default { this.fetchFullData(this.$props) .then((data) => { this.loadingState = null; - this.fullData = data; + this.fullData = data.map((x, i) => ({ id: i, ...x })); }) .catch((e) => { this.loadingState = LOADING_STATES.expandedError; @@ -196,9 +195,6 @@ export default { Sentry.captureException(e); }); }, - isArray(arr) { - return Array.isArray(arr); - }, appear(index) { if (index === this.fullData.length - 1) { this.showFade = false; @@ -281,80 +277,33 @@ export default { <div v-if="isLoadingExpanded" class="report-block-container"> <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> - <smart-virtual-list + <dynamic-scroller v-else-if="hasFullData" - :length="fullData.length" - :remain="20" - :size="32" - wtag="ul" - wclass="report-block-list" + :items="fullData" + :min-item-size="32" class="report-block-container gl-px-5 gl-py-0" > - <li - v-for="(data, index) in fullData" - :key="data.id" - :class="{ - 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1, - }" - class="gl-py-3 gl-pl-7" - data-testid="extension-list-item" - > - <div class="gl-w-full"> - <div v-if="data.header" class="gl-mb-2"> - <template v-if="isArray(data.header)"> - <component - :is="headerI === 0 ? 'strong' : 'span'" - v-for="(header, headerI) in data.header" - :key="headerI" - v-safe-html="generateText(header)" - class="gl-display-block" - /> - </template> - <strong v-else v-safe-html="generateText(data.header)"></strong> - </div> - <div class="gl-display-flex"> - <status-icon - v-if="data.icon" - :icon-name="data.icon.name" - :size="12" - class="gl-pl-0" - /> + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active" :class="{ active }"> + <div + :class="{ + 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1, + }" + class="gl-py-3 gl-pl-7" + data-testid="extension-list-item" + > <gl-intersection-observer :options="{ rootMargin: '100px', thresholds: 0.1 }" class="gl-w-full" @appear="appear(index)" @disappear="disappear(index)" > - <div class="gl-flex-wrap gl-display-flex gl-w-full"> - <div class="gl-mr-4 gl-display-flex gl-align-items-center"> - <p v-safe-html="generateText(data.text)" class="gl-m-0"></p> - </div> - <div v-if="data.link"> - <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> - </div> - <div v-if="data.supportingText"> - <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> - </div> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> - {{ data.badge.text }} - </gl-badge> - - <actions - :widget="$options.label || $options.name" - :tertiary-buttons="data.actions" - class="gl-ml-auto" - /> - </div> - <p - v-if="data.subtext" - v-safe-html="generateText(data.subtext)" - class="gl-m-0 gl-font-sm" - ></p> + <child-content :data="item" :widget-label="widgetLabel" :level="2" /> </gl-intersection-observer> </div> - </div> - </li> - </smart-virtual-list> + </dynamic-scroller-item> + </template> + </dynamic-scroller> <div :class="{ show: showFade }" class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue new file mode 100644 index 00000000000..5f42c6c7acb --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -0,0 +1,95 @@ +<script> +import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import StatusIcon from './status_icon.vue'; +import Actions from './actions.vue'; +import { generateText } from './utils'; + +export default { + name: 'ChildContent', + components: { + GlBadge, + GlLink, + StatusIcon, + Actions, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + data: { + type: Object, + required: true, + }, + widgetLabel: { + type: String, + required: true, + }, + level: { + type: Number, + required: true, + }, + }, + methods: { + isArray(arr) { + return Array.isArray(arr); + }, + generateText, + }, +}; +</script> + +<template> + <div :class="{ 'gl-pl-6': level === 3 }" class="gl-w-full"> + <div v-if="data.header" class="gl-mb-2"> + <template v-if="isArray(data.header)"> + <component + :is="headerI === 0 ? 'strong' : 'span'" + v-for="(header, headerI) in data.header" + :key="headerI" + v-safe-html="generateText(header)" + class="gl-display-block" + /> + </template> + <strong v-else v-safe-html="generateText(data.header)"></strong> + </div> + <div class="gl-display-flex"> + <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> + <div class="gl-w-full"> + <div class="gl-flex-wrap gl-display-flex gl-w-full"> + <div class="gl-mr-4 gl-display-flex gl-align-items-center"> + <p v-safe-html="generateText(data.text)" class="gl-m-0"></p> + </div> + <div v-if="data.link"> + <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + </div> + <div v-if="data.supportingText"> + <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> + </div> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> + <actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" /> + </div> + <p + v-if="data.subtext" + v-safe-html="generateText(data.subtext)" + class="gl-m-0 gl-font-sm" + ></p> + </div> + </div> + <template v-if="data.children && level === 2"> + <ul class="gl-m-0 gl-p-0 gl-list-style-none"> + <li> + <child-content + v-for="childData in data.children" + :key="childData.id" + :data="childData" + :widget-label="widgetLabel" + :level="3" + data-testid="child-content" + /> + </li> + </ul> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index b75f2dce54e..f5667aee15b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -70,7 +70,9 @@ export default { <template v-if="isCollapsed"> <slot name="header"></slot> <gl-button - variant="link" + category="tertiary" + variant="confirm" + size="small" data-testid="mr-collapsible-title" :disabled="isLoading" :class="{ 'border-0': isLoading }" @@ -81,7 +83,9 @@ export default { </template> <gl-button v-else - variant="link" + category="tertiary" + variant="confirm" + size="small" data-testid="mr-collapsible-title" :disabled="isLoading" :class="{ 'border-0': isLoading }" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index 68cff1368af..b062833cdf8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable @gitlab/require-i18n-strings */ import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { escapeShellString } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -10,24 +11,26 @@ export default { steps: { step1: { label: __('Step 1.'), - help: __('Fetch and check out the branch for this merge request'), + help: __("Fetch and check out this merge request's feature branch:"), }, step2: { label: __('Step 2.'), - help: __('Review the changes locally'), + help: __('Review the changes locally.'), }, step3: { label: __('Step 3.'), - help: __('Merge the branch and fix any conflicts that come up'), + help: __( + 'Merge the feature branch into the target branch and fix any conflicts. %{linkStart}How do I fix them?%{linkEnd}', + ), }, step4: { label: __('Step 4.'), - help: __('Push the result of the merge to GitLab'), + help: __('Push the target branch up to GitLab.'), }, }, copyCommands: __('Copy commands'), tip: __( - '%{strongStart}Tip:%{strongEnd} You can also checkout merge requests locally by %{linkStart}following these guidelines%{linkEnd}', + '%{strongStart}Tip:%{strongEnd} You can also check out merge requests locally. %{linkStart}Learn more.%{linkEnd}', ), title: __('Check out, review, and merge locally'), }, @@ -74,6 +77,13 @@ export default { default: null, }, }, + data() { + return { + resolveConflictsFromCli: helpPagePath('ee/user/project/merge_requests/conflicts.html', { + anchor: 'resolve-conflicts-from-the-command-line', + }), + }; + }, computed: { mergeInfo1() { const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`); @@ -138,7 +148,13 @@ export default { <strong> {{ $options.i18n.steps.step3.label }} </strong> - {{ $options.i18n.steps.step3.help }} + <gl-sprintf :message="$options.i18n.steps.step3.help"> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="resolveConflictsFromCli"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> </p> <div class="gl-display-flex"> <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre> @@ -163,7 +179,7 @@ export default { /> </div> <p v-if="reviewingDocsPath"> - <gl-sprintf :message="$options.i18n.tip"> + <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip"> <template #strong="{ content }"> <strong>{{ content }}</strong> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 730d11b1208..2cef37d5c2e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui'; import { s__, n__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -8,6 +8,9 @@ export default { directives: { SafeHtml, }, + components: { + GlLink, + }, mixins: [glFeatureFlagMixin()], props: { relatedLinks: { @@ -37,6 +40,17 @@ export default { return n__('mrWidget|Closes issue', 'mrWidget|Closes issues', this.relatedLinks.closingCount); }, + assignIssueText() { + if (this.relatedLinks.unassignedCount > 1) { + return s__('mrWidget|Assign yourself to these issues'); + } + return s__('mrWidget|Assign yourself to this issue'); + }, + shouldShowAssignToMeLink() { + return ( + this.relatedLinks.unassignedCount && this.relatedLinks.assignToMe && this.showAssignToMe + ); + }, }, }; </script> @@ -44,23 +58,28 @@ export default { <section> <p v-if="relatedLinks.closing" - :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" + :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }" > {{ closesText }} <span v-safe-html="relatedLinks.closing"></span> </p> <p v-if="relatedLinks.mentioned" - :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" + :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }" > + <span v-if="relatedLinks.closing && glFeatures.restructuredMrWidget">·</span> {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} <span v-safe-html="relatedLinks.mentioned"></span> </p> <p - v-if="relatedLinks.assignToMe && showAssignToMe" - :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" + v-if="shouldShowAssignToMeLink" + :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }" > - <span v-html="relatedLinks.assignToMe /* eslint-disable-line vue/no-v-html */"></span> + <span> + <gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{ + assignIssueText + }}</gl-link> + </span> </p> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue index 73d75352cb5..5baeb309f79 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -21,7 +21,9 @@ export default { <gl-dropdown right text="Use an existing commit message" - variant="link" + category="tertiary" + variant="confirm" + size="small" class="mr-commit-dropdown" > <gl-dropdown-item diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index 5c4a526bcc3..400759aa086 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -77,7 +77,7 @@ export default { :target-branch="targetBranch" /> </span> - <gl-button variant="link" class="modify-message-button"> + <gl-button category="tertiary" variant="confirm" size="small" class="modify-message-button"> {{ modifyLinkMessage }} </gl-button> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index a2c9cfe53cc..7435f578852 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -82,17 +82,8 @@ export default { return this.mr.shouldBeRebased; }, - sourceBranchProtected() { - if (this.glFeatures.mergeRequestWidgetGraphql) { - return this.stateData.sourceBranchProtected; - } - - return this.mr.sourceBranchProtected; - }, showResolveButton() { - return ( - this.mr.conflictResolutionPath && this.canPushToSourceBranch && !this.sourceBranchProtected - ); + return this.mr.conflictResolutionPath && this.canPushToSourceBranch; }, }, }; @@ -144,7 +135,7 @@ export default { :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'" data-testid="merge-locally-button" > - {{ s__('mrWidget|Merge locally') }} + {{ s__('mrWidget|Resolve locally') }} </gl-button> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index bb0fb410d3e..ebdc8309cd5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -3,13 +3,11 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import ActionsButton from '~/vue_shared/components/actions_button.vue'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import rebaseQuery from '../../queries/states/rebase.query.graphql'; import statusIcon from '../mr_widget_status_icon.vue'; -import { REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY } from '../../constants'; export default { name: 'MRWidgetRebase', @@ -28,7 +26,6 @@ export default { components: { statusIcon, GlSkeletonLoader, - ActionsButton, GlButton, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], @@ -47,7 +44,6 @@ export default { state: {}, isMakingRequest: false, rebasingError: null, - selectedRebaseAction: REBASE_BUTTON_KEY, }; }, computed: { @@ -93,28 +89,6 @@ export default { fastForwardMergeText() { return __('Merge blocked: the source branch must be rebased onto the target branch.'); }, - actions() { - return [this.rebaseAction, this.rebaseWithoutCiAction].filter((action) => action); - }, - rebaseAction() { - return { - key: REBASE_BUTTON_KEY, - text: __('Rebase'), - secondaryText: __('Rebases and triggers a pipeline'), - attrs: { - 'data-qa-selector': 'mr_rebase_button', - }, - handle: () => this.rebase(), - }; - }, - rebaseWithoutCiAction() { - return { - key: REBASE_WITHOUT_CI_BUTTON_KEY, - text: __('Rebase without CI'), - secondaryText: __('Performs a rebase but skips triggering a new pipeline'), - handle: () => this.rebase({ skipCi: true }), - }; - }, }, methods: { rebase({ skipCi = false } = {}) { @@ -138,8 +112,8 @@ export default { } }); }, - selectRebaseAction(key) { - this.selectedRebaseAction = key; + rebaseWithoutCi() { + return this.rebase({ skipCi: true }); }, checkRebaseStatus(continuePolling, stopPolling) { this.service @@ -198,10 +172,10 @@ export default { > <div v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" - class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" + class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center" > <gl-button - v-if="!glFeatures.restructuredMrWidget && !showRebaseWithoutCi" + v-if="!glFeatures.restructuredMrWidget" :loading="isMakingRequest" variant="confirm" data-qa-selector="mr_rebase_button" @@ -210,14 +184,16 @@ export default { > {{ __('Rebase') }} </gl-button> - <actions-button + <gl-button v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi" - :actions="actions" - :selected-key="selectedRebaseAction" + :loading="isMakingRequest" variant="confirm" - category="primary" - @select="selectRebaseAction" - /> + category="secondary" + data-testid="rebase-without-ci-button" + @click="rebaseWithoutCi" + > + {{ __('Rebase without pipeline') }} + </gl-button> <span v-if="!rebasingError" :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" 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 bc094501e89..4f8faeb877f 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 @@ -82,6 +82,13 @@ export default { }; this.loading = false; + if (!this.commitMessageIsTouched) { + this.commitMessage = this.state.defaultMergeCommitMessage; + } + if (!this.squashCommitMessageIsTouched) { + this.squashCommitMessage = this.state.defaultSquashCommitMessage; + } + if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) { this.initPolling(); } @@ -133,9 +140,11 @@ export default { isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, + commitMessageIsTouched: false, squashBeforeMerge: this.mr.squashIsSelected, isSquashReadOnly: this.mr.squashIsReadonly, squashCommitMessage: this.mr.squashCommitMessage, + squashCommitMessageIsTouched: false, isPipelineFailedModalVisibleMergeTrain: false, isPipelineFailedModalVisibleNormalMerge: false, editCommitMessage: false, @@ -295,13 +304,6 @@ export default { return enableSquashBeforeMerge; }, - shouldShowMergeControls() { - if (this.glFeatures.restructuredMrWidget) { - return this.restructuredWidgetShowMergeButtons; - } - - return this.isMergeAllowed || this.isAutoMergeAvailable; - }, shouldShowSquashEdit() { return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge; }, @@ -472,6 +474,14 @@ export default { }); }); }, + setCommitMessage(val) { + this.commitMessage = val; + this.commitMessageIsTouched = true; + }, + setSquashCommitMessage(val) { + this.squashCommitMessage = val; + this.squashCommitMessageIsTouched = true; + }, }, i18n: { mergeCommitTemplateHintText: s__( @@ -637,21 +647,23 @@ export default { > <commit-edit v-if="shouldShowSquashEdit" - v-model="squashCommitMessage" + :value="squashCommitMessage" :label="__('Squash commit message')" input-id="squash-message-edit" class="gl-m-0! gl-p-0!" + @input="setSquashCommitMessage" > <template #header> - <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" /> + <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> </template> </commit-edit> <commit-edit v-if="shouldShowMergeEdit" - v-model="commitMessage" + :value="commitMessage" :label="__('Merge commit message')" input-id="merge-message-edit" class="gl-m-0! gl-p-0!" + @input="setCommitMessage" /> <li class="gl-m-0! gl-p-0!"> <p class="form-text text-muted"> @@ -755,20 +767,22 @@ export default { <ul class="border-top content-list commits-list flex-list"> <commit-edit v-if="shouldShowSquashEdit" - v-model="squashCommitMessage" + :value="squashCommitMessage" :label="__('Squash commit message')" input-id="squash-message-edit" squash + @input="setSquashCommitMessage" > <template #header> - <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" /> + <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> </template> </commit-edit> <commit-edit v-if="shouldShowMergeEdit" - v-model="commitMessage" + :value="commitMessage" :label="__('Merge commit message')" input-id="merge-message-edit" + @input="setCommitMessage" /> <li> <p class="form-text text-muted"> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index d337a554663..533bb38a88c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -166,6 +166,3 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500'; export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700'; export { STATE_MACHINE }; - -export const REBASE_BUTTON_KEY = 'rebase'; -export const REBASE_WITHOUT_CI_BUTTON_KEY = 'rebaseWithoutCi'; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js new file mode 100644 index 00000000000..d32db50874c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -0,0 +1,123 @@ +import { n__, s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; +import { SEVERITY_ICONS_EXTENSION } from '~/reports/codequality_report/constants'; +import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +export default { + name: 'WidgetCodeQuality', + props: ['codeQuality', 'blobPath'], + i18n: { + label: s__('ciReport|Code Quality'), + loading: s__('ciReport|Code Quality test metrics results are being parsed'), + error: s__('ciReport|Code Quality failed loading results'), + }, + expandEvent: 'i_testing_code_quality_widget_total', + computed: { + summary() { + const { newErrors, resolvedErrors, errorSummary } = this.collapsedData; + if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) { + const improvements = sprintf( + n__( + '%{strongOpen}%{errors}%{strongClose} point', + '%{strongOpen}%{errors}%{strongClose} points', + resolvedErrors.length, + ), + { + errors: resolvedErrors.length, + strongOpen: '<strong>', + strongClose: '</strong>', + }, + false, + ); + + const degradations = sprintf( + n__( + '%{strongOpen}%{errors}%{strongClose} point', + '%{strongOpen}%{errors}%{strongClose} points', + newErrors.length, + ), + { errors: newErrors.length, strongOpen: '<strong>', strongClose: '</strong>' }, + false, + ); + return sprintf( + s__(`ciReport|Code Quality improved on ${improvements} and degraded on ${degradations}.`), + ); + } else if (errorSummary.resolved >= 1) { + const improvements = n__('%d point', '%d points', resolvedErrors.length); + return sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)); + } else if (errorSummary.errored >= 1) { + const degradations = n__('%d point', '%d points', newErrors.length); + return sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)); + } + return s__(`ciReport|No changes to Code Quality.`); + }, + statusIcon() { + if (this.collapsedData.errorSummary?.errored >= 1) { + return EXTENSION_ICONS.warning; + } + return EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData() { + return Promise.all([this.fetchReport(this.codeQuality)]).then((values) => { + return { + resolvedErrors: parseCodeclimateMetrics( + values[0].resolved_errors, + this.blobPath.head_path, + ), + newErrors: parseCodeclimateMetrics(values[0].new_errors, this.blobPath.head_path), + existingErrors: parseCodeclimateMetrics( + values[0].existing_errors, + this.blobPath.head_path, + ), + errorSummary: values[0].summary, + }; + }); + }, + fetchFullData() { + const fullData = []; + + this.collapsedData.newErrors.map((e) => { + return fullData.push({ + text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, + subtext: sprintf( + s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`), + { + open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`, + close_link: '</a>', + }, + false, + ), + icon: { + name: SEVERITY_ICONS_EXTENSION[e.severity], + }, + }); + }); + + this.collapsedData.resolvedErrors.map((e) => { + return fullData.push({ + text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, + subtext: sprintf( + s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`), + { + open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`, + close_link: '</a>', + }, + false, + ), + icon: { + name: SEVERITY_ICONS_EXTENSION[e.severity], + }, + }); + }); + + return Promise.resolve(fullData); + }, + fetchReport(endpoint) { + return axios.get(endpoint).then((res) => res.data); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 4aeebf095c4..e52f2c2c666 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -88,6 +88,16 @@ export default { // text: 'Link text', // Required: Text to be used inside the link // }, actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + children: [ + { + id: `child-${issue.id}`, + header: 'New', + text: '%{critical_start}1 Critical%{critical_end}', + icon: { + name: EXTENSION_ICONS.error, + }, + }, + ], })); }); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 247a3711fc8..627ddb0445e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,8 +1,6 @@ import { __ } from '~/locale'; -export const MERGE_DISABLED_TEXT = __( - 'Merge blocked: all merge request dependencies must be merged or closed.', -); +export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __( "Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.", ); @@ -22,6 +20,13 @@ export default { this.mr.preventMerge, ); }, + shouldShowMergeControls() { + if (this.glFeatures.restructuredMrWidget) { + return this.restructuredWidgetShowMergeButtons; + } + + return this.isMergeAllowed || this.isAutoMergeAvailable; + }, mergeDisabledText() { if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) { return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 11de58aa344..965746e79fb 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 @@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab import getStateQuery from './queries/get_state.query.graphql'; import terraformExtension from './extensions/terraform'; import accessibilityExtension from './extensions/accessibility'; +import codeQualityExtension from './extensions/code_quality'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -241,6 +242,11 @@ export default { this.registerTerraformPlans(); } }, + shouldRenderCodeQuality(newVal) { + if (newVal) { + this.registerCodeQualityExtension(); + } + }, shouldShowAccessibilityReport(newVal) { if (newVal) { this.registerAccessibilityExtension(); @@ -352,6 +358,8 @@ export default { return Promise.resolve(); }, initPolling() { + if (this.startingPollInterval <= 0) return; + this.pollingInterval = new SmartInterval({ callback: this.checkStatus, startingInterval: this.startingPollInterval, @@ -435,10 +443,10 @@ export default { notify.notifyMe(title, message, this.mr.gitlabLogo); }, resumePolling() { - this.pollingInterval.resume(); + this.pollingInterval?.resume(); }, stopPolling() { - this.pollingInterval.stopTimer(); + this.pollingInterval?.stopTimer(); }, bindEventHubListeners() { eventHub.$on('MRWidgetUpdateRequested', (cb) => { @@ -489,6 +497,11 @@ export default { registerExtension(accessibilityExtension); } }, + registerCodeQualityExtension() { + if (this.shouldRenderCodeQuality && this.shouldShowExtension) { + registerExtension(codeQualityExtension); + } + }, }, }; </script> @@ -544,7 +557,7 @@ export default { </div> <extensions-container :mr="mr" /> <grouped-codequality-reports-app - v-if="shouldRenderCodeQuality" + v-if="shouldRenderCodeQuality && !shouldShowExtension" :head-blob-path="mr.headBlobPath" :base-blob-path="mr.baseBlobPath" :codequality-reports-path="mr.codequalityReportsPath" @@ -574,7 +587,7 @@ export default { /> <grouped-accessibility-reports-app - v-if="shouldShowAccessibilityReport" + v-if="shouldShowAccessibilityReport && !shouldShowExtension" :endpoint="mr.accessibilityReportPath" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index d85794f7245..99e6f4e9beb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -1,9 +1,11 @@ fragment ReadyToMerge on Project { + __typename id onlyAllowMergeIfPipelineSucceeds mergeRequestsFfOnlyEnabled squashReadOnly mergeRequest(iid: $iid) { + __typename id autoMergeEnabled shouldRemoveSourceBranch 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 5378dabf638..eb07609d5d6 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 @@ -32,9 +32,15 @@ export default class MergeRequestStore { this.setPaths(data); this.setData(data); + this.initCodeQualityReport(data); this.setGitpodData(data); } + initCodeQualityReport(data) { + this.blobPath = data.blob_path; + this.codeQuality = data.codequality_reports_path; + } + setData(data, isRebased) { this.initApprovals(); @@ -82,14 +88,16 @@ export default class MergeRequestStore { const { closing } = links; const mentioned = links.mentioned_but_not_closing; const assignToMe = links.assign_to_closing; + const unassignedCount = links.assign_to_closing_count; - if (closing || mentioned || assignToMe) { + if (closing || mentioned || unassignedCount) { this.relatedLinks = { closing, mentioned, assignToMe, closingCount: links.closing_count, mentionedCount: links.mentioned_count, + unassignedCount: links.assign_to_closing_count, }; } } diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index b6010d4b70c..96970f4ce2f 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -199,12 +199,15 @@ export default { <div v-if="canAwardEmoji" class="award-menu-holder gl-my-2"> <emoji-picker v-if="glFeatures.improvedEmojiPicker" + v-gl-tooltip.viewport + :title="__('Add reaction')" :toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]" @click="handleAward" @shown="setIsMenuOpen(true)" @hidden="setIsMenuOpen(false)" > <template #button-content> + <span class="gl-sr-only">{{ __('Add reaction') }}</span> <span class="reaction-control-icon reaction-control-icon-neutral"> <gl-icon name="slight-smile" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue index 7563c35dfc8..7a166f9a3e4 100644 --- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -7,6 +7,7 @@ :invalid-feedback="__('Please enter a valid hex (#RRGGBB or #RGB) color value')" :label="__('Background color')" :value="#FF0000" + :suggestedColors="{ '#ff0000': 'Red', '#808080': 'Gray' }", state="isValidColor" /> */ @@ -48,6 +49,11 @@ export default { required: false, default: null, }, + suggestedColors: { + type: Object, + required: false, + default: () => gon.suggested_label_colors, + }, }, computed: { description() { @@ -55,9 +61,6 @@ export default { ? this.$options.i18n.fullDescription : this.$options.i18n.shortDescription; }, - suggestedColors() { - return gon.suggested_label_colors; - }, previewColor() { if (this.state) { return { backgroundColor: this.value }; diff --git a/app/assets/javascripts/vue_shared/components/content_transition.vue b/app/assets/javascripts/vue_shared/components/content_transition.vue new file mode 100644 index 00000000000..446610d6b91 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_transition.vue @@ -0,0 +1,32 @@ +<script> +export default { + props: { + currentSlot: { + type: String, + required: true, + }, + slots: { + type: Array, + required: true, + }, + transitionName: { + type: String, + required: true, + }, + }, + methods: { + shouldShow(key) { + return this.currentSlot === key; + }, + }, +}; +</script> +<template> + <div> + <transition v-for="{ key, attributes } in slots" :key="key" :name="transitionName"> + <div v-show="shouldShow(key)" v-bind="attributes"> + <slot :name="key"></slot> + </div> + </transition> + </div> +</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 153b0981813..2a79ccc2648 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 @@ -1,22 +1,28 @@ <script> import { + GlIcon, GlLoadingIcon, GlDropdown, GlDropdownForm, GlDropdownDivider, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, } from '@gitlab/ui'; import { __ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { + GlIcon, GlLoadingIcon, GlDropdown, GlDropdownForm, GlDropdownDivider, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, + TooltipOnTruncate, }, props: { selectText: { @@ -39,6 +45,11 @@ export default { required: false, default: () => [], }, + groupedOptions: { + type: Array, + required: false, + default: () => [], + }, isLoading: { type: Boolean, required: false, @@ -79,11 +90,7 @@ export default { if (Array.isArray(this.selected)) { return this.selected.some((label) => label.title === option.title); } - return ( - this.selected && - ((option.name && this.selected.name === option.name) || - (option.title && this.selected.title === option.title)) - ); + return this.selected && option.id && this.selected.id === option.id; }, showDropdown() { this.$refs.dropdown.show(); @@ -101,6 +108,9 @@ export default { // TODO: this has some knowledge of the context where the component is used. We could later rework it. return option.username || null; }, + optionKey(option) { + return option.key ? option.key : option.id; + }, }, i18n: { noMatchingResults: __('No matching results'), @@ -154,10 +164,10 @@ export default { </template> <gl-dropdown-item v-for="option in options" - :key="option.id" + :key="optionKey(option)" :is-checked="isSelected(option)" - :is-check-centered="true" - :is-check-item="true" + is-check-centered + is-check-item :avatar-url="avatarUrl(option)" :secondary-text="secondaryText(option)" data-testid="unselected-option" @@ -167,6 +177,36 @@ export default { {{ option.title }} </slot> </gl-dropdown-item> + <template v-for="(optionGroup, index) in groupedOptions"> + <gl-dropdown-divider v-if="index !== 0" :key="index" /> + <gl-dropdown-section-header :key="optionGroup.id"> + <div class="gl-display-flex gl-max-w-full"> + <tooltip-on-truncate + :title="optionGroup.title" + class="gl-text-truncate gl-flex-grow-1" + > + {{ optionGroup.title }} + </tooltip-on-truncate> + <span v-if="optionGroup.secondaryText" class="gl-float-right gl-font-weight-normal"> + <gl-icon name="clock" class="gl-mr-2" /> + {{ optionGroup.secondaryText }} + </span> + </div> + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="option in optionGroup.options" + :key="optionKey(option)" + :is-checked="isSelected(option)" + is-check-centered + is-check-item + data-testid="unselected-option" + @click="selectOption(option)" + > + <slot name="item" :item="option"> + {{ option.title }} + </slot> + </gl-dropdown-item> + </template> <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> {{ $options.i18n.noMatchingResults }} </gl-dropdown-item> 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 157068b2c0f..e7923e0b55e 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 @@ -76,9 +76,10 @@ export default { }, data() { return { + hasFetched: false, // use this to avoid flash of `No suggestions found` before fetching searchKey: '', recentSuggestions: this.config.recentSuggestionsStorageKey - ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) + ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) ?? [] : [], }; }, @@ -86,6 +87,9 @@ export default { isRecentSuggestionsEnabled() { return Boolean(this.config.recentSuggestionsStorageKey); }, + suggestionsEnabled() { + return !this.config.suggestionsDisabled; + }, recentTokenIds() { return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, @@ -134,17 +138,6 @@ export default { showAvailableSuggestions() { return this.availableSuggestions.length > 0; }, - showSuggestions() { - // These conditions must match the template under `#suggestions` slot - // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411 - return ( - this.showDefaultSuggestions || - this.showRecentSuggestions || - this.showPreloadedSuggestions || - this.suggestionsLoading || - this.showAvailableSuggestions - ); - }, searchTerm() { return this.searchBy && this.activeTokenValue ? this.activeTokenValue[this.searchBy] @@ -161,6 +154,13 @@ export default { } }, }, + suggestionsLoading: { + handler(loading) { + if (loading) { + this.hasFetched = true; + } + }, + }, }, methods: { handleInput: debounce(function debouncedSearch({ data, operator }) { @@ -216,7 +216,7 @@ export default { <template #view="viewTokenProps"> <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> </template> - <template v-if="showSuggestions" #suggestions> + <template v-if="suggestionsEnabled" #suggestions> <template v-if="showDefaultSuggestions"> <gl-filtered-search-suggestion v-for="token in availableDefaultSuggestions" @@ -238,12 +238,13 @@ export default { :suggestions="preloadedSuggestions" ></slot> <gl-loading-icon v-if="suggestionsLoading" size="sm" /> + <template v-else-if="showAvailableSuggestions"> + <slot name="suggestions-list" :suggestions="availableSuggestions"></slot> + </template> <gl-dropdown-text v-else-if="showNoMatchesText"> {{ __('No matches found') }} </gl-dropdown-text> - <template v-else> - <slot name="suggestions-list" :suggestions="availableSuggestions"></slot> - </template> + <gl-dropdown-text v-else-if="hasFetched">{{ __('No suggestions found') }}</gl-dropdown-text> </template> </gl-filtered-search-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index cbf38984e23..e1020ce656b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -48,6 +48,11 @@ export default { required: false, default: '', }, + enablePreview: { + type: Boolean, + required: false, + default: true, + }, addSpacingClasses: { type: Boolean, required: false, @@ -113,6 +118,7 @@ export default { markdownPreviewLoading: false, previewMarkdown: false, suggestions: this.note.suggestions || [], + debouncedFetchMarkdownLoading: false, }; }, computed: { @@ -198,12 +204,22 @@ export default { const justRemovedAll = hadAll && !hasAll; if (justAddedAll) { + this.debouncedFetchMarkdownLoading = false; this.debouncedFetchMarkdown(); } else if (justRemovedAll) { + this.debouncedFetchMarkdownLoading = true; this.referencedUsers = []; } }, }, + enablePreview: { + immediate: true, + handler(newVal) { + if (!newVal) { + this.showWriteTab(); + } + }, + }, }, mounted() { // GLForm class handles all the toolbar buttons @@ -271,7 +287,12 @@ export default { }, debouncedFetchMarkdown: debounce(function debouncedFetchMarkdown() { - return this.fetchMarkdown(); + return this.fetchMarkdown().then(() => { + if (this.debouncedFetchMarkdownLoading) { + this.referencedUsers = []; + this.debouncedFetchMarkdownLoading = false; + } + }); }, 400), renderMarkdown(data = {}) { @@ -301,6 +322,7 @@ export default { :preview-markdown="previewMarkdown" :line-content="lineContent" :can-suggest="canSuggest" + :enable-preview="enablePreview" :show-suggest-popover="showSuggestPopover" :suggestion-start-index="suggestionsStartIndex" data-testid="markdownHeader" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 3b99afa9e3d..13189670e17 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,7 +1,13 @@ <script> import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui'; import $ from 'jquery'; -import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings'; +import { + keysFor, + BOLD_TEXT, + ITALIC_TEXT, + STRIKETHROUGH_TEXT, + LINK_TEXT, +} from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm'; @@ -43,6 +49,11 @@ export default { required: false, default: 0, }, + enablePreview: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -144,6 +155,7 @@ export default { shortcuts: { bold: keysFor(BOLD_TEXT), italic: keysFor(ITALIC_TEXT), + strikethrough: keysFor(STRIKETHROUGH_TEXT), link: keysFor(LINK_TEXT), }, i18n: { @@ -164,6 +176,7 @@ export default { @click="writeMarkdownTab($event)" /> <gl-tab + v-if="enablePreview" title-link-class="gl-pt-3 gl-px-3 js-md-preview-button" :title="$options.i18n.previewTabTitle" :active="previewMarkdown" @@ -194,6 +207,16 @@ export default { icon="italic" /> <toolbar-button + tag="~~" + :button-title=" + sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), { + modifierKey, + }) + " + :shortcuts="$options.shortcuts.strikethrough" + icon="strikethrough" + /> + <toolbar-button :prepend="true" :tag="tag" :button-title="__('Insert a quote')" diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index 0b302f22062..7a7074da084 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -1,12 +1,7 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; -import { escape } from 'lodash'; +import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -function buildDocsLinkStart(path) { - return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`; -} - const NoteableTypeText = { Issue: __('issue'), Epic: __('epic'), @@ -17,6 +12,7 @@ export default { components: { GlIcon, GlLink, + GlSprintf, }, props: { isLocked: { @@ -59,20 +55,6 @@ export default { noteableTypeText() { return NoteableTypeText[this.noteableType]; }, - confidentialAndLockedDiscussionText() { - return sprintf( - __( - 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', - ), - { - noteableTypeText: this.noteableTypeText, - confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath), - lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath), - linkEnd: '</a>', - }, - false, - ); - }, confidentialContextText() { return sprintf(__('This is a confidential %{noteableTypeText}.'), { noteableTypeText: this.noteableTypeText, @@ -91,9 +73,23 @@ export default { <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> - <span - v-html="confidentialAndLockedDiscussionText /* eslint-disable-line vue/no-v-html */" - ></span> + <span> + <gl-sprintf + :message=" + __( + 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and %{lockedLinkStart}locked%{lockedLinkEnd}.', + ) + " + > + <template #noteableTypeText>{{ noteableTypeText }}</template> + <template #confidentialLink="{ content }"> + <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ content }}</gl-link> + </template> + <template #lockedLink="{ content }"> + <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> {{ __("People without permission will never get a notification and won't be able to comment.") }} diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js index 46361c6eb32..88c975b97b9 100644 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js @@ -1,7 +1,5 @@ import { s__, sprintf } from '~/locale'; -export const EXPERIMENT_NAME = 'ci_runner_templates'; - export const README_URL = 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; @@ -16,7 +14,11 @@ export const EASY_BUTTONS = [ templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml', description: s__( - 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.', + 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.', + ), + moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'), + moreDetails2: s__( + 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.', ), }, { @@ -28,12 +30,20 @@ export const EASY_BUTTONS = [ ), { percentage: '100%' }, ), + moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }), + moreDetails2: s__( + 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', + ), }, { stackName: 'win2019-shell-non-spot', templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml', description: s__( - 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.', + 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.', + ), + moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'), + moreDetails2: s__( + 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', ), }, { @@ -45,5 +55,9 @@ export const EASY_BUTTONS = [ ), { percentage: '100%' }, ), + moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }), + moreDetails2: s__( + 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', + ), }, ]; diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue index 57cc25caa25..eee65d90285 100644 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue @@ -1,35 +1,44 @@ <script> -import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; -import awsCloudFormationImageUrl from 'images/aws-cloud-formation.png'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility'; -import { __, s__ } from '~/locale'; import { - EXPERIMENT_NAME, - README_URL, - CF_BASE_URL, - TEMPLATES_BASE_URL, - EASY_BUTTONS, -} from './constants'; + GlModal, + GlSprintf, + GlLink, + GlFormRadioGroup, + GlFormRadio, + GlAccordion, + GlAccordionItem, +} from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import { README_URL, CF_BASE_URL, TEMPLATES_BASE_URL, EASY_BUTTONS } from './constants'; export default { components: { GlModal, GlSprintf, GlLink, + GlFormRadioGroup, + GlFormRadio, + GlAccordion, + GlAccordionItem, }, + mixins: [Tracking.mixin()], props: { modalId: { type: String, required: true, }, - imgSrc: { - type: String, - required: false, - default: awsCloudFormationImageUrl, - }, + }, + data() { + return { + selected: this.$options.easyButtons[0], + }; }, methods: { + borderBottom(idx) { + return idx < this.$options.easyButtons.length - 1; + }, easyButtonUrl(easyButton) { const params = { templateURL: TEMPLATES_BASE_URL + easyButton.templateName, @@ -39,21 +48,30 @@ export default { return CF_BASE_URL + objectToQuery(params); }, trackCiRunnerTemplatesClick(stackName) { - const tracking = new ExperimentTracking(EXPERIMENT_NAME); - tracking.event(`template_clicked_${stackName}`); + this.track('template_clicked', { + label: stackName, + }); + }, + handleModalPrimary() { + this.trackCiRunnerTemplatesClick(this.selected.stackName); + visitUrl(this.easyButtonUrl(this.selected), true); }, }, i18n: { title: s__('Runners|Deploy GitLab Runner in AWS'), instructions: s__( - 'Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.', + 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.', ), - dont_see_what_you_are_looking_for: s__( - "Rnners|Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}.", - ), - note: s__( - 'Runners|If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.', + chooseRunner: s__('Runners|Choose your preferred GitLab Runner'), + dontSeeWhatYouAreLookingFor: s__( + "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.", ), + moreDetails: __('More Details'), + lessDetails: __('Less Details'), + }, + deployButton: { + text: s__('Runners|Deploy GitLab Runner in AWS'), + attributes: [{ variant: 'confirm' }], }, closeButton: { text: __('Cancel'), @@ -67,37 +85,41 @@ export default { <gl-modal :modal-id="modalId" :title="$options.i18n.title" + :action-primary="$options.deployButton" :action-secondary="$options.closeButton" size="sm" + @primary="handleModalPrimary" > <p>{{ $options.i18n.instructions }}</p> - <ul class="gl-list-style-none gl-p-0 gl-mb-0"> - <li v-for="easyButton in $options.easyButtons" :key="easyButton.templateName"> - <gl-link - :href="easyButtonUrl(easyButton)" - target="_blank" - class="gl-display-flex gl-font-weight-bold" - @click="trackCiRunnerTemplatesClick(easyButton.stackName)" - > - <img - :title="easyButton.stackName" - :alt="easyButton.stackName" - :src="imgSrc" - width="46" - height="46" - class="gl-mt-2 gl-mr-5 gl-mb-6" - /> + <gl-form-radio-group v-model="selected" :label="$options.i18n.chooseRunner" label-sr-only> + <gl-form-radio + v-for="(easyButton, idx) in $options.easyButtons" + :key="easyButton.templateName" + :value="easyButton" + class="gl-py-5 gl-pl-8" + :class="{ 'gl-border-b': borderBottom(idx) }" + > + <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"> {{ easyButton.description }} - </gl-link> - </li> - </ul> + <gl-accordion :header-level="3" class="gl-pt-3"> + <gl-accordion-item + :title="$options.i18n.moreDetails" + :title-visible="$options.i18n.lessDetails" + class="gl-font-weight-normal" + > + <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p> + <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p> + </gl-accordion-item> + </gl-accordion> + </div> + </gl-form-radio> + </gl-form-radio-group> <p> - <gl-sprintf :message="$options.i18n.dont_see_what_you_are_looking_for"> + <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor"> <template #link="{ content }"> <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </p> - <p class="gl-font-sm gl-mb-0">{{ $options.i18n.note }}</p> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 81e19e48d75..7127940bb05 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { nodes { ...User ...UserAvailability + mergeRequestInteraction { + canMerge + } } } + userPermissions { + canMerge + } } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql index 77140ea36d8..5fec2ccbdfb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql @@ -2,21 +2,18 @@ #import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { - mergeRequestSetAssignees( + issuableSetAssignees: mergeRequestSetAssignees( input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } ) { - mergeRequest { + issuable: mergeRequest { id assignees { nodes { ...User ...UserAvailability - } - } - participants { - nodes { - ...User - ...UserAvailability + mergeRequestInteraction { + canMerge + } } } } diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index 011cad4267c..6a0bf07c8b4 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -46,6 +46,11 @@ export default { required: false, default: () => ({}), }, + debounceValue: { + type: Number, + required: false, + default: CONTENT_UPDATE_DEBOUNCE, + }, }, data() { return { @@ -73,9 +78,7 @@ export default { ...this.editorOptions, }); - this.editor.onDidChangeModelContent( - debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE), - ); + this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue)); }, beforeDestroy() { this.editor.dispose(); 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 5aae1812de3..4a78cbacec0 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 @@ -35,16 +35,20 @@ export default { }, highlightedContent() { let highlightedContent; + let { language } = this; if (this.hljs) { - if (!this.language) { - highlightedContent = this.hljs.highlightAuto(this.content).value; + if (!language) { + const hljsHighlightAuto = this.hljs.highlightAuto(this.content); + + highlightedContent = hljsHighlightAuto.value; + language = hljsHighlightAuto.language; } else if (this.languageDefinition) { highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; } } - return wrapLines(highlightedContent); + return wrapLines(highlightedContent, language); }, }, watch: { @@ -110,7 +114,7 @@ export default { data-qa-selector="blob_viewer_file_content" > <line-numbers :lines="lineNumbers" /> - <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code> + <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code> </pre> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js index e64e564bf61..d726a8a55ff 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js @@ -1,11 +1,13 @@ -export const wrapLines = (content) => { +export const wrapLines = (content, language) => { + const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_). + return ( content && content .split('\n') .map((line, i) => { let formattedLine; - const idAttribute = `id="LC${i + 1}"`; + const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`; if (line.includes('<span class="hljs') && !line.includes('</span>')) { /** @@ -14,9 +16,9 @@ export const wrapLines = (content) => { * example (before): <span class="hljs-code">```bash * example (after): <span id="LC67" class="hljs-code">```bash */ - formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `); + formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `); } else { - formattedLine = `<span ${idAttribute} class="line">${line}</span>`; + formattedLine = `<span ${attributes} class="line">${line}</span>`; } return formattedLine; diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index efb99eb0d94..d07f65cf5c1 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -1,30 +1,33 @@ <script> /* This is a re-usable vue component for rendering a user avatar that - does not need to link to the user's profile. The image and an optional - tooltip can be configured by props passed to this component. + does not need to link to the user's profile. The image and an optional + tooltip can be configured by props passed to this component. - Sample configuration: + Sample configuration: - <user-avatar-image - :lazy="true" - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :tooltip-text="tooltipText" - tooltip-placement="top" - /> + <user-avatar-image + lazy + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> -*/ + */ -import { GlTooltip } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __ } from '~/locale'; -import { placeholderImage } from '../../../lazy_loader'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import UserAvatarImageNew from './user_avatar_image_new.vue'; +import UserAvatarImageOld from './user_avatar_image_old.vue'; export default { name: 'UserAvatarImage', components: { - GlTooltip, + UserAvatarImageNew, + UserAvatarImageOld, }, + mixins: [glFeatureFlagMixin()], props: { lazy: { type: Boolean, @@ -62,51 +65,14 @@ export default { default: 'top', }, }, - computed: { - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside user avatar link. - // In both cases we should render the defaultAvatarUrl - sanitizedSource() { - let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - // Only adds the width to the URL if its not a base64 data image - if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) - baseSrc += `?width=${this.size}`; - return baseSrc; - }, - resultantSrcAttribute() { - return this.lazy ? placeholderImage : this.sanitizedSource; - }, - avatarSizeClass() { - return `s${this.size}`; - }, - }, }; </script> <template> - <span> - <img - ref="userAvatarImage" - :class="{ - lazy: lazy, - [avatarSizeClass]: true, - [cssClasses]: true, - }" - :src="resultantSrcAttribute" - :width="size" - :height="size" - :alt="imgAlt" - :data-src="sanitizedSource" - class="avatar" - /> - <gl-tooltip - v-if="tooltipText || $slots.default" - :target="() => $refs.userAvatarImage" - :placement="tooltipPlacement" - boundary="window" - class="js-user-avatar-image-tooltip" - > - <slot> {{ tooltipText }} </slot> - </gl-tooltip> - </span> + <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props"> + <slot></slot> + </user-avatar-image-new> + <user-avatar-image-old v-else v-bind="$props"> + <slot></slot> + </user-avatar-image-old> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue new file mode 100644 index 00000000000..f52a3471ea4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue @@ -0,0 +1,106 @@ +<script> +/* This is a re-usable vue component for rendering a user avatar that + does not need to link to the user's profile. The image and an optional + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar + lazy + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + + */ + +import { GlTooltip, GlAvatar } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; +import { __ } from '~/locale'; +import { placeholderImage } from '../../../lazy_loader'; + +export default { + name: 'UserAvatarImageNew', + components: { + GlTooltip, + GlAvatar, + }, + props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, + imgSrc: { + type: String, + required: false, + default: defaultAvatarUrl, + }, + cssClasses: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: __('user avatar'), + }, + size: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, + computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + // Only adds the width to the URL if its not a base64 data image + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.size}`; + return baseSrc; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; + }, + }, +}; +</script> + +<template> + <span> + <gl-avatar + ref="userAvatar" + :class="{ + lazy: lazy, + [cssClasses]: true, + }" + :src="resultantSrcAttribute" + :data-src="sanitizedSource" + :size="size" + :alt="imgAlt" + /> + + <gl-tooltip + :target="() => $refs.userAvatar.$el" + :placement="tooltipPlacement" + boundary="window" + > + <slot> {{ tooltipText }}</slot> + </gl-tooltip> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue new file mode 100644 index 00000000000..bca10c76038 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue @@ -0,0 +1,110 @@ +<script> +/* This is a re-usable vue component for rendering a user avatar that + does not need to link to the user's profile. The image and an optional + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-image + lazy + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + + */ + +import { GlTooltip } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; +import { __ } from '~/locale'; +import { placeholderImage } from '../../../lazy_loader'; + +export default { + name: 'UserAvatarImageOld', + components: { + GlTooltip, + }, + props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, + imgSrc: { + type: String, + required: false, + default: defaultAvatarUrl, + }, + cssClasses: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: __('user avatar'), + }, + size: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, + computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + // Only adds the width to the URL if its not a base64 data image + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.size}`; + return baseSrc; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; + }, + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <span> + <img + ref="userAvatarImage" + :class="{ + lazy: lazy, + [avatarSizeClass]: true, + [cssClasses]: true, + }" + :src="resultantSrcAttribute" + :width="size" + :height="size" + :alt="imgAlt" + :data-src="sanitizedSource" + class="avatar" + /> + <gl-tooltip + :target="() => $refs.userAvatarImage" + :placement="tooltipPlacement" + boundary="window" + > + <slot> {{ tooltipText }}</slot> + </gl-tooltip> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 04423aac651..887deff17c9 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -17,18 +17,17 @@ */ -import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import userAvatarImage from './user_avatar_image.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import UserAvatarLinkNew from './user_avatar_link_new.vue'; +import UserAvatarLinkOld from './user_avatar_link_old.vue'; export default { name: 'UserAvatarLink', components: { - GlLink, - userAvatarImage, - }, - directives: { - GlTooltip: GlTooltipDirective, + UserAvatarLinkNew, + UserAvatarLinkOld, }, + mixins: [glFeatureFlagMixin()], props: { lazy: { type: Boolean, @@ -76,36 +75,21 @@ export default { default: '', }, }, - computed: { - shouldShowUsername() { - return this.username.length > 0; - }, - avatarTooltipText() { - return this.shouldShowUsername ? '' : this.tooltipText; - }, - }, }; </script> <template> - <gl-link :href="linkHref" class="user-avatar-link"> - <user-avatar-image - :img-src="imgSrc" - :img-alt="imgAlt" - :css-classes="imgCssClasses" - :size="imgSize" - :tooltip-text="avatarTooltipText" - :tooltip-placement="tooltipPlacement" - :lazy="lazy" - > - <slot></slot> </user-avatar-image - ><span - v-if="shouldShowUsername" - v-gl-tooltip - :title="tooltipText" - :tooltip-placement="tooltipPlacement" - class="js-user-avatar-link-username" - >{{ username }}</span - ><slot name="avatar-badge"></slot> - </gl-link> + <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props"> + <slot></slot> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> + </user-avatar-link-new> + + <user-avatar-link-old v-else v-bind="$props"> + <slot></slot> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> + </user-avatar-link-old> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue new file mode 100644 index 00000000000..3b459569274 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue @@ -0,0 +1,117 @@ +<script> +/* This is a re-usable vue component for rendering a user avatar wrapped in + a clickable link (likely to the user's profile). The link, image, and + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-link + :link-href="userProfileUrl" + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :img-size="20" + :tooltip-text="tooltipText" + :tooltip-placement="top" + :username="username" + /> + +*/ + +import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; +import UserAvatarImage from './user_avatar_image.vue'; + +export default { + name: 'UserAvatarLinkNew', + components: { + UserAvatarImage, + GlAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, + linkHref: { + type: String, + required: false, + default: '', + }, + imgSrc: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: '', + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + imgSize: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + username: { + type: String, + required: false, + default: '', + }, + }, + computed: { + shouldShowUsername() { + return this.username.length > 0; + }, + avatarTooltipText() { + return this.shouldShowUsername ? '' : this.tooltipText; + }, + }, +}; +</script> + +<template> + <gl-avatar-link :href="linkHref" class="user-avatar-link"> + <user-avatar-image + :img-src="imgSrc" + :img-alt="imgAlt" + :css-classes="imgCssClasses" + :size="imgSize" + :tooltip-text="avatarTooltipText" + :tooltip-placement="tooltipPlacement" + :lazy="lazy" + > + <slot></slot> + </user-avatar-image> + + <span + v-if="shouldShowUsername" + v-gl-tooltip + :title="tooltipText" + :tooltip-placement="tooltipPlacement" + class="gl-ml-3" + data-testid="user-avatar-link-username" + > + {{ username }} + </span> + + <slot name="avatar-badge"></slot> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue new file mode 100644 index 00000000000..c2e46e61e1b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue @@ -0,0 +1,117 @@ +<script> +/* This is a re-usable vue component for rendering a user avatar wrapped in + a clickable link (likely to the user's profile). The link, image, and + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-link + :link-href="userProfileUrl" + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :img-size="20" + :tooltip-text="tooltipText" + :tooltip-placement="top" + :username="username" + /> + +*/ + +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import UserAvatarImage from './user_avatar_image.vue'; + +export default { + name: 'UserAvatarLinkOld', + components: { + GlLink, + UserAvatarImage, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, + linkHref: { + type: String, + required: false, + default: '', + }, + imgSrc: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: '', + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + imgSize: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + username: { + type: String, + required: false, + default: '', + }, + }, + computed: { + shouldShowUsername() { + return this.username.length > 0; + }, + avatarTooltipText() { + return this.shouldShowUsername ? '' : this.tooltipText; + }, + }, +}; +</script> + +<template> + <span> + <gl-link :href="linkHref" class="user-avatar-link"> + <user-avatar-image + :img-src="imgSrc" + :img-alt="imgAlt" + :css-classes="imgCssClasses" + :size="imgSize" + :tooltip-text="avatarTooltipText" + :tooltip-placement="tooltipPlacement" + :lazy="lazy" + > + <slot></slot> + </user-avatar-image> + + <span + v-if="shouldShowUsername" + v-gl-tooltip + :title="tooltipText" + :tooltip-placement="tooltipPlacement" + data-testid="user-avatar-link-username" + > + {{ username }} + </span> + <slot name="avatar-badge"></slot> + </gl-link> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 05e0c3b0be3..41507ca94e2 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -116,7 +116,7 @@ export default { <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status"> <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> - <div v-if="user.bot" class="gl-text-blue-500"> + <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500"> <gl-icon name="question" /> <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> <gl-sprintf :message="__('Learn more about %{username}')"> 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 b85cae0c64f..9df5254155e 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 @@ -1,4 +1,5 @@ <script> +import { debounce } from 'lodash'; import { GlDropdown, GlDropdownForm, @@ -6,11 +7,14 @@ import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, + GlTooltipDirective, } from '@gitlab/ui'; -import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants'; +import { IssuableType } from '~/issues/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; export default { i18n: { @@ -25,6 +29,9 @@ export default { SidebarParticipant, GlLoadingIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { headerText: { type: String, @@ -58,13 +65,18 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: IssuableType.Issue, }, isEditing: { type: Boolean, required: false, default: true, }, + issuableId: { + type: Number, + required: false, + default: null, + }, }, data() { return { @@ -89,28 +101,35 @@ export default { }; }, update(data) { - return data.workspace?.issuable?.participants.nodes; + return data.workspace?.issuable?.participants.nodes.map((node) => ({ + ...node, + canMerge: false, + })); }, error() { this.$emit('error'); }, }, searchUsers: { - query: searchUsers, + query() { + return userSearchQueries[this.issuableType].query; + }, variables() { - return { - fullPath: this.fullPath, - search: this.search, - first: 20, - }; + return this.searchUsersVariables; }, skip() { return !this.isEditing; }, update(data) { - return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; + return ( + data.workspace?.users?.nodes + .filter((x) => x?.user) + .map((node) => ({ + ...node.user, + canMerge: node.mergeRequestInteraction?.canMerge || false, + })) || [] + ); }, - debounce: ASSIGNEES_DEBOUNCE_DELAY, error() { this.$emit('error'); this.isSearching = false; @@ -121,6 +140,23 @@ export default { }, }, computed: { + isMergeRequest() { + return this.issuableType === IssuableType.MergeRequest; + }, + searchUsersVariables() { + const variables = { + fullPath: this.fullPath, + search: this.search, + first: 20, + }; + if (!this.isMergeRequest) { + return variables; + } + return { + ...variables, + mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId), + }; + }, isLoading() { return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; }, @@ -135,8 +171,8 @@ export default { // TODO this de-duplication is temporary (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - const mergedSearchResults = filteredParticipants - .concat(this.searchUsers) + const mergedSearchResults = this.searchUsers + .concat(filteredParticipants) .reduce( (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]), [], @@ -179,6 +215,7 @@ export default { return this.selectedFiltered.length === 0; }, }, + watch: { // We need to add this watcher to track the moment when user is alredy typing // but query is still not started due to debounce @@ -188,15 +225,21 @@ export default { } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, methods: { selectAssignee(user) { let selected = [...this.value]; if (!this.allowMultipleAssignees) { selected = [user]; + this.$emit('input', selected); + this.$refs.dropdown.hide(); + this.$emit('toggle'); } else { selected.push(user); + this.$emit('input', selected); } - this.$emit('input', selected); }, unselect(name) { const selected = this.value.filter((user) => user.username !== name); @@ -205,6 +248,9 @@ export default { focusSearch() { this.$refs.search.focusInput(); }, + showDropdown() { + this.$refs.dropdown.show(); + }, showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, @@ -216,22 +262,37 @@ export default { const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); if (currentUser) { + currentUser.canMerge = this.currentUser.canMerge; const index = usersCopy.indexOf(currentUser); usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); } return usersCopy; }, + setSearchKey(value) { + this.search = value.trim(); + }, + tooltipText(user) { + if (!this.isMergeRequest) { + return ''; + } + return user.canMerge ? '' : __('Cannot merge'); + }, }, }; </script> <template> - <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> + <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch"> <template #header> <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> <gl-dropdown-divider /> - <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" /> + <gl-search-box-by-type + ref="search" + :value="search" + class="js-dropdown-input-field" + @input="debouncedSearchKeyUpdate" + /> </template> <gl-dropdown-form class="gl-relative gl-min-h-7"> <gl-loading-icon @@ -247,7 +308,7 @@ export default { :is-checked="selectedIsEmpty" :is-check-centered="true" data-testid="unassign" - @click="$emit('input', [])" + @click.native.capture.stop="$emit('input', [])" > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned @@ -258,27 +319,44 @@ export default { <gl-dropdown-item v-for="item in selectedFiltered" :key="item.id" + v-gl-tooltip.left.viewport + :title="tooltipText(item)" + boundary="viewport" is-checked is-check-centered data-testid="selected-participant" - @click.stop="unselect(item.username)" + @click.native.capture.stop="unselect(item.username)" > - <sidebar-participant :user="item" /> + <sidebar-participant :user="item" :issuable-type="issuableType" /> </gl-dropdown-item> <template v-if="showCurrentUser"> <gl-dropdown-divider /> - <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)"> - <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + <gl-dropdown-item + data-testid="current-user" + @click.native.capture.stop="selectAssignee(currentUser)" + > + <sidebar-participant + :user="currentUser" + :issuable-type="issuableType" + class="gl-pl-6!" + /> </gl-dropdown-item> </template> <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> <gl-dropdown-item v-for="unselectedUser in unselectedFiltered" :key="unselectedUser.id" + v-gl-tooltip.left.viewport + :title="tooltipText(unselectedUser)" + boundary="viewport" data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" + @click.native.capture.stop="selectAssignee(unselectedUser)" > - <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + <sidebar-participant + :user="unselectedUser" + :issuable-type="issuableType" + class="gl-pl-6!" + /> </gl-dropdown-item> <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!"> {{ __('No matching results') }} 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 82022d1f4d6..199516b3eb3 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -8,6 +8,7 @@ import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; const KEY_EDIT = 'edit'; const KEY_WEB_IDE = 'webide'; const KEY_GITPOD = 'gitpod'; +const KEY_PIPELINE_EDITOR = 'pipeline_editor'; export default { components: { @@ -64,6 +65,11 @@ export default { required: false, default: false, }, + showPipelineEditorButton: { + type: Boolean, + required: false, + default: false, + }, userPreferencesGitpodPath: { type: String, required: false, @@ -79,6 +85,11 @@ export default { required: false, default: '', }, + pipelineEditorUrl: { + type: String, + required: false, + default: '', + }, webIdeUrl: { type: String, required: false, @@ -117,14 +128,19 @@ export default { }, data() { return { - selection: KEY_WEB_IDE, + selection: this.showPipelineEditorButton ? KEY_PIPELINE_EDITOR : KEY_WEB_IDE, showEnableGitpodModal: false, showForkModal: false, }; }, computed: { actions() { - return [this.webIdeAction, this.editAction, this.gitpodAction].filter((action) => action); + return [ + this.pipelineEditorAction, + this.webIdeAction, + this.editAction, + this.gitpodAction, + ].filter((action) => action); }, editAction() { if (!this.showEditButton) { @@ -162,7 +178,7 @@ export default { if (this.webIdeText) { return this.webIdeText; } else if (this.isBlob) { - return __('Edit in Web IDE'); + return __('Open in Web IDE'); } else if (this.isFork) { return __('Edit fork in Web IDE'); } @@ -202,6 +218,9 @@ export default { }; }, gitpodActionText() { + if (this.isBlob) { + return __('Open in Gitpod'); + } return this.gitpodText || __('Gitpod'); }, computedShowGitpodButton() { @@ -209,11 +228,28 @@ export default { this.showGitpodButton && this.userPreferencesGitpodPath && this.userProfileEnableGitpodPath ); }, + pipelineEditorAction() { + if (!this.showPipelineEditorButton) { + return null; + } + + const secondaryText = __('Edit, lint, and visualize your pipeline.'); + + return { + key: KEY_PIPELINE_EDITOR, + text: __('Edit in pipeline editor'), + secondaryText, + tooltip: secondaryText, + attrs: { + 'data-qa-selector': 'pipeline_editor_button', + }, + href: this.pipelineEditorUrl, + }; + }, gitpodAction() { if (!this.computedShowGitpodButton) { return null; } - const handleOptions = this.gitpodEnabled ? { href: this.gitpodUrl } : { diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index b96ce0c43f7..45941174a62 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -58,7 +58,12 @@ export default { <template> <div> <div class="title-container"> - <h2 v-safe-html="issuable.titleHtml || issuable.title" class="title qa-title" dir="auto"></h2> + <h1 + v-safe-html="issuable.titleHtml || issuable.title" + class="title qa-title" + dir="auto" + data-testid="title" + ></h1> <gl-button v-if="enableEdit" v-gl-tooltip.bottom diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js index 55ac2f0be6a..af671e72129 100644 --- a/app/assets/javascripts/webpack_non_compiled_placeholder.js +++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js @@ -1,3 +1,4 @@ +/* globals LIVE_RELOAD */ const div = document.createElement('div'); Object.assign(div.style, { @@ -15,6 +16,10 @@ Object.assign(div.style, { 'text-align': 'center', }); +const reloadMessage = LIVE_RELOAD + ? 'You have live_reload enabled, the page will reload automatically when complete.' + : 'You have live_reload disabled, the page will reload automatically in a few seconds.'; + div.innerHTML = ` <!-- https://github.com/webpack/media/blob/master/logo/icon-square-big.svg --> <svg height="50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200"> @@ -30,9 +35,15 @@ div.innerHTML = ` Learn more <a href="https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/configuration.md#webpack-settings">here</a>. </p> <p> - If you have live_reload enabled, the page will reload automatically when complete.<br /> - Otherwise, please <a href="">reload the page manually in a few seconds</a> + ${reloadMessage}<br /> + If it doesn't, please <a href="">reload the page manually</a>. </p> `; document.body.append(div); + +if (!LIVE_RELOAD) { + setTimeout(() => { + window.location.reload(); + }, 5000); +} 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 new file mode 100644 index 00000000000..942677bb937 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -0,0 +1,62 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import workItemQuery from '../graphql/work_item.query.graphql'; +import ItemTitle from './item_title.vue'; + +export default { + components: { + GlModal, + ItemTitle, + }, + props: { + visible: { + type: Boolean, + required: true, + }, + workItemId: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + workItem: {}, + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + update(data) { + return data.workItem; + }, + skip() { + return !this.workItemId; + }, + error() { + this.$emit( + 'error', + s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), + ); + }, + }, + }, + computed: { + workItemTitle() { + return this.workItem?.title; + }, + }, +}; +</script> + +<template> + <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')"> + <item-title class="gl-m-0!" :initial-title="workItemTitle" /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql index 2f302dae7d7..9312d1c582b 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -1,16 +1,16 @@ #import './widget.fragment.graphql' -mutation createWorkItem($input: LocalCreateWorkItemInput) { - localCreateWorkItem(input: $input) @client { +mutation createWorkItem($input: WorkItemCreateInput!) { + workItemCreate(input: $input) { workItem { id - type - widgets { + title + workItemType { + id + } + widgets @client { nodes { ...WidgetBase - ... on LocalTitleWidget { - contentText - } } } } diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 676fffb12d8..28328a840cf 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -10,29 +10,28 @@ export function createApolloProvider() { const defaultClient = createDefaultClient(resolvers, { typeDefs, + cacheConfig: { + possibleTypes: { + LocalWorkItemWidget: ['LocalTitleWidget'], + }, + }, }); defaultClient.cache.writeQuery({ query: workItemQuery, variables: { - id: '1', + id: 'gid://gitlab/WorkItem/1', }, data: { - workItem: { + localWorkItem: { __typename: 'LocalWorkItem', - id: '1', + id: 'gid://gitlab/WorkItem/1', type: 'FEATURE', + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Test Work Item', widgets: { __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - enabled: true, - // eslint-disable-next-line @gitlab/require-i18n-strings - contentText: 'Test Work Item Title', - }, - ], + nodes: [], }, }, }, diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js index 63d5234d083..fb74e27f840 100644 --- a/app/assets/javascripts/work_items/graphql/resolvers.js +++ b/app/assets/javascripts/work_items/graphql/resolvers.js @@ -1,53 +1,24 @@ -import { uuids } from '~/lib/utils/uuids'; import workItemQuery from './work_item.query.graphql'; export const resolvers = { Mutation: { - localCreateWorkItem(_, { input }, { cache }) { - const id = uuids()[0]; - const workItem = { - __typename: 'LocalWorkItem', - type: 'FEATURE', - id, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - enabled: true, - contentText: input.title, - }, - ], - }, - }; - - cache.writeQuery({ query: workItemQuery, variables: { id }, data: { workItem } }); - - return { - __typename: 'LocalCreateWorkItemPayload', - workItem, - }; - }, - localUpdateWorkItem(_, { input }, { cache }) { - const workItemTitle = { - __typename: 'LocalTitleWidget', - type: 'TITLE', - enabled: true, - contentText: input.title, - }; const workItem = { __typename: 'LocalWorkItem', type: 'FEATURE', id: input.id, + title: input.title, widgets: { __typename: 'LocalWorkItemWidgetConnection', - nodes: [workItemTitle], + nodes: [], }, }; - cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } }); + cache.writeQuery({ + query: workItemQuery, + variables: { id: input.id }, + data: { localWorkItem: workItem }, + }); return { __typename: 'LocalUpdateWorkItemPayload', diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 177eea00322..9b4811203f5 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -22,14 +22,10 @@ type LocalWorkItemWidgetConnection { pageInfo: PageInfo! } -type LocalTitleWidget implements LocalWorkItemWidget { - type: LocalWidgetType! - contentText: String! -} - type LocalWorkItem { id: ID! type: LocalWorkItemType! + title: String! widgets: [LocalWorkItemWidgetConnection] } @@ -51,7 +47,7 @@ type LocalUpdateWorkItemPayload { } extend type Query { - workItem(id: ID!): LocalWorkItem! + localWorkItem(id: ID!): LocalWorkItem! } extend type Mutation { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index f0563f099b2..efb1ed8d6df 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -1,16 +1,16 @@ #import './widget.fragment.graphql' -mutation updateWorkItem($input: LocalUpdateWorkItemInput) { - localUpdateWorkItem(input: $input) @client { +mutation workItemUpdate($input: WorkItemUpdateInput!) { + workItemUpdate(input: $input) { workItem { id - type - widgets { + title + workItemType { + id + } + widgets @client { nodes { ...WidgetBase - ... on LocalTitleWidget { - contentText - } } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 9f173f7c302..b32cb4f28fb 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,15 +1,15 @@ #import './widget.fragment.graphql' query WorkItem($id: ID!) { - workItem(id: $id) @client { + workItem(id: $id) { id - type - widgets { + title + workItemType { + id + } + widgets @client { nodes { ...WidgetBase - ... on LocalTitleWidget { - contentText - } } } } diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 6c3bcf8f6a8..cc90cedb110 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -1,6 +1,8 @@ <script> import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; @@ -67,21 +69,45 @@ export default { variables: { input: { title: this.title, + projectPath: this.fullPath, + workItemTypeId: this.selectedWorkItemType?.id, }, }, + update(store, { data: { workItemCreate } }) { + const { id, title, workItemType } = workItemCreate.workItem; + + store.writeQuery({ + query: workItemQuery, + variables: { + id, + }, + data: { + workItem: { + __typename: 'WorkItem', + id, + title, + workItemType, + widgets: { + __typename: 'LocalWorkItemWidgetConnection', + nodes: [], + }, + }, + }, + }); + }, }); const { data: { - localCreateWorkItem: { - workItem: { id }, + workItemCreate: { + workItem: { id, type }, }, }, } = response; if (!this.isModal) { - this.$router.push({ name: 'workItem', params: { id } }); + this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); } else { - this.$emit('onCreate', this.title); + this.$emit('onCreate', { id, title: this.title, type }); } } catch { this.error = s__( 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 4262e169655..32b6fc231a8 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,9 +1,10 @@ <script> -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; import workItemQuery from '../graphql/work_item.query.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { widgetTypes, WI_TITLE_TRACK_LABEL } from '../constants'; +import { WI_TITLE_TRACK_LABEL } from '../constants'; import ItemTitle from '../components/item_title.vue'; @@ -14,6 +15,7 @@ export default { components: { ItemTitle, GlAlert, + GlLoadingIcon, }, mixins: [trackingMixin], props: { @@ -24,7 +26,7 @@ export default { }, data() { return { - workItem: null, + workItem: {}, error: false, }; }, @@ -33,7 +35,7 @@ export default { query: workItemQuery, variables() { return { - id: this.id, + id: this.gid, }; }, }, @@ -47,19 +49,19 @@ export default { property: '[type_work_item]', }; }, - titleWidgetData() { - return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); + gid() { + return convertToGraphQLId('WorkItem', this.id); }, }, methods: { - async updateWorkItem(title) { + async updateWorkItem(updatedTitle) { try { await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { - id: this.id, - title, + id: this.gid, + title: updatedTitle, }, }, }); @@ -79,12 +81,18 @@ export default { }}</gl-alert> <!-- Title widget placeholder --> <div> - <item-title - v-if="titleWidgetData" - :initial-title="titleWidgetData.contentText" - data-testid="title" - @title-changed="updateWorkItem" + <gl-loading-icon + v-if="$apollo.queries.workItem.loading" + size="md" + data-testid="loading-types" /> + <template v-else> + <item-title + :initial-title="workItem.title" + data-testid="title" + @title-changed="updateWorkItem" + /> + </template> </div> </section> </template> diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 7b4f68e7a44..e06c71dccf0 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -360,27 +360,10 @@ } > li { - // TODO: Remove this block once all sidebar badges use gl_badge_tag - // https://gitlab.com/gitlab-org/gitlab/-/issues/350061 - .badge.badge-pill:not(.gl-badge) { - @include gl-rounded-lg; - @include gl-py-1; - @include gl-px-3; - background-color: $blue-100; - color: $blue-700; - } - &.active { .sidebar-sub-level-items:not(.is-fly-out-only) { display: block; } - - // TODO: Remove this block once all sidebar badges use gl_badge_tag - // https://gitlab.com/gitlab-org/gitlab/-/issues/350061 - .badge.badge-pill:not(.gl-badge) { - @include gl-font-weight-normal; - color: $blue-700; - } } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 9387500e66f..e378fcb6129 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -377,10 +377,6 @@ span.idiff { color: $gl-text-color; } - .file-actions .ide-edit-button { - z-index: 2; - } - @include media-breakpoint-down(md) { .file-actions { margin-top: $gl-padding-8; diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 122c605e603..a80643e695b 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -87,7 +87,7 @@ td.line-numbers { } .project-highlight-puc .unicode-bidi::before { - content: '�'; + content: '\FFFD'; cursor: pointer; text-decoration: underline wavy $red-500; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 1e51bf3d974..1caf02937d5 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -439,6 +439,12 @@ .na { color: inherit; } + + // Rouge `Comment` token (quoted text in email body) + .c { + color: $gl-grayish-blue; + font-style: italic; + } } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index feedc40b487..b1e44a81267 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -158,8 +158,8 @@ } hr { - // Darken 'whitesmoke' a bit to make it more visible in note bodies - border-color: darken($gray-normal, 8%); + border-color: rgba($black, 0.15); + margin: 10px 0; } diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss index feb4ea77e58..2d501781119 100644 --- a/app/assets/stylesheets/notify.scss +++ b/app/assets/stylesheets/notify.scss @@ -1,24 +1,4 @@ -@import 'framework/mixins'; -@import 'framework/variables'; - -img { - max-width: 100%; - height: auto; -} - -p.details { - font-style: italic; - color: $gray-500; -} - -.footer > p { - font-size: small; - color: $gray-500; -} - -pre.commit-message { - white-space: pre-wrap; -} +@import 'notify_base'; .gl-label-scoped { border: 2px solid currentColor; @@ -40,6 +20,11 @@ pre.commit-message { color: $gl-text-color; } +.gl-label-text-scoped { + padding: 0 5px; + color: $gl-text-color; +} + .content { .markdown-code-block pre.code { padding: $gl-padding-8 $input-horizontal-padding; @@ -47,6 +32,4 @@ pre.commit-message { border: 1px solid $gray-100; border-radius: $border-radius-small; } - - @include email-code-block; } diff --git a/app/assets/stylesheets/notify_base.scss b/app/assets/stylesheets/notify_base.scss new file mode 100644 index 00000000000..8c6f9a27077 --- /dev/null +++ b/app/assets/stylesheets/notify_base.scss @@ -0,0 +1,25 @@ +@import 'framework/mixins'; +@import 'framework/variables'; + +img { + max-width: 100%; + height: auto; +} + +p.details { + font-style: italic; + color: $gray-500; +} + +.footer > p { + font-size: small; + color: $gray-500; +} + +pre.commit-message { + white-space: pre-wrap; +} + +.content { + @include email-code-block; +} diff --git a/app/assets/stylesheets/notify_enhanced.scss b/app/assets/stylesheets/notify_enhanced.scss new file mode 100644 index 00000000000..5df5a8592bf --- /dev/null +++ b/app/assets/stylesheets/notify_enhanced.scss @@ -0,0 +1,68 @@ +// Import a subset of the GitLab UI framework: +// keep parts that affect elements that can appear in emails; +// omit Bootstrap Reboot since it adds unnecessary styles to every element. +@import 'notify_base'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/code'; +@import '@gitlab/ui/src/scss/variables'; +@import '@gitlab/ui/src/scss/utility-mixins/index'; +@import '@gitlab/ui/src/scss/components'; +@import 'bootstrap_migration'; +@import 'framework/common'; +@import 'framework/gfm'; +@import 'framework/kbd'; +@import 'framework/tables'; +@import 'framework/typography'; +@import 'framework/emojis'; + +body { + font-family: $regular-font; + font-size: inherit; +} + +a { + text-decoration: none; +} + +.content { + .md { + padding: 1rem 0; + } + + hr { + border: 1px solid #e1e1e1; + } + + blockquote { + border-top-width: 0; + border-bottom-width: 0; + border-right-width: 0; + + &:dir(rtl) { + border-left-width: 0; + border-right-width: inherit; + } + } + + table { + border-collapse: collapse; + } + + .diff-table.code, + table.code { + width: auto; + + td { + padding: inherit; + + pre { + background-color: inherit; + margin: 0; + padding: 0; + border: inherit; + } + } + } +} diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 63e951be698..34a3d936a67 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -745,6 +745,10 @@ $tabs-holder-z-index: 250; } } +.mr-section-container .resize-observer > object { + height: 0; +} + // TODO: Move to GitLab UI .mr-extenson-scrim { background: linear-gradient(to bottom, rgba($gray-light, 0), rgba($gray-light, 1)); @@ -753,3 +757,7 @@ $tabs-holder-z-index: 250; background: linear-gradient(to bottom, rgba(#333, 0), rgba(#333, 1)); } } + +.attention-request-sidebar-popover { + z-index: 999; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index fa07d29b536..c00af802c06 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -108,12 +108,15 @@ .merge-icon { color: $orange-400; position: absolute; - bottom: 0; - right: 0; filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white); } } +.assignee .merge-icon { + top: calc(50% + 0.25rem); + left: 1.275rem; +} + .reviewer .merge-icon { bottom: -3px; right: -3px; @@ -399,7 +402,7 @@ /* This change should be temporary, because the DOM currently gets generated from a ruby definition in `app/helpers/button_helper.rb`. - As soon as the `copy to clipboard` button will be transfered to + As soon as the `copy to clipboard` button will be transferred to Vue this should be adjusted as well. */ flex: 1; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index ac3d4dad585..8034389adc8 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -134,47 +134,6 @@ } } -.fork-thumbnail { - width: calc((100% / 2) - #{$gl-padding * 2}); - - @include media-breakpoint-up(md) { - width: calc((100% / 4) - #{$gl-padding * 2}); - } - - @include media-breakpoint-up(lg) { - width: calc((100% / 5) - #{$gl-padding * 2}); - } - - &:hover:not(.disabled), - &.forked { - background-color: $blue-50; - border-color: $blue-200; - } - - .avatar-container, - .identicon { - float: none; - margin-left: auto; - margin-right: auto; - } - - a.disabled { - opacity: 0.3; - cursor: not-allowed; - } -} - -.fork-thumbnail-container { - display: flex; - flex-wrap: wrap; - margin-left: -$gl-padding; - margin-right: -$gl-padding; - - > h5 { - width: 100%; - } -} - .project-template { > .form-group { margin-bottom: 0; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 4c31cc6e111..c84a83c1fab 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -252,7 +252,8 @@ input[type='checkbox']:hover { .btn-search, .btn-success, - .dropdown-menu-toggle { + .dropdown-menu-toggle, + .gl-new-dropdown { width: 100%; margin-top: 5px; @@ -270,7 +271,8 @@ input[type='checkbox']:hover { } } - .dropdown-menu-toggle { + .dropdown-menu-toggle, + .gl-new-dropdown { @include media-breakpoint-up(sm) { width: 180px; margin-top: 0; @@ -366,12 +368,13 @@ input[type='checkbox']:hover { } } -// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling -/* stylelint-disable property-no-vendor-prefix */ -input[type='search']::-webkit-search-decoration, -input[type='search']::-webkit-search-cancel-button, -input[type='search']::-webkit-search-results-button, -input[type='search']::-webkit-search-results-decoration { - -webkit-appearance: none; +// Disable Webkit's search input styles +input[type='search'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-appearance: textfield; + + &::-webkit-search-cancel-button, + &::-webkit-search-results-button { + @include gl-display-none; + } } -/* stylelint-enable */ diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 1397590cc31..00195f553dc 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -993,19 +993,6 @@ input { .top-nav-toggle .dropdown-icon { margin-right: 0.5rem; } -.tanuki-logo .tanuki-left-ear, -.tanuki-logo .tanuki-right-ear, -.tanuki-logo .tanuki-nose { - fill: #e24329; -} -.tanuki-logo .tanuki-left-eye, -.tanuki-logo .tanuki-right-eye { - fill: #fc6d26; -} -.tanuki-logo .tanuki-left-cheek, -.tanuki-logo .tanuki-right-cheek { - fill: #fca326; -} .context-header { position: relative; margin-right: 2px; @@ -1393,24 +1380,11 @@ input { border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } -.sidebar-top-level-items > li .badge.badge-pill:not(.gl-badge) { - border-radius: 0.5rem; - padding-top: 0.125rem; - padding-bottom: 0.125rem; - padding-left: 0.5rem; - padding-right: 0.5rem; - background-color: #064787; - color: #9dc7f1; -} .sidebar-top-level-items > li.active .sidebar-sub-level-items:not(.is-fly-out-only) { display: block; } -.sidebar-top-level-items > li.active .badge.badge-pill:not(.gl-badge) { - font-weight: 400; - color: #9dc7f1; -} .sidebar-top-level-items li > a.gl-link { color: #fafafa; } @@ -1786,7 +1760,6 @@ body.gl-dark { --border-color: #4f4f4f; --white: #333; --black: #fff; - --black-normal: #fafafa; --svg-status-bg: #333; } .nav-sidebar li a { @@ -1824,6 +1797,9 @@ body.gl-dark .navbar-gitlab .navbar-sub-nav { body.gl-dark .navbar-gitlab .nav > li { color: #fafafa; } +body.gl-dark .navbar-gitlab .nav > li.header-search-new { + color: #fafafa; +} body.gl-dark .navbar-gitlab .nav > li > a .notification-dot { border: 2px solid #fafafa; } @@ -1861,8 +1837,8 @@ body.gl-dark body.gl-dark .header-search { background-color: rgba(250, 250, 250, 0.2) !important; } -body.gl-dark .header-search svg { - color: rgba(250, 250, 250, 0.8) !important; +body.gl-dark .header-search svg.gl-search-box-by-type-search-icon { + color: rgba(250, 250, 250, 0.8); } body.gl-dark .header-search input { background-color: transparent; @@ -2017,7 +1993,6 @@ body.gl-dark { --border-color: #4f4f4f; --white: #333; --black: #fff; - --black-normal: #fafafa; --svg-status-bg: #333; } .tab-width-8 { diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 0d35c400676..6d66e207bdc 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -978,19 +978,6 @@ input { .top-nav-toggle .dropdown-icon { margin-right: 0.5rem; } -.tanuki-logo .tanuki-left-ear, -.tanuki-logo .tanuki-right-ear, -.tanuki-logo .tanuki-nose { - fill: #e24329; -} -.tanuki-logo .tanuki-left-eye, -.tanuki-logo .tanuki-right-eye { - fill: #fc6d26; -} -.tanuki-logo .tanuki-left-cheek, -.tanuki-logo .tanuki-right-cheek { - fill: #fca326; -} .context-header { position: relative; margin-right: 2px; @@ -1378,24 +1365,11 @@ input { border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } -.sidebar-top-level-items > li .badge.badge-pill:not(.gl-badge) { - border-radius: 0.5rem; - padding-top: 0.125rem; - padding-bottom: 0.125rem; - padding-left: 0.5rem; - padding-right: 0.5rem; - background-color: #cbe2f9; - color: #0b5cad; -} .sidebar-top-level-items > li.active .sidebar-sub-level-items:not(.is-fly-out-only) { display: block; } -.sidebar-top-level-items > li.active .badge.badge-pill:not(.gl-badge) { - font-weight: 400; - color: #0b5cad; -} .sidebar-top-level-items li > a.gl-link { color: #303030; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index c5cbe58ec27..213d1c013a0 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -514,19 +514,6 @@ label.label-bold { .navbar-empty .brand-header-logo { max-height: 100%; } -.tanuki-logo .tanuki-left-ear, -.tanuki-logo .tanuki-right-ear, -.tanuki-logo .tanuki-nose { - fill: #e24329; -} -.tanuki-logo .tanuki-left-eye, -.tanuki-logo .tanuki-right-eye { - fill: #fc6d26; -} -.tanuki-logo .tanuki-left-cheek, -.tanuki-logo .tanuki-right-cheek { - fill: #fca326; -} input::-moz-placeholder { color: #868686; opacity: 1; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 9db134ffa65..3cb8c58a380 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -199,7 +199,6 @@ body.gl-dark { --white: #{$white}; --black: #{$black}; - --black-normal: #{$black-normal}; --svg-status-bg: #{$white}; diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index ec0928fc3d4..c6e29c7f8b0 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -64,6 +64,10 @@ > li { color: $search-and-nav-links; + &.header-search-new { + color: $sidebar-text; + } + > a { .notification-dot { border: 2px solid $nav-svg-color; @@ -151,10 +155,11 @@ background-color: rgba($search-and-nav-links, 0.3) !important; } - svg { - color: rgba($search-and-nav-links, 0.8) !important; + svg.gl-search-box-by-type-search-icon { + color: rgba($search-and-nav-links, 0.8); } + input { background-color: transparent; color: rgba($search-and-nav-links, 0.8); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 8a4f9c32f9f..0511a179980 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -342,4 +342,32 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence } } + /* End gitlab-ui#1709 */ + +/* + * The below two styles will be moved to @gitlab/ui by + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1750 + */ +.gl-max-w-34 { + max-width: 34 * $grid-size; +} + +.gl-max-w-80 { + max-width: 80 * $grid-size; +} + +/* + * The below style will be moved to @gitlab/ui by + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1751 + */ +.gl-filter-blur-1 { + backdrop-filter: blur(2px); + /* stylelint-disable property-no-vendor-prefix */ + -webkit-backdrop-filter: blur(2px); // still required by Safari +} + +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2708 +.gl-inset-border-l-3-red-600 { + box-shadow: inset $gl-border-size-3 0 0 0 $red-600; +} diff --git a/app/components/pajamas/component.rb b/app/components/pajamas/component.rb new file mode 100644 index 00000000000..b05d93b680e --- /dev/null +++ b/app/components/pajamas/component.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Pajamas + class Component < ViewComponent::Base + private + + # :nocov: + + # Filter a given a value against a list of allowed values + # If no value is given or value is not allowed return default one + # + # @param [Object] value + # @param [Enumerable] allowed_values + # @param [Object] default + def filter_attribute(value, allowed_values, default: nil) + return default unless value + return value if allowed_values.include?(value) + + default + end + # :nocov: + end +end diff --git a/app/components/pajamas/toggle_component.html.haml b/app/components/pajamas/toggle_component.html.haml new file mode 100644 index 00000000000..1716e8b69c3 --- /dev/null +++ b/app/components/pajamas/toggle_component.html.haml @@ -0,0 +1,16 @@ +%span{ class: @classes, + data: { name: @name, + id: @id, + is_checked: @is_checked.to_s, + disabled: @is_disabled.to_s, + is_loading: @is_loading.to_s, + label: @label, + help: @help, + label_position: @label_position, + **@data } } + +-# Leverage this block to render a rich help text. To render a plain text help text, +-# prefer the `help` parameter. +- if content.present? + .gl-text-secondary.gl-mt-1 + = content diff --git a/app/components/pajamas/toggle_component.rb b/app/components/pajamas/toggle_component.rb new file mode 100644 index 00000000000..2d99f3d3b69 --- /dev/null +++ b/app/components/pajamas/toggle_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Renders a GlToggle root element +# To actually initialize the component, make sure to call the initToggle helper from ~/toggles. +class Pajamas::ToggleComponent < Pajamas::Component + LABEL_POSITION_OPTIONS = [:top, :left, :hidden].freeze + + # @param [String] classes + # @param [String] label + # @param [Symbol] label_position :top, :left or :hidden + # @param [String] id + # @param [String] name + # @param [String] help + # @param [Hash] data + # @param [Boolean] is_disabled + # @param [Boolean] is_checked + # @param [Boolean] is_loading + def initialize( + classes:, label: nil, label_position: nil, + id: nil, name: nil, help: nil, data: {}, + is_disabled: false, is_checked: false, is_loading: false) + + @id = id + @name = name + @classes = classes + @label = label + @label_position = filter_attribute(label_position, LABEL_POSITION_OPTIONS) + @help = help + @data = data + @is_disabled = is_disabled + @is_checked = is_checked + @is_loading = is_loading + end +end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 1d0930ba73c..0dd85376050 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -66,12 +66,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController render html: Gitlab::Highlight.highlight('payload.json', usage_data_json, language: 'json') end - format.json { render json: Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true).to_json } + + format.json do + Gitlab::UsageDataCounters::ServiceUsageDataCounter.count(:download_payload_click) + + render json: Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true).to_json + end end end def reset_registration_token - @application_setting.reset_runners_registration_token! + ::Ci::Runners::ResetRegistrationTokenService.new(@application_setting, current_user).execute flash[:notice] = _('New runners registration token has been generated!') redirect_to admin_runners_path diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 4660b0bfbb0..ef843a84e6c 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -65,6 +65,6 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController target_path broadcast_type dismissable - )) + ), target_access_levels: []).reverse_merge!(target_access_levels: []) end end diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb index 9a642e53d86..052c8821588 100644 --- a/app/controllers/admin/clusters_controller.rb +++ b/app/controllers/admin/clusters_controller.rb @@ -2,6 +2,7 @@ class Admin::ClustersController < Clusters::ClustersController include EnforcesAdminAuthentication + before_action :ensure_feature_enabled! layout 'admin' diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index e750b5c5ad4..468a1077694 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -5,6 +5,8 @@ class Admin::CohortsController < Admin::ApplicationController feature_category :devops_reports + urgency :low + def index @cohorts = load_cohorts track_cohorts_visit diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb index a235af7c538..47e3337aed7 100644 --- a/app/controllers/admin/dev_ops_report_controller.rb +++ b/app/controllers/admin/dev_ops_report_controller.rb @@ -9,6 +9,8 @@ class Admin::DevOpsReportController < Admin::ApplicationController feature_category :devops_reports + urgency :low + # rubocop: disable CodeReuse/ActiveRecord def show @metric = DevOpsReport::Metric.order(:created_at).last&.present diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb index 1ce6e66c6de..cc801bce5b7 100644 --- a/app/controllers/admin/instance_review_controller.rb +++ b/app/controllers/admin/instance_review_controller.rb @@ -2,6 +2,8 @@ class Admin::InstanceReviewController < Admin::ApplicationController feature_category :devops_reports + urgency :low + def index redirect_to("#{Gitlab::SubscriptionPortal.subscriptions_instance_review_url}?#{instance_review_params}") end diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb index ad0ee0b2cef..db9835e65ec 100644 --- a/app/controllers/admin/integrations_controller.rb +++ b/app/controllers/admin/integrations_controller.rb @@ -5,6 +5,10 @@ class Admin::IntegrationsController < Admin::ApplicationController before_action :not_found, unless: -> { instance_level_integrations? } + before_action do + push_frontend_feature_flag(:integration_form_sections, default_enabled: :yaml) + end + feature_category :integrations def overrides diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb index 598c536d652..a4055cbe990 100644 --- a/app/controllers/admin/runner_projects_controller.rb +++ b/app/controllers/admin/runner_projects_controller.rb @@ -8,7 +8,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController def create @runner = Ci::Runner.find(params[:runner_project][:runner_id]) - if @runner.assign_to(@project, current_user) + if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute redirect_to edit_admin_runner_url(@runner), notice: s_('Runners|Runner assigned to project.') else redirect_to edit_admin_runner_url(@runner), alert: 'Failed adding runner to project' @@ -18,7 +18,8 @@ class Admin::RunnerProjectsController < Admin::ApplicationController def destroy rp = Ci::RunnerProject.find(params[:id]) runner = rp.runner - rp.destroy + + ::Ci::Runners::UnassignRunnerService.new(rp, current_user).execute redirect_to edit_admin_runner_url(runner), status: :found, notice: s_('Runners|Runner unassigned from project.') end diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index f7f78ab3229..2744be0150c 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -23,7 +23,7 @@ class Admin::RunnersController < Admin::ApplicationController end def update - if Ci::UpdateRunnerService.new(@runner).update(runner_params) + if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params) respond_to do |format| format.html { redirect_to edit_admin_runner_path(@runner) } end @@ -34,13 +34,13 @@ class Admin::RunnersController < Admin::ApplicationController end def destroy - Ci::UnregisterRunnerService.new(@runner).execute + Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute redirect_to admin_runners_path, status: :found end def resume - if Ci::UpdateRunnerService.new(@runner).update(active: true) + if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true) redirect_to admin_runners_path, notice: _('Runner was successfully updated.') else redirect_to admin_runners_path, alert: _('Runner was not updated.') @@ -48,7 +48,7 @@ class Admin::RunnersController < Admin::ApplicationController end def pause - if Ci::UpdateRunnerService.new(@runner).update(active: false) + if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false) redirect_to admin_runners_path, notice: _('Runner was successfully updated.') else redirect_to admin_runners_path, alert: _('Runner was not updated.') diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb index 0b315517594..2cede1aec05 100644 --- a/app/controllers/admin/usage_trends_controller.rb +++ b/app/controllers/admin/usage_trends_controller.rb @@ -7,6 +7,8 @@ class Admin::UsageTrendsController < Admin::ApplicationController feature_category :devops_reports + urgency :low + def index end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index c1fa104ffda..f19333d5d57 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -2,6 +2,7 @@ class Admin::UsersController < Admin::ApplicationController include RoutableActions + include SortingHelper before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate @@ -18,7 +19,8 @@ class Admin::UsersController < Admin::ApplicationController @users = User.filter_items(params[:filter]).order_name_asc @users = @users.search(params[:search_query], with_private_emails: true) if params[:search_query].present? @users = users_with_included_associations(@users) - @users = @users.sort_by_attribute(@sort = params[:sort]) + @sort = params[:sort].presence || sort_value_name + @users = @users.sort_by_attribute(@sort) @users = @users.page(params[:page]) @users = @users.without_count if paginate_without_count? end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8e758c669db..1d17e8aa085 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -111,6 +111,15 @@ class ApplicationController < ActionController::Base render plain: e.message, status: :too_many_requests end + content_security_policy do |p| + next if p.directives.blank? + next unless Gitlab::CurrentSettings.snowplow_enabled? && !Gitlab::CurrentSettings.snowplow_collector_hostname.blank? + + default_connect_src = p.directives['connect-src'] || p.directives['default-src'] + connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.snowplow_collector_hostname] + p.connect_src(*connect_src_values) + end + def redirect_back_or_default(default: root_path, options: {}) redirect_back(fallback_location: default, **options) end @@ -237,19 +246,19 @@ class ApplicationController < ActionController::Base end def git_not_found! - render "errors/git_not_found.html", layout: "errors", status: :not_found + render template: "errors/git_not_found", formats: :html, layout: "errors", status: :not_found end def render_403 respond_to do |format| - format.html { render "errors/access_denied", layout: "errors", status: :forbidden } + format.html { render template: "errors/access_denied", formats: :html, layout: "errors", status: :forbidden } format.any { head :forbidden } end end def render_404 respond_to do |format| - format.html { render "errors/not_found", layout: "errors", status: :not_found } + format.html { render template: "errors/not_found", formats: :html, layout: "errors", status: :not_found } # Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file format.js { render json: '', status: :not_found, content_type: 'application/json' } format.any { head :not_found } diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index ee5caf63703..4bcd1be9f53 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class AutocompleteController < ApplicationController + include SearchRateLimitable + skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches] - before_action :check_email_search_rate_limit!, only: [:users] + before_action :check_search_rate_limit!, only: [:users, :projects] feature_category :users, [:users, :user] feature_category :projects, [:projects] @@ -72,12 +74,6 @@ class AutocompleteController < ApplicationController def target_branch_params params.permit(:group_id, :project_id).select { |_, v| v.present? } end - - def check_email_search_rate_limit! - search_params = Gitlab::Search::Params.new(params) - - check_rate_limit!(:user_email_lookup, scope: [current_user]) if search_params.email_lookup? - end end AutocompleteController.prepend_mod_with('AutocompleteController') diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index c12ceca9c3b..d9179129983 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -9,15 +9,25 @@ class Clusters::ClustersController < Clusters::BaseController before_action :generate_gcp_authorize_url, only: [:new] before_action :validate_gcp_token, only: [:new] before_action :gcp_cluster, only: [:new] - before_action :user_cluster, only: [:new] + before_action :user_cluster, only: [:new, :connect] before_action :authorize_read_cluster!, only: [:show, :index] - before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role] + before_action :authorize_create_cluster!, only: [:new, :connect, :authorize_aws_role] before_action :authorize_update_cluster!, only: [:update] before_action :update_applications_status, only: [:cluster_status] + before_action :ensure_feature_enabled!, except: :index helper_method :token_in_session STATUS_POLLING_INTERVAL = 10_000 + AWS_CSP_DOMAINS = %w[https://ec2.ap-east-1.amazonaws.com https://ec2.ap-northeast-1.amazonaws.com https://ec2.ap-northeast-2.amazonaws.com https://ec2.ap-northeast-3.amazonaws.com https://ec2.ap-south-1.amazonaws.com https://ec2.ap-southeast-1.amazonaws.com https://ec2.ap-southeast-2.amazonaws.com https://ec2.ca-central-1.amazonaws.com https://ec2.eu-central-1.amazonaws.com https://ec2.eu-north-1.amazonaws.com https://ec2.eu-west-1.amazonaws.com https://ec2.eu-west-2.amazonaws.com https://ec2.eu-west-3.amazonaws.com https://ec2.me-south-1.amazonaws.com https://ec2.sa-east-1.amazonaws.com https://ec2.us-east-1.amazonaws.com https://ec2.us-east-2.amazonaws.com https://ec2.us-west-1.amazonaws.com https://ec2.us-west-2.amazonaws.com https://ec2.af-south-1.amazonaws.com https://iam.amazonaws.com].freeze + + content_security_policy do |p| + next if p.directives.blank? + + default_connect_src = p.directives['connect-src'] || p.directives['default-src'] + connect_src_values = Array.wrap(default_connect_src) | AWS_CSP_DOMAINS + p.connect_src(*connect_src_values) + end def index @clusters = cluster_list @@ -142,7 +152,7 @@ class Clusters::ClustersController < Clusters::BaseController validate_gcp_token gcp_cluster - render :new, locals: { active_tab: 'add' } + render :connect end end @@ -163,7 +173,17 @@ class Clusters::ClustersController < Clusters::BaseController private + def certificate_based_clusters_enabled? + Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops) + end + + def ensure_feature_enabled! + render_404 unless certificate_based_clusters_enabled? + end + def cluster_list + return [] unless certificate_based_clusters_enabled? + finder = ClusterAncestorsFinder.new(clusterable.subject, current_user) clusters = finder.execute diff --git a/app/controllers/concerns/floc_opt_out.rb b/app/controllers/concerns/floc_opt_out.rb index 3039af02bbb..50a52cecda9 100644 --- a/app/controllers/concerns/floc_opt_out.rb +++ b/app/controllers/concerns/floc_opt_out.rb @@ -4,7 +4,7 @@ module FlocOptOut extend ActiveSupport::Concern included do - after_action :set_floc_opt_out_header, unless: :floc_enabled? + before_action :set_floc_opt_out_header, unless: :floc_enabled? end def floc_enabled? diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index eae087bca4d..ae90bd59d01 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -17,10 +17,6 @@ module IssuableActions def show respond_to do |format| format.html do - @show_crm_contacts = issuable.is_a?(Issue) && # rubocop:disable Gitlab/ModuleWithInstanceVariables - can?(current_user, :read_crm_contact, issuable.project.group) && - CustomerRelations::Contact.exists_for_group?(issuable.project.group) - @issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables render 'show' end @@ -43,18 +39,18 @@ module IssuableActions if updated_issuable.is_a?(Spammable) respond_to do |format| format.html do - # NOTE: This redirect is intentionally only performed in the case where the updated - # issuable is a spammable, and intentionally is not performed in the non-spammable case. - # This preserves the legacy behavior of this action. if updated_issuable.valid? + # NOTE: This redirect is intentionally only performed in the case where the valid updated + # issuable is a spammable, and intentionally is not performed below in the + # valid non-spammable case. This preserves the legacy behavior of this action. redirect_to spammable_path else - with_captcha_check_html_format { render :edit } + with_captcha_check_html_format(spammable: spammable) { render :edit } end end format.json do - with_captcha_check_json_format { render_entity_json } + with_captcha_check_json_format(spammable: spammable) { render_entity_json } end end else diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index b68db0e3f9f..96cf6021ea9 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -17,7 +17,7 @@ module IssuableCollectionsAction respond_to do |format| format.html - format.atom { render layout: 'xml.atom' } + format.atom { render layout: 'xml' } end end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index f716c1f6c2f..0b9024dc3db 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -4,17 +4,6 @@ module MembershipActions include MembersPresentation extend ActiveSupport::Concern - def create - create_params = params.permit(:user_ids, :access_level, :expires_at) - result = Members::CreateService.new(current_user, create_params.merge({ source: membershipable, invite_source: "#{plain_source_type}-members-page" })).execute - - if result[:status] == :success - redirect_to members_page_url, notice: _('Users were successfully added.') - else - redirect_to members_page_url, alert: result[:message] - end - end - def update update_params = params.require(root_params_key).permit(:access_level, :expires_at) member = membershipable.members_and_requesters.find(params[:id]) @@ -79,8 +68,8 @@ module MembershipActions notice: _('Your request for access has been queued for review.') else redirect_to polymorphic_path(membershipable), - alert: _("Your request for access could not be processed: %{error_meesage}") % - { error_meesage: access_requester.errors.full_messages.to_sentence } + alert: _("Your request for access could not be processed: %{error_message}") % + { error_message: access_requester.errors.full_messages.to_sentence } end end diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb new file mode 100644 index 00000000000..03296d6b233 --- /dev/null +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ProductAnalyticsTracking + include Gitlab::Tracking::Helpers + include RedisTracking + extend ActiveSupport::Concern + + class_methods do + def track_event(*controller_actions, name:, conditions: nil, destinations: [:redis_hll], &block) + custom_conditions = [:trackable_html_request?, *conditions] + + after_action only: controller_actions, if: custom_conditions do + route_events_to(destinations, name, &block) + end + end + end + + private + + def route_events_to(destinations, name, &block) + track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll) + + if destinations.include?(:snowplow) && Feature.enabled?(:route_hll_to_snowplow, tracking_namespace_source, default_enabled: :yaml) + Gitlab::Tracking.event(self.class.to_s, name, namespace: tracking_namespace_source, user: current_user) + end + end +end diff --git a/app/controllers/concerns/search_rate_limitable.rb b/app/controllers/concerns/search_rate_limitable.rb new file mode 100644 index 00000000000..a77ebd276b6 --- /dev/null +++ b/app/controllers/concerns/search_rate_limitable.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SearchRateLimitable + extend ActiveSupport::Concern + + private + + def check_search_rate_limit! + if current_user + check_rate_limit!(:search_rate_limit, scope: [current_user]) + else + check_rate_limit!(:search_rate_limit_unauthenticated, scope: [request.ip]) + end + end +end diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb index 1f17f9f4e1b..48daacc09c2 100644 --- a/app/controllers/concerns/sessionless_authentication.rb +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -26,6 +26,9 @@ module SessionlessAuthentication # for every request. If you want the token to work as a # sign in token, you can simply remove store: false. sign_in(user, store: false, message: :sessionless_sign_in) + elsif request_authenticator.can_sign_in_bot?(user) + # we suppress callbacks to avoid redirecting the bot + sign_in(user, store: false, message: :sessionless_sign_in, run_callbacks: false) end end diff --git a/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb b/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb index 234c591ffb7..044519004b2 100644 --- a/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb +++ b/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb @@ -2,7 +2,6 @@ module SpammableActions::AkismetMarkAsSpamAction extend ActiveSupport::Concern - include SpammableActions::Attributes included do before_action :authorize_submit_spammable!, only: :mark_as_spam @@ -22,7 +21,15 @@ module SpammableActions::AkismetMarkAsSpamAction access_denied! unless current_user.can_admin_all_resources? end + def spammable + # The class extending this module should define the #spammable method to return + # the Spammable model instance via: `alias_method :spammable , <:model_name>` + raise NotImplementedError, "#{self.class} should implement #{__method__}" + end + def spammable_path - raise NotImplementedError, "#{self.class} does not implement #{__method__}" + # The class extending this module should define the #spammable_path method to return + # the route helper pointing to the action to show the Spammable instance + raise NotImplementedError, "#{self.class} should implement #{__method__}" end end diff --git a/app/controllers/concerns/spammable_actions/attributes.rb b/app/controllers/concerns/spammable_actions/attributes.rb deleted file mode 100644 index d7060e47c07..00000000000 --- a/app/controllers/concerns/spammable_actions/attributes.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module SpammableActions - module Attributes - extend ActiveSupport::Concern - - private - - def spammable - raise NotImplementedError, "#{self.class} does not implement #{__method__}" - end - end -end diff --git a/app/controllers/concerns/spammable_actions/captcha_check/common.rb b/app/controllers/concerns/spammable_actions/captcha_check/common.rb index 7c047e02a1d..aaeb6b3ba83 100644 --- a/app/controllers/concerns/spammable_actions/captcha_check/common.rb +++ b/app/controllers/concerns/spammable_actions/captcha_check/common.rb @@ -1,23 +1,25 @@ # frozen_string_literal: true -module SpammableActions::CaptchaCheck - module Common - extend ActiveSupport::Concern +module SpammableActions + module CaptchaCheck + module Common + extend ActiveSupport::Concern - private + private - def with_captcha_check_common(captcha_render_lambda:, &block) - # If the Spammable indicates that CAPTCHA is not necessary (either due to it not being flagged - # as spam, or if spam/captcha is disabled for some reason), then we will go ahead and - # yield to the block containing the action's original behavior, then return. - return yield unless spammable.render_recaptcha? + def with_captcha_check_common(spammable:, captcha_render_lambda:, &block) + # If the Spammable indicates that CAPTCHA is not necessary (either due to it not being flagged + # as spam, or if spam/captcha is disabled for some reason), then we will go ahead and + # yield to the block containing the action's original behavior, then return. + return yield unless spammable.render_recaptcha? - # If we got here, we need to render the CAPTCHA instead of yielding to action's original - # behavior. We will present a CAPTCHA to be solved by executing the lambda which was passed - # as the `captcha_render_lambda:` argument. This lambda contains either the HTML-specific or - # JSON-specific behavior to cause the CAPTCHA modal to be rendered. - Gitlab::Recaptcha.load_configurations! - captcha_render_lambda.call + # If we got here, we need to render the CAPTCHA instead of yielding to action's original + # behavior. We will present a CAPTCHA to be solved by executing the lambda which was passed + # as the `captcha_render_lambda:` argument. This lambda contains either the HTML-specific or + # JSON-specific behavior to cause the CAPTCHA modal to be rendered. + Gitlab::Recaptcha.load_configurations! + captcha_render_lambda.call + end end end 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 f687c0fcf2d..b254916cdd6 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 @@ -8,7 +8,6 @@ # which supports JSON format should be used instead. module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport extend ActiveSupport::Concern - include SpammableActions::Attributes include SpammableActions::CaptchaCheck::Common included do @@ -17,9 +16,9 @@ module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport private - def with_captcha_check_html_format(&block) + def with_captcha_check_html_format(spammable:, &block) captcha_render_lambda = -> { render :captcha_check } - with_captcha_check_common(captcha_render_lambda: captcha_render_lambda, &block) + with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block) end # Convert spam/CAPTCHA values from form field params to headers, because all spam-related services diff --git a/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb index 0bfea05abc7..4a278a7b233 100644 --- a/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb +++ b/app/controllers/concerns/spammable_actions/captcha_check/json_format_actions_support.rb @@ -4,22 +4,27 @@ # In other words, forms handled by actions which use a `respond_to` of `format.js` or `format.json`. # # For example, for all Javascript based form submissions and Vue components which use Apollo and Axios +# which are directly handled by a controller other than `GraphqlController`. For example, issue +# update currently uses this module. +# +# However, requests which directly hit `GraphqlController` will not use this module - the +# `Mutations::SpamProtection` module handles those requests (for example, snippet create/update +# requests) # # If the request is handled by actions via `format.html`, then the corresponding module which # supports HTML format should be used instead. module SpammableActions::CaptchaCheck::JsonFormatActionsSupport extend ActiveSupport::Concern - include SpammableActions::Attributes include SpammableActions::CaptchaCheck::Common include Spam::Concerns::HasSpamActionResponseFields private - def with_captcha_check_json_format(&block) + def with_captcha_check_json_format(spammable:, &block) # NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response # which requires a CAPTCHA to be solved in order for the request to be resubmitted. # https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.10 captcha_render_lambda = -> { render json: spam_action_response_fields(spammable), status: :conflict } - with_captcha_check_common(captcha_render_lambda: captcha_render_lambda, &block) + with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block) end end diff --git a/app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb new file mode 100644 index 00000000000..2ebfa90e6da --- /dev/null +++ b/app/controllers/concerns/spammable_actions/captcha_check/rest_api_actions_support.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# This module should be included to support CAPTCHA check for REST API actions via Grape. +# +# If the request is directly handled by a controller action, then the corresponding module which +# supports HTML or JSON formats should be used instead. +module SpammableActions::CaptchaCheck::RestApiActionsSupport + extend ActiveSupport::Concern + include SpammableActions::CaptchaCheck::Common + include Spam::Concerns::HasSpamActionResponseFields + + private + + def with_captcha_check_rest_api(spammable:, &block) + # In the case of the REST API, the request is handled by Grape, so if there is a spam-related + # error, we don't render directly, instead we will pass the error message and other necessary + # fields to the Grape api error helper for it to handle. + captcha_render_lambda = -> do + fields = spam_action_response_fields(spammable) + + fields.delete :spam + # NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response + # which requires a CAPTCHA to be solved in order for the request to be resubmitted. + # https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.10 + status = 409 + + # NOTE: This nested 'error' key may not be consistent with all other API error responses, + # because they are not currently consistent across different API endpoints + # and models. Some (snippets) will nest errors in an errors key like this, + # while others (issues) will return the model's errors hash without an errors key, + # while still others just return a plain string error. + # See https://gitlab.com/groups/gitlab-org/-/epics/5527#revisit-inconsistent-shape-of-error-responses-in-rest-api + fields[:message] = { error: spammable.errors.full_messages.to_sentence } + render_structured_api_error!(fields, status) + end + + with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block) + end +end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index e1bfe92f61b..c9b6e8923fe 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -143,7 +143,7 @@ module UploadsActions end def bypass_auth_checks_on_uploads? - if ::Feature.enabled?(:enforce_auth_checks_on_uploads, default_enabled: :yaml) + if ::Feature.enabled?(:enforce_auth_checks_on_uploads, project, default_enabled: :yaml) false else action_name == 'show' && embeddable? diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 0074bcac360..4d6c7a63516 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -23,7 +23,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end format.atom do load_events - render layout: 'xml.atom' + render layout: 'xml' end format.json do render json: { diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index f94da77609f..f25cc1bbc32 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -20,10 +20,6 @@ class DashboardController < Dashboard::ApplicationController urgency :low, [:merge_requests] - before_action only: [:merge_requests] do - push_frontend_feature_flag(:mr_attention_requests, default_enabled: :yaml) - end - def activity respond_to do |format| format.html @@ -71,10 +67,15 @@ class DashboardController < Dashboard::ApplicationController end def check_filters_presence! - no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) } - no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) } + no_scalar_filters_set = finder_type.scalar_params.none? { |k| params[k].present? } + no_array_filters_set = finder_type.array_params.none? { |k, _| params[k].present? } + + # The `in` param is a modifier of `search`. If it's present while the `search` + # param isn't, the finder won't use the `in` param. We consider this as a no + # filter scenario. + no_search_filter_set = params[:in].present? && params[:search].blank? - @no_filters_set = no_scalar_filters_set && no_array_filters_set + @no_filters_set = (no_scalar_filters_set && no_array_filters_set) || no_search_filter_set return unless @no_filters_set diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index f9c875b80b2..bf72ade32d0 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -82,6 +82,10 @@ class Groups::ApplicationController < ApplicationController def has_project_list? false end + + def validate_root_group! + render_404 unless group.root? + end end Groups::ApplicationController.prepend_mod_with('Groups::ApplicationController') diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 6fac6fcf426..641b3adb12b 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -7,7 +7,6 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:issue_boards_filtered_search, group, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) experiment(:prominent_create_board_btn, subject: current_user) do |e| diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb index 666a96d6fc0..2fe9faa252f 100644 --- a/app/controllers/groups/clusters_controller.rb +++ b/app/controllers/groups/clusters_controller.rb @@ -3,6 +3,7 @@ class Groups::ClustersController < Clusters::ClustersController include ControllerWithCrossProjectAccessCheck + before_action :ensure_feature_enabled! prepend_before_action :group requires_cross_project_access diff --git a/app/controllers/groups/crm/contacts_controller.rb b/app/controllers/groups/crm/contacts_controller.rb index f00f4d1df25..b59e20d9cea 100644 --- a/app/controllers/groups/crm/contacts_controller.rb +++ b/app/controllers/groups/crm/contacts_controller.rb @@ -3,6 +3,7 @@ class Groups::Crm::ContactsController < Groups::ApplicationController feature_category :team_planning + before_action :validate_root_group! before_action :authorize_read_crm_contact! def new diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb index ab720f490be..f8536b4f538 100644 --- a/app/controllers/groups/crm/organizations_controller.rb +++ b/app/controllers/groups/crm/organizations_controller.rb @@ -3,6 +3,7 @@ class Groups::Crm::OrganizationsController < Groups::ApplicationController feature_category :team_planning + before_action :validate_root_group! before_action :authorize_read_crm_organization! def new diff --git a/app/controllers/groups/deploy_tokens_controller.rb b/app/controllers/groups/deploy_tokens_controller.rb index 79152bf2695..9ef22aa33dc 100644 --- a/app/controllers/groups/deploy_tokens_controller.rb +++ b/app/controllers/groups/deploy_tokens_controller.rb @@ -6,8 +6,7 @@ class Groups::DeployTokensController < Groups::ApplicationController feature_category :continuous_delivery def revoke - @token = @group.deploy_tokens.find(params[:id]) - @token.revoke! + Groups::DeployTokens::RevokeService.new(@group, current_user, params).execute redirect_to group_settings_repository_path(@group, anchor: 'js-deploy-tokens') end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 6e59f159636..ece1083d4d1 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -16,7 +16,7 @@ class Groups::GroupMembersController < Groups::ApplicationController before_action :authorize_admin_group_member!, except: admin_not_required_endpoints skip_before_action :check_two_factor_requirement, only: :leave - skip_cross_project_access_check :index, :create, :update, :destroy, :request_access, + skip_cross_project_access_check :index, :update, :destroy, :request_access, :approve_access_request, :leave, :resend_invite, :override @@ -26,8 +26,6 @@ class Groups::GroupMembersController < Groups::ApplicationController @sort = params[:sort].presence || sort_value_name if can?(current_user, :admin_group_member, @group) - @skip_groups = @group.related_group_ids - @invited_members = invited_members @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present? @invited_members = present_invited_members(@invited_members) @@ -38,8 +36,6 @@ class Groups::GroupMembersController < Groups::ApplicationController @requesters = present_members( AccessRequestsFinder.new(@group).execute(current_user) ) - - @group_member = @group.group_members.new end # MembershipActions concern diff --git a/app/controllers/groups/harbor/repositories_controller.rb b/app/controllers/groups/harbor/repositories_controller.rb new file mode 100644 index 00000000000..364607f9b20 --- /dev/null +++ b/app/controllers/groups/harbor/repositories_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class RepositoriesController < Groups::ApplicationController + feature_category :integrations + + before_action :harbor_registry_enabled! + before_action do + push_frontend_feature_flag(:harbor_registry_integration) + end + + def show + render :index + end + + private + + def harbor_registry_enabled! + render_404 unless Feature.enabled?(:harbor_registry_integration) + end + end + end +end diff --git a/app/controllers/groups/releases_controller.rb b/app/controllers/groups/releases_controller.rb index 6a42f30b847..db5385ecc71 100644 --- a/app/controllers/groups/releases_controller.rb +++ b/app/controllers/groups/releases_controller.rb @@ -15,11 +15,17 @@ module Groups private def releases - ReleasesFinder - .new(@group, current_user, { include_subgroups: true }) - .execute(preload: false) - .page(params[:page]) - .per(30) + if Feature.enabled?(:group_releases_finder_inoperator) + Releases::GroupReleasesFinder + .new(@group, current_user, { include_subgroups: true, page: params[:page], per: 30 }) + .execute(preload: false) + else + ReleasesFinder + .new(@group, current_user, { include_subgroups: true }) + .execute(preload: false) + .page(params[:page]) + .per(30) + end end end end diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index b194aeff80d..dabef978ee1 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -24,7 +24,7 @@ class Groups::RunnersController < Groups::ApplicationController end def update - if Ci::UpdateRunnerService.new(@runner).update(runner_params) + if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params) redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.') else render 'edit' @@ -32,17 +32,17 @@ class Groups::RunnersController < Groups::ApplicationController end def destroy - if @runner.belongs_to_more_than_one_project? - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner was not deleted because it is assigned to multiple projects.') - else - Ci::UnregisterRunnerService.new(@runner).execute + if can?(current_user, :delete_runner, @runner) + Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found + else + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner cannot be deleted, please contact your administrator.') end end def resume - if Ci::UpdateRunnerService.new(@runner).update(active: true) + if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true) redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.') else redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.') @@ -50,7 +50,7 @@ class Groups::RunnersController < Groups::ApplicationController end def pause - if Ci::UpdateRunnerService.new(@runner).update(active: false) + if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false) redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.') else redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.') diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index a290ef9b5e7..9b9e3f7b0bc 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -36,7 +36,7 @@ module Groups end def reset_registration_token - @group.reset_runners_token! + ::Ci::Runners::ResetRegistrationTokenService.new(@group, current_user).execute flash[:notice] = _('GroupSettings|New runners registration token has been generated!') redirect_to group_settings_ci_cd_path diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb index 0a63c3d304b..ec64e75a68e 100644 --- a/app/controllers/groups/settings/integrations_controller.rb +++ b/app/controllers/groups/settings/integrations_controller.rb @@ -7,6 +7,10 @@ module Groups before_action :authorize_admin_group! + before_action do + push_frontend_feature_flag(:integration_form_sections, group, default_enabled: :yaml) + end + feature_category :integrations layout 'group_settings' diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb index 387f7be56cd..49249f87d31 100644 --- a/app/controllers/groups/uploads_controller.rb +++ b/app/controllers/groups/uploads_controller.rb @@ -4,7 +4,7 @@ class Groups::UploadsController < Groups::ApplicationController include UploadsActions include WorkhorseRequest - skip_before_action :group, if: -> { bypass_auth_checks_on_uploads? } + skip_before_action :group, if: -> { action_name == 'show' && embeddable? } before_action :authorize_upload_file!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 12af76efe0d..b53d9b1be04 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -212,7 +212,7 @@ class GroupsController < Groups::ApplicationController def issues return super if !html_request? || Feature.disabled?(:vue_issues_list, group, default_enabled: :yaml) - @has_issues = IssuesFinder.new(current_user, group_id: group.id).execute + @has_issues = IssuesFinder.new(current_user, group_id: group.id, include_subgroups: true).execute .non_archived .exists? @@ -235,7 +235,7 @@ class GroupsController < Groups::ApplicationController def render_details_view_atom load_events - render layout: 'xml.atom', template: 'groups/show' + render layout: 'xml', template: 'groups/show' end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb index 1ea0a92662b..327192857f6 100644 --- a/app/controllers/jira_connect/events_controller.rb +++ b/app/controllers/jira_connect/events_controller.rb @@ -7,11 +7,13 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController before_action :verify_asymmetric_atlassian_jwt! def installed - return head :ok if current_jira_installation + unless Feature.enabled?(:jira_connect_installation_update, default_enabled: :yaml) + return head :ok if current_jira_installation + end - installation = JiraConnectInstallation.new(event_params) + success = current_jira_installation ? update_installation : create_installation - if installation.save + if success head :ok else head :unprocessable_entity @@ -28,8 +30,24 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController private - def event_params - params.permit(:clientKey, :sharedSecret, :baseUrl).transform_keys(&:underscore) + def create_installation + JiraConnectInstallation.new(create_params).save + end + + def update_installation + current_jira_installation.update(update_params) + end + + def create_params + transformed_params.permit(:client_key, :shared_secret, :base_url) + end + + def update_params + transformed_params.permit(:shared_secret, :base_url) + end + + def transformed_params + @transformed_params ||= params.transform_keys(&:underscore) end def verify_asymmetric_atlassian_jwt! @@ -43,7 +61,7 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController def jwt_verification_claims { aud: jira_connect_base_url(protocol: 'https'), - iss: event_params[:client_key], + iss: transformed_params[:client_key], qsh: Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url) } end diff --git a/app/controllers/jira_connect/oauth_callbacks_controller.rb b/app/controllers/jira_connect/oauth_callbacks_controller.rb new file mode 100644 index 00000000000..f603a563402 --- /dev/null +++ b/app/controllers/jira_connect/oauth_callbacks_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This controller's role is to serve as a landing page +# that users get redirected to after installing and authenticating +# The GitLab.com for Jira App (https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud) +# +class JiraConnect::OauthCallbacksController < ApplicationController + feature_category :integrations + + def index; end +end diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index fcd95c7942c..ec6ba07a125 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -16,6 +16,10 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController p.style_src(*style_src_values) end + before_action do + push_frontend_feature_flag(:jira_connect_oauth, @user, default_enabled: :yaml) + end + before_action :allow_rendering_in_iframe, only: :index before_action :verify_qsh_claim!, only: :index before_action :authenticate_user!, only: :create diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 46738651960..d57a293ab4d 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -7,7 +7,7 @@ class ProfilesController < Profiles::ApplicationController before_action :user before_action :authorize_change_username!, only: :update_username before_action only: :update_username do - check_rate_limit!(:profile_update_username, scope: current_user) if Feature.enabled?(:rate_limit_profile_update_username, default_enabled: :yaml) + check_rate_limit!(:profile_update_username, scope: current_user) end skip_before_action :require_email, only: [:show, :update] before_action do diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 7a03e7b84b7..62233c8c3c9 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -24,11 +24,14 @@ class Projects::ApplicationController < ApplicationController return unless params[:project_id] || params[:id] path = File.join(params[:namespace_id], params[:project_id] || params[:id]) - auth_proc = ->(project) { !project.pending_delete? } @project = find_routable!(Project, path, request.fullpath, extra_authorization_proc: auth_proc) end + def auth_proc + ->(project) { !project.pending_delete? } + end + def build_canonical_path(project) params[:namespace_id] = project.namespace.to_param params[:project_id] = project.to_param @@ -89,3 +92,5 @@ class Projects::ApplicationController < ApplicationController return render_404 unless @project.feature_available?(:issues, current_user) end end + +Projects::ApplicationController.prepend_mod_with('Projects::ApplicationController') diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index b30ef7506aa..26a7b5662be 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -35,7 +35,6 @@ class Projects::BlobController < Projects::ApplicationController before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff before_action :set_last_commit_sha, only: [:edit, :update] - before_action :track_experiment, only: :create track_redis_hll_event :create, :update, name: 'g_edit_by_sfe' @@ -45,7 +44,6 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml) - push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end @@ -55,7 +53,7 @@ class Projects::BlobController < Projects::ApplicationController def create create_commit(Files::CreateService, success_notice: _("The file has been successfully created."), - success_path: -> { create_success_path }, + success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) }, failure_view: :new, failure_path: project_new_blob_path(@project, @ref)) end @@ -283,20 +281,6 @@ class Projects::BlobController < Projects::ApplicationController def visitor_id current_user&.id end - - def create_success_path - if params[:code_quality_walkthrough] - project_pipelines_path(@project, code_quality_walkthrough: true) - else - project_blob_path(@project, File.join(@branch_name, @file_path)) - end - end - - def track_experiment - return unless params[:code_quality_walkthrough] - - experiment(:code_quality_walkthrough, namespace: @project.root_ancestor).track(:commit_created) - end end Projects::BlobController.prepend_mod diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 0170cff6160..c44a0830e2e 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -7,7 +7,6 @@ class Projects::BoardsController < Projects::ApplicationController before_action :check_issues_available! before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:issue_boards_filtered_search, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) experiment(:prominent_create_board_btn, subject: current_user) do |e| diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index c5f6ed1c105..61e8e5b015a 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -5,6 +5,9 @@ class Projects::BuildsController < Projects::ApplicationController feature_category :continuous_integration + urgency :high, [:index, :show] + urgency :low, [:raw] + def index redirect_to project_jobs_path(project) end diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 6f12e3940dd..8c6e8f0e126 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -2,7 +2,6 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! - before_action :setup_walkthrough_experiment, only: :show before_action do push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml) end @@ -19,11 +18,4 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController def check_can_collaborate! render_404 unless can_collaborate_with_project?(@project) end - - def setup_walkthrough_experiment - experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e| - e.candidate {} - e.publish_to_database - end - end end diff --git a/app/controllers/projects/ci/secure_files_controller.rb b/app/controllers/projects/ci/secure_files_controller.rb new file mode 100644 index 00000000000..5141d0188b0 --- /dev/null +++ b/app/controllers/projects/ci/secure_files_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Projects::Ci::SecureFilesController < Projects::ApplicationController + before_action :authorize_read_secure_files! + + feature_category :pipeline_authoring + + def show + end +end diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb index 84bb01ee266..282b9ef1fb7 100644 --- a/app/controllers/projects/cluster_agents_controller.rb +++ b/app/controllers/projects/cluster_agents_controller.rb @@ -3,10 +3,6 @@ class Projects::ClusterAgentsController < Projects::ApplicationController before_action :authorize_can_read_cluster_agent! - before_action do - push_frontend_feature_flag(:cluster_vulnerabilities, project, default_enabled: :yaml) - end - feature_category :kubernetes_management def show diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 0ce0b8b8895..0c26b402876 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -164,6 +164,7 @@ class Projects::CommitController < Projects::ApplicationController opts = diff_options opts[:ignore_whitespace_change] = true if params[:format] == 'diff' + opts[:use_extra_viewer_as_main] = false @diffs = commit.diffs(opts) @notes_count = commit.notes.count diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 82a13b60b13..60b8e45f5be 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -30,7 +30,7 @@ class Projects::CommitsController < Projects::ApplicationController respond_to do |format| format.html - format.atom { render layout: 'xml.atom' } + format.atom { render layout: 'xml' } format.json do pager_json( diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb index 3c890bbafdf..42c2d8b17f1 100644 --- a/app/controllers/projects/deploy_tokens_controller.rb +++ b/app/controllers/projects/deploy_tokens_controller.rb @@ -12,3 +12,5 @@ class Projects::DeployTokensController < Projects::ApplicationController redirect_to project_settings_repository_path(project, anchor: 'js-deploy-tokens') end end + +Projects::DeployTokensController.prepend_mod diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 84ebdcd9364..eabc048e341 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -29,13 +29,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController feature_category :continuous_delivery def index - @environments = project.environments - .with_state(params[:scope] || :available) @project = ProjectPresenter.new(project, current_user: current_user) respond_to do |format| format.html format.json do + @environments = project.environments + .with_state(params[:scope] || :available) + Gitlab::PollingInterval.set_header(response, interval: 3_000) environments_count_by_state = project.environments.count_by_state @@ -52,14 +53,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController # Returns all environments for a given folder # rubocop: disable CodeReuse/ActiveRecord def folder - folder_environments = project.environments.where(environment_type: params[:id]) - @environments = folder_environments.with_state(params[:scope] || :available) - .order(:name) @folder = params[:id] respond_to do |format| format.html format.json do + folder_environments = project.environments.where(environment_type: params[:id]) + @environments = folder_environments.with_state(params[:scope] || :available) + .order(:name) + render json: { environments: serialize_environments(request, response), available_count: folder_environments.available.count, diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 8700d3c2198..06383d26133 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -6,6 +6,10 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle before_action :authorize_read_sentry_issue! before_action :set_issue_id, only: :details + before_action only: [:index] do + push_frontend_feature_flag(:integrated_error_tracking, project) + end + def index respond_to do |format| format.html @@ -75,7 +79,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle end def list_issues_params - params.permit(:search_term, :sort, :cursor, :issue_status) + params.permit(:search_term, :sort, :cursor, :issue_status).merge(tracking_event: :error_tracking_view_list) end def issue_update_params @@ -83,7 +87,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle end def issue_details_params - params.permit(:issue_id) + params.permit(:issue_id).merge(tracking_event: :error_tracking_view_details) end def set_issue_id diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 475c41eec9c..3208a5076e7 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -17,10 +17,6 @@ class Projects::ForksController < Projects::ApplicationController feature_category :source_code_management urgency :low, [:index] - before_action do - push_frontend_feature_flag(:fork_project_form, @project, default_enabled: :yaml) - end - def index @sort = forks_params[:sort] @@ -54,9 +50,7 @@ class Projects::ForksController < Projects::ApplicationController format.json do namespaces = load_namespaces_with_associations - [project.namespace] - namespaces = [current_user.namespace] + namespaces if - Feature.enabled?(:fork_project_form, project, default_enabled: :yaml) && - can_fork_to?(current_user.namespace) + namespaces = [current_user.namespace] + namespaces if can_fork_to?(current_user.namespace) render json: { namespaces: ForkNamespaceSerializer.new.represent( diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index f4a773a62f6..f293ec752ab 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -10,18 +10,25 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController private def admin_project_google_cloud! - access_denied! unless can?(current_user, :admin_project_google_cloud, project) + unless can?(current_user, :admin_project_google_cloud, project) + track_event('admin_project_google_cloud!', 'access_denied', 'invalid_user') + access_denied! + end end def google_oauth2_enabled! config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') if config.app_id.blank? || config.app_secret.blank? + track_event('google_oauth2_enabled!', 'access_denied', { reason: 'google_oauth2_not_configured', config: config }) access_denied! 'This GitLab instance not configured for Google Oauth2.' end end def feature_flag_enabled! - access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud, project) + unless Feature.enabled?(:incubation_5mp_google_cloud) + track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled') + access_denied! + end end def validate_gcp_token! @@ -53,9 +60,21 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] end - def handle_gcp_error(error, project) - Gitlab::ErrorTracking.track_exception(error, project_id: project.id) + def handle_gcp_error(action, error) + track_event(action, 'gcp_error', error) @js_data = { screen: 'gcp_error', error: error.to_s }.to_json render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error' end + + def track_event(action, label, property) + options = { label: label, project: project, user: current_user } + + if property.is_a?(String) + options[:property] = property + else + options[:extra] = property + end + + Gitlab::Tracking.event('Projects::GoogleCloud', action, **options) + end end diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index 1941eb8a5f9..4867d344c5a 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -9,6 +9,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base .new(project, current_user, params).execute if enable_cloud_run_response[:status] == :error + track_event('deployments#cloud_run', 'enable_cloud_run_error', enable_cloud_run_response) flash[:error] = enable_cloud_run_response[:message] redirect_to project_google_cloud_index_path(project) else @@ -17,15 +18,17 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base .new(project, current_user, params).execute if generate_pipeline_response[:status] == :error + track_event('deployments#cloud_run', 'generate_pipeline_error', generate_pipeline_response) flash[:error] = 'Failed to generate pipeline' redirect_to project_google_cloud_index_path(project) else cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name]) + track_event('deployments#cloud_run', 'cloud_run_success', cloud_run_mr_params) redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params) end end rescue Google::Apis::ClientError => error - handle_gcp_error(error, project) + handle_gcp_error('deployments#cloud_run', error) end def cloud_storage diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb new file mode 100644 index 00000000000..beeb91cfd80 --- /dev/null +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseController + # filtered list of GCP cloud run locations... + # ...that have domain mapping available + # Source https://cloud.google.com/run/docs/locations 2022-01-30 + AVAILABLE_REGIONS = %w[asia-east1 asia-northeast1 asia-southeast1 europe-north1 europe-west1 europe-west4 us-central1 us-east1 us-east4 us-west1].freeze + + def index + @google_cloud_path = project_google_cloud_index_path(project) + params = { per_page: 50 } + branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) + tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) + refs = (branches + tags).map(&:name) + js_data = { + screen: 'gcp_regions_form', + availableRegions: AVAILABLE_REGIONS, + refs: refs, + cancelPath: project_google_cloud_index_path(project) + } + @js_data = js_data.to_json + track_event('gcp_regions#index', 'form_render', js_data) + end + + def create + permitted_params = params.permit(:ref, :gcp_region) + response = GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) + track_event('gcp_regions#create', 'form_submit', response) + redirect_to project_google_cloud_index_path(project), notice: _('GCP region configured') + end +end diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb new file mode 100644 index 00000000000..03d1474707b --- /dev/null +++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::BaseController + before_action :validate_gcp_token! + + def create + google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + response = google_api_client.revoke_authorizations + + if response.success? + status = 'success' + redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') } + else + status = 'failed' + redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') } + end + + session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token) + track_event('revoke_oauth#create', 'create', status) + + redirect_to project_google_cloud_index_path(project), redirect_message + end +end diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index b5f2b658235..5d8b2030d5c 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -10,30 +10,41 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: if gcp_projects.empty? @js_data = { screen: 'no_gcp_projects' }.to_json + track_event('service_accounts#index', 'form_error', 'no_gcp_projects') render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects' else - @js_data = { + params = { per_page: 50 } + branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) + tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) + refs = (branches + tags).map(&:name) + js_data = { screen: 'service_accounts_form', gcpProjects: gcp_projects, - environments: project.environments, + refs: refs, cancelPath: project_google_cloud_index_path(project) - }.to_json + } + @js_data = js_data.to_json + + track_event('service_accounts#index', 'form_success', js_data) end rescue Google::Apis::ClientError => error - handle_gcp_error(error, project) + handle_gcp_error('service_accounts#index', error) end def create + permitted_params = params.permit(:gcp_project, :ref) + response = GoogleCloud::CreateServiceAccountsService.new( project, current_user, google_oauth2_token: token_in_session, - gcp_project_id: params[:gcp_project], - environment_name: params[:environment] + gcp_project_id: permitted_params[:gcp_project], + environment_name: permitted_params[:ref] ).execute + track_event('service_accounts#create', 'form_submit', response) redirect_to project_google_cloud_index_path(project), notice: response.message rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error - handle_gcp_error(error, project) + handle_gcp_error('service_accounts#create', error) end end diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb index 206a8c7e391..49bb4bec859 100644 --- a/app/controllers/projects/google_cloud_controller.rb +++ b/app/controllers/projects/google_cloud_controller.rb @@ -1,14 +1,34 @@ # frozen_string_literal: true class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController + GCP_REGION_CI_VAR_KEY = 'GCP_REGION' + def index - @js_data = { + js_data = { screen: 'home', serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, createServiceAccountUrl: project_google_cloud_service_accounts_path(project), enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project), enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project), - emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg') - }.to_json + emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), + configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), + gcpRegions: gcp_regions, + revokeOauthUrl: revoke_oauth_url + } + @js_data = js_data.to_json + track_event('google_cloud#index', 'index', js_data) + end + + private + + def gcp_regions + list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute + list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } } + end + + def revoke_oauth_url + google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil end end diff --git a/app/controllers/projects/harbor/application_controller.rb b/app/controllers/projects/harbor/application_controller.rb new file mode 100644 index 00000000000..e6e694783fa --- /dev/null +++ b/app/controllers/projects/harbor/application_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Projects + module Harbor + class ApplicationController < Projects::ApplicationController + layout 'project' + + before_action :harbor_registry_enabled! + before_action do + push_frontend_feature_flag(:harbor_registry_integration) + end + + feature_category :integrations + + private + + def harbor_registry_enabled! + render_404 unless Feature.enabled?(:harbor_registry_integration) + end + end + end +end diff --git a/app/controllers/projects/harbor/repositories_controller.rb b/app/controllers/projects/harbor/repositories_controller.rb new file mode 100644 index 00000000000..dd3e3dc1978 --- /dev/null +++ b/app/controllers/projects/harbor/repositories_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Projects + module Harbor + class RepositoriesController < ::Projects::Harbor::ApplicationController + def show + render :index + end + end + end +end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 3395e75666e..293581a6744 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -6,6 +6,11 @@ class Projects::IncidentsController < Projects::ApplicationController before_action :authorize_read_issue! before_action :load_incident, only: [:show] + before_action do + push_frontend_feature_flag(:incident_escalations, @project) + push_frontend_feature_flag(:incident_timeline_event_tab, @project, default_enabled: :yaml) + push_licensed_feature(:incident_timeline_events) if @project.licensed_feature_available?(:incident_timeline_events) + end feature_category :incident_management @@ -43,3 +48,5 @@ class Projects::IncidentsController < Projects::ApplicationController IssueSerializer.new(current_user: current_user, project: incident.project) end end + +Projects::IncidentsController.prepend_mod diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 1b98810b09b..d4474b9d5a3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -36,11 +36,6 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_import_issues!, only: [:import_csv] before_action :authorize_download_code!, only: [:related_branches] - # Limit the amount of issues created per minute - before_action -> { check_rate_limit!(:issues_create, scope: [@project, @current_user])}, - only: [:create], - if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) } - before_action do push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml) @@ -50,12 +45,10 @@ class Projects::IssuesController < Projects::ApplicationController end before_action only: :show do - push_frontend_feature_flag(:real_time_issue_sidebar, project, default_enabled: :yaml) push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, project, default_enabled: :yaml) push_frontend_feature_flag(:paginated_issue_discussions, project, default_enabled: :yaml) - push_frontend_feature_flag(:fix_comment_scroll, project, default_enabled: :yaml) - push_frontend_feature_flag(:work_items, project, default_enabled: :yaml) + push_frontend_feature_flag(:work_items, project&.group, default_enabled: :yaml) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -79,13 +72,16 @@ class Projects::IssuesController < Projects::ApplicationController attr_accessor :vulnerability_id def index - set_issuables_index if !html_request? || Feature.disabled?(:vue_issues_list, project&.group, default_enabled: :yaml) - - @issues = @issuables + if html_request? && Feature.enabled?(:vue_issues_list, project&.group, default_enabled: :yaml) + set_sort_order + else + set_issuables_index + @issues = @issuables + end respond_to do |format| format.html - format.atom { render layout: 'xml.atom' } + format.atom { render layout: 'xml' } format.json do render json: { html: view_to_html_string("projects/issues/_issues"), @@ -112,6 +108,8 @@ class Projects::IssuesController < Projects::ApplicationController @issue = @noteable = service.execute + @add_related_issue = add_related_issue + @merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of if params[:discussion_to_resolve] @@ -128,6 +126,7 @@ class Projects::IssuesController < Projects::ApplicationController def create create_params = issue_params.merge( + add_related_issue: add_related_issue, merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve] ) @@ -150,7 +149,7 @@ class Projects::IssuesController < Projects::ApplicationController redirect_to project_issue_path(@project, @issue) else # NOTE: this CAPTCHA support method is indirectly included via IssuableActions - with_captcha_check_html_format { render :new } + with_captcha_check_html_format(spammable: spammable) { render :new } end end @@ -383,6 +382,11 @@ class Projects::IssuesController < Projects::ApplicationController action_name == 'service_desk' end + def add_related_issue + add_related_issue = project.issues.find_by_iid(params[:add_related_issue]) + add_related_issue if Ability.allowed?(current_user, :read_issue, add_related_issue) + end + # Overridden in EE def create_vulnerability_issue_feedback(issue); end end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index bfc2fe6432d..b0f032a01e5 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -4,6 +4,8 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload include ContinueParams + urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :status, :erase, :raw] + before_action :find_job_as_build, except: [:index, :play, :show] before_action :find_job_as_processable, only: [:play, :show] before_action :authorize_read_build_trace!, only: [:trace, :raw] diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 9bc9c19157a..0dcc2bc3181 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -111,9 +111,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic allow_tree_conflicts: display_merge_conflicts_in_diff? ) - if @merge_request.project.context_commits_enabled? - options[:context_commits] = @merge_request.recent_context_commits - end + options[:context_commits] = @merge_request.recent_context_commits render json: DiffsSerializer.new(request).represent(diffs, options) end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6445f920db5..60d7920f83e 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -30,14 +30,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] - before_action only: [:index, :show] do - push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml) - end before_action only: [:show] do push_frontend_feature_flag(:file_identifier_hash) push_frontend_feature_flag(:merge_request_widget_graphql, project, default_enabled: :yaml) - push_frontend_feature_flag(:default_merge_ref_for_diffs, project, default_enabled: :yaml) push_frontend_feature_flag(:core_security_mr_widget_counts, project) push_frontend_feature_flag(:paginated_notes, project, default_enabled: :yaml) push_frontend_feature_flag(:confidential_notes, project, default_enabled: :yaml) @@ -45,8 +41,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml) push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml) - push_frontend_feature_flag(:rearrange_pipelines_table, project, default_enabled: :yaml) push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml) + push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml) + push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) @@ -87,7 +84,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :ci_environments_status, :destroy, :rebase, - :discussions + :discussions, + :pipelines ] def index @@ -95,7 +93,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo respond_to do |format| format.html - format.atom { render layout: 'xml.atom' } + format.atom { render layout: 'xml' } format.json do render json: { html: view_to_html_string("projects/merge_requests/_merge_requests") @@ -220,8 +218,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def context_commits - return render_404 unless project.context_commits_enabled? - # Get commits from repository # or from cache if already merged commits = ContextCommitsFinder.new(project, @merge_request, { search: params[:search], limit: params[:limit], offset: params[:offset] }).execute @@ -553,12 +549,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def endpoint_metadata_url(project, merge_request) - params = request.query_parameters - params[:view] = "inline" - - if Feature.enabled?(:default_merge_ref_for_diffs, project, default_enabled: :yaml) - params = params.merge(diff_head: true) - end + params = request.query_parameters.merge(view: 'inline', diff_head: true) diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params) end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index ac94cc001dd..271c31b6429 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -10,6 +10,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] + before_action do + push_frontend_feature_flag(:pipeline_schedules_with_tags, @project, default_enabled: :yaml) + end + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/pipelines/stages_controller.rb b/app/controllers/projects/pipelines/stages_controller.rb index ce08b49ce9f..0447bbf29e7 100644 --- a/app/controllers/projects/pipelines/stages_controller.rb +++ b/app/controllers/projects/pipelines/stages_controller.rb @@ -5,6 +5,10 @@ module Projects class StagesController < Projects::Pipelines::ApplicationController before_action :authorize_update_pipeline! + urgency :low, [ + :play_manual + ] + def play_manual ::Ci::PlayManualStageService .new(@project, current_user, pipeline: pipeline) diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index 25ec7ab1335..602fc02686a 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -42,9 +42,9 @@ module Projects end def test_suite - suite = builds.map do |build| + suite = builds.sum do |build| build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) - end.sum + end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load! diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 7f680bbf121..8279bb20769 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -4,6 +4,9 @@ class Projects::PipelinesController < Projects::ApplicationController include ::Gitlab::Utils::StrongMemoize include RedisTracking + urgency :default, [:status] + urgency :low, [:index, :new, :builds, :show, :failures, :create, :stage, :retry, :dag, :cancel] + before_action :disable_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables] before_action :set_pipeline_path, only: [:show] @@ -13,13 +16,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] - before_action do - push_frontend_feature_flag(:rearrange_pipelines_table, project, default_enabled: :yaml) - end - - before_action do - push_frontend_feature_flag(:jobs_tab_vue, @project, default_enabled: :yaml) - end # 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? } @@ -55,8 +51,7 @@ class Projects::PipelinesController < Projects::ApplicationController respond_to do |format| format.html do - enable_code_quality_walkthrough_experiment - enable_ci_runner_templates_experiment + enable_runners_availability_section_experiment end format.json do Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) @@ -166,14 +161,20 @@ class Projects::PipelinesController < Projects::ApplicationController end def retry - ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker + # Check for access before execution to allow for async execution while still returning access results + access_response = ::Ci::RetryPipelineService.new(@project, current_user).check_access(pipeline) + + if access_response.error? + response = { json: { errors: [access_response.message] }, status: access_response.http_status } + else + response = { json: {}, status: :no_content } + ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker + end respond_to do |format| - format.html do - redirect_back_or_default default: project_pipelines_path(project) + format.json do + render response end - - format.json { head :no_content } end end @@ -224,7 +225,7 @@ class Projects::PipelinesController < Projects::ApplicationController PipelineSerializer .new(project: @project, current_user: @current_user) .with_pagination(request, response) - .represent(@pipelines, disable_coverage: true, preload: true, code_quality_walkthrough: params[:code_quality_walkthrough].present?) + .represent(@pipelines, disable_coverage: true, preload: true) end def render_show @@ -309,28 +310,13 @@ class Projects::PipelinesController < Projects::ApplicationController params.permit(:scope, :username, :ref, :status, :source) end - def enable_code_quality_walkthrough_experiment - experiment(:code_quality_walkthrough, namespace: project.root_ancestor) do |e| - e.exclude! unless current_user - e.exclude! unless can?(current_user, :create_pipeline, project) - e.exclude! unless project.root_ancestor.recent? - e.exclude! if @pipelines_count.to_i > 0 - e.exclude! if helpers.has_gitlab_ci?(project) - - e.control {} - e.candidate {} - e.publish_to_database - end - end - - def enable_ci_runner_templates_experiment - experiment(:ci_runner_templates, namespace: project.root_ancestor) do |e| - e.exclude! unless current_user - e.exclude! unless can?(current_user, :create_pipeline, project) - e.exclude! if @pipelines_count.to_i > 0 - e.exclude! if helpers.has_gitlab_ci?(project) + def enable_runners_availability_section_experiment + return unless current_user + return unless can?(current_user, :create_pipeline, project) + return if @pipelines_count.to_i > 0 + return if helpers.has_gitlab_ci?(project) - e.control {} + experiment(:runners_availability_section, namespace: project.root_ancestor) do |e| e.candidate {} e.publish_to_database end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index dc0614c6bdd..0279a65f262 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -13,8 +13,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController def index @sort = params[:sort].presence || sort_value_name - @skip_groups = @project.related_group_ids - @group_links = @project.project_group_links @group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present? @@ -24,25 +22,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController end @project_members = present_members(non_invited_members.page(params[:page])) - - @project_member = @project.project_members.new - end - - def import - @projects = Project.visible_to_user_and_access_level(current_user, Gitlab::Access::MAINTAINER).order_id_desc - end - - def apply_import - source_project = Project.find(params[:source_project_id]) - - if can?(current_user, :admin_project_member, source_project) - status = @project.team.import(source_project, current_user) - notice = status ? "Successfully imported" : "Import failed" - else - return render_404 - end - - redirect_to(project_project_members_path(project), notice: notice) end # MembershipActions concern diff --git a/app/controllers/projects/redirect_controller.rb b/app/controllers/projects/redirect_controller.rb new file mode 100644 index 00000000000..6bcbe87ee42 --- /dev/null +++ b/app/controllers/projects/redirect_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Projects::RedirectController is used to resolve the route projects/:id. +# It's helpful for this to be in its own controller so that the +# ProjectsController can assume that :namespace_id exists +class Projects::RedirectController < ::ApplicationController + skip_before_action :authenticate_user! + + feature_category :projects + + def redirect_from_id + project = Project.find(params[:id]) + + if can?(current_user, :read_project, project) + redirect_to project + else + render_404 + end + end +end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 7fba6cc5bf4..1a2baf96020 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -7,6 +7,7 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :authorize_read_release! before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new + before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink before_action only: :index do push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml) end @@ -26,10 +27,24 @@ class Projects::ReleasesController < Projects::ApplicationController redirect_to link.url end + def latest_permalink + unless @latest_tag.present? + return render_404 + end + + query_parameters_except_order_by = request.query_parameters.except(:order_by) + + redirect_url = project_release_url(@project, @latest_tag) + redirect_url += "/#{params[:suffix_path]}" if params[:suffix_path] + redirect_url += "?#{query_parameters_except_order_by.compact.to_param}" if query_parameters_except_order_by.present? + + redirect_to redirect_url + end + private - def releases - ReleasesFinder.new(@project, current_user).execute + def releases(params = {}) + ReleasesFinder.new(@project, current_user, params).execute end def authorize_update_release! @@ -51,4 +66,18 @@ class Projects::ReleasesController < Projects::ApplicationController def sanitized_tag_name CGI.unescape(params[:tag]) end + + # Default order_by is 'released_at', which is set in ReleasesFinder. + # Also if the passed order_by is invalid, we reject and default to 'released_at'. + def fetch_latest_tag + allowed_values = ['released_at'] + + params.reject! { |key, value| key.to_sym == :order_by && !allowed_values.any?(value) } + + @latest_tag = releases(order_by: params[:order_by]).first&.tag + end + + def validate_suffix_path + Gitlab::Utils.check_path_traversal!(params[:suffix_path]) if params[:suffix_path] + end end diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index 39db7618db0..b77ce070492 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -14,7 +14,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController path = project_runners_path(project) - if @runner.assign_to(project, current_user) + if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute redirect_to path, notice: s_('Runners|Runner assigned to project.') else assign_to_messages = @runner.errors.messages[:assign_to] @@ -26,7 +26,8 @@ class Projects::RunnerProjectsController < Projects::ApplicationController def destroy runner_project = project.runner_projects.find(params[:id]) - runner_project.destroy + + ::Ci::Runners::UnassignRunnerService.new(runner_project, current_user).execute redirect_to project_runners_path(project), status: :found, notice: s_('Runners|Runner unassigned from project.') end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index 192a29730d9..0eda8e3352d 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -14,7 +14,7 @@ class Projects::RunnersController < Projects::ApplicationController end def update - if Ci::UpdateRunnerService.new(@runner).update(runner_params) + if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params) redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.') else render 'edit' @@ -23,14 +23,14 @@ class Projects::RunnersController < Projects::ApplicationController def destroy if @runner.only_for?(project) - Ci::UnregisterRunnerService.new(@runner).execute + Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute end redirect_to project_runners_path(@project), status: :found end def resume - if Ci::UpdateRunnerService.new(@runner).update(active: true) + if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true) redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.') else redirect_to project_runners_path(@project), alert: _('Runner was not updated.') @@ -38,7 +38,7 @@ class Projects::RunnersController < Projects::ApplicationController end def pause - if Ci::UpdateRunnerService.new(@runner).update(active: false) + if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false) redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.') else redirect_to project_runners_path(@project), alert: _('Runner was not updated.') diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 3fc379a135a..b6f77a6d515 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -3,6 +3,7 @@ module Projects module Serverless class FunctionsController < Projects::ApplicationController + before_action :ensure_feature_enabled! before_action :authorize_read_cluster! feature_category :not_owned @@ -69,6 +70,10 @@ module Projects def serialize_function(function) Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function) end + + def ensure_feature_enabled! + render_404 unless Feature.enabled?(:deprecated_serverless, project, default_enabled: :yaml, type: :ops) + end end end end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 1321111faaf..105f8efde7b 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -13,6 +13,10 @@ class Projects::ServicesController < Projects::ApplicationController before_action :set_deprecation_notice_for_prometheus_integration, only: [:edit, :update] before_action :redirect_deprecated_prometheus_integration, only: [:update] + before_action do + push_frontend_feature_flag(:integration_form_sections, project, default_enabled: :yaml) + end + respond_to :html layout "project_settings" diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index dd2fb57f7ac..3f4d26bb6ec 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -64,7 +64,7 @@ module Projects end def reset_registration_token - @project.reset_runners_token! + ::Ci::Runners::ResetRegistrationTokenService.new(@project, current_user).execute flash[:toast] = _("New runners registration token has been generated!") redirect_to namespace_project_settings_ci_cd_path diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 56e201c592f..43c72b358db 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -7,6 +7,10 @@ module Projects before_action :authorize_admin_operations! before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token] + before_action do + push_frontend_feature_flag(:integrated_error_tracking, project) + end + respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token] helper_method :error_tracking_setting diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 6472d3c3454..eb3579551bd 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -42,7 +42,7 @@ class Projects::TagsController < Projects::ApplicationController status = @tags_loading_error ? :service_unavailable : :ok format.html { render status: status } - format.atom { render layout: 'xml.atom', status: status } + format.atom { render layout: 'xml', status: status } end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 4f905a2d565..e447fc3f3fe 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -22,7 +22,6 @@ class Projects::TreeController < Projects::ApplicationController push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) - push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) end feature_category :source_code_management diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 519d9cd0d52..507a8b66942 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -17,10 +17,10 @@ class ProjectsController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching, only: [:index, :show] before_action :disable_query_limiting, only: [:show, :create] - before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve, :unfoldered_environment_names] + before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :unfoldered_environment_names] before_action :redirect_git_extension, only: [:show] - before_action :project, except: [:index, :new, :create, :resolve] - before_action :repository, except: [:index, :new, :create, :resolve] + before_action :project, except: [:index, :new, :create] + before_action :repository, except: [:index, :new, :create] before_action :verify_git_import_enabled, only: [:create] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] @@ -41,7 +41,6 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml) push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) - push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) push_frontend_feature_flag(:work_items, @project, default_enabled: :yaml) end @@ -49,7 +48,7 @@ class ProjectsController < Projects::ApplicationController feature_category :projects, [ :index, :show, :new, :create, :edit, :update, :transfer, - :destroy, :resolve, :archive, :unarchive, :toggle_star, :activity + :destroy, :archive, :unarchive, :toggle_star, :activity ] feature_category :source_code_management, [:remove_fork, :housekeeping, :refs] @@ -174,7 +173,7 @@ class ProjectsController < Projects::ApplicationController format.atom do load_events @events = @events.select { |event| event.visible_to_user?(current_user) } - render layout: 'xml.atom' + render layout: 'xml' end end end @@ -325,16 +324,6 @@ class ProjectsController < Projects::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord - def resolve - @project = Project.find(params[:id]) - - if can?(current_user, :read_project, @project) - redirect_to @project - else - render_404 - end - end - def unfoldered_environment_names respond_to do |format| format.json do @@ -346,11 +335,7 @@ class ProjectsController < Projects::ApplicationController private def refs_params - if Feature.enabled?(:strong_parameters_for_project_controller, @project, default_enabled: :yaml) - params.permit(:search, :sort, :ref, find: []) - else - params - end + params.permit(:search, :sort, :ref, find: []) end # Render project landing depending of which features are available diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 057c451ace2..7011bf856e3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -15,7 +15,7 @@ class RegistrationsController < Devise::RegistrationsController before_action :load_recaptcha, only: :new before_action :set_invite_params, only: :new before_action only: [:create] do - check_rate_limit!(:user_sign_up, scope: request.ip) if Feature.enabled?(:rate_limit_user_sign_up_endpoint, default_enabled: :yaml) + check_rate_limit!(:user_sign_up, scope: request.ip) end before_action only: [:new] do diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index e38eeaed367..817da658f14 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -4,6 +4,7 @@ class SearchController < ApplicationController include ControllerWithCrossProjectAccessCheck include SearchHelper include RedisTracking + include SearchRateLimitable RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze @@ -17,7 +18,7 @@ class SearchController < ApplicationController search_term_present = params[:search].present? || params[:term].present? search_term_present && !params[:project_id].present? end - before_action :check_email_search_rate_limit!, only: [:show, :count, :autocomplete] + before_action :check_search_rate_limit!, only: [:show, :count, :autocomplete] rescue_from ActiveRecord::QueryCanceled, with: :render_timeout @@ -25,6 +26,7 @@ class SearchController < ApplicationController feature_category :global_search urgency :high, [:opensearch] + urgency :low, [:count] def show @project = search_service.project @@ -201,12 +203,6 @@ class SearchController < ApplicationController render status: :request_timeout end end - - def check_email_search_rate_limit! - return unless search_service.params.email_lookup? - - check_rate_limit!(:user_email_lookup, scope: [current_user]) - end end SearchController.prepend_mod_with('SearchController') diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index d7eb3ccd274..4df0ef78907 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -40,30 +40,29 @@ class UploadsController < ApplicationController upload_model_class.find(params[:id]) end - def authorize_access! - authorized = - case model - when Note - can?(current_user, :read_project, model.project) - when Snippet, ProjectSnippet - can?(current_user, :read_snippet, model) - when User - # We validate the current user has enough (writing) - # access to itself when a secret is given. - # For instance, user avatars are readable by anyone, - # while temporary, user snippet uploads are not. - !secret? || can?(current_user, :update_user, model) - when Appearance - true - when Projects::Topic - true - else - permission = "read_#{model.class.underscore}".to_sym - - can?(current_user, permission, model) - end + def authorized? + case model + when Note + can?(current_user, :read_project, model.project) + when Snippet, ProjectSnippet + can?(current_user, :read_snippet, model) + when User + # We validate the current user has enough (writing) + # access to itself when a secret is given. + # For instance, user avatars are readable by anyone, + # while temporary, user snippet uploads are not. + !secret? || can?(current_user, :update_user, model) + when Appearance + true + when Projects::Topic + true + else + can?(current_user, "read_#{model.class.underscore}".to_sym, model) + end + end - render_unauthorized unless authorized + def authorize_access! + render_unauthorized unless authorized? end def authorize_create_access! diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f6cef7e133c..dc02e4a3e87 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -24,7 +24,7 @@ class UsersController < ApplicationController before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following] before_action only: [:exists] do - check_rate_limit!(:username_exists, scope: request.ip) if Feature.enabled?(:rate_limit_username_exists_endpoint, default_enabled: :yaml) + check_rate_limit!(:username_exists, scope: request.ip) end feature_category :users @@ -35,7 +35,7 @@ class UsersController < ApplicationController format.atom do load_events - render layout: 'xml.atom' + render layout: 'xml' end format.json do diff --git a/app/events/repositories/keep_around_refs_created_event.rb b/app/events/repositories/keep_around_refs_created_event.rb new file mode 100644 index 00000000000..2ac499e6e21 --- /dev/null +++ b/app/events/repositories/keep_around_refs_created_event.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Repositories + class KeepAroundRefsCreatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' } + } + } + end + end +end diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index f6af7ca15bb..f74e7fe3b1d 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -1,19 +1,7 @@ # frozen_string_literal: true class ApplicationExperiment < Gitlab::Experiment - def publish(_result = nil) - super - - publish_to_client - end - - def publish_to_client - return unless should_track? - - Gon.push({ experiment: { name => signature } }, true) - rescue NoMethodError - # means we're not in the request cycle, and can't add to Gon. Log a warning maybe? - end + control { nil } # provide a default control for anonymous experiments def publish_to_database ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore') diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb index 576e10815aa..38295cec0d3 100644 --- a/app/experiments/combined_registration_experiment.rb +++ b/app/experiments/combined_registration_experiment.rb @@ -3,6 +3,9 @@ class CombinedRegistrationExperiment < ApplicationExperiment include Rails.application.routes.url_helpers + control { new_users_sign_up_group_path } + candidate { new_users_sign_up_groups_project_path } + def key_for(source, _ = nil) super(source, 'force_company_trial') end @@ -10,12 +13,4 @@ class CombinedRegistrationExperiment < ApplicationExperiment def redirect_path run end - - def control_behavior - new_users_sign_up_group_path - end - - def candidate_behavior - new_users_sign_up_groups_project_path - end end diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb index 6567ec0b3f1..78602874cb7 100644 --- a/app/experiments/in_product_guidance_environments_webide_experiment.rb +++ b/app/experiments/in_product_guidance_environments_webide_experiment.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment - exclude :has_environments? + control { false } - def control_behavior - false - end + exclude :has_environments? private diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb index ee9d0dc1700..4aca4c875b2 100644 --- a/app/experiments/new_project_sast_enabled_experiment.rb +++ b/app/experiments/new_project_sast_enabled_experiment.rb @@ -1,21 +1,15 @@ # frozen_string_literal: true class NewProjectSastEnabledExperiment < ApplicationExperiment - def publish(_result = nil) + control { } + variant(:candidate) { } + variant(:free_indicator) { } + variant(:unchecked_candidate) { } + variant(:unchecked_free_indicator) { } + + def publish(*args) super publish_to_database end - - def candidate_behavior - end - - def free_indicator_behavior - end - - def unchecked_candidate_behavior - end - - def unchecked_free_indicator_behavior - end end diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb index 0c47f5d183c..cb667c6ae60 100644 --- a/app/experiments/require_verification_for_namespace_creation_experiment.rb +++ b/app/experiments/require_verification_for_namespace_creation_experiment.rb @@ -1,18 +1,13 @@ # frozen_string_literal: true class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment + control { false } + candidate { true } + exclude :existing_user EXPERIMENT_START_DATE = Date.new(2022, 1, 31) - def control_behavior - false - end - - def candidate_behavior - true - end - def candidate? run end diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb index bcb9d64fcb7..51b81be672d 100644 --- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb +++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment + control { } + candidate { } + def publish(_result = nil) super publish_to_database end - - # This is a purely client side experiment, and since we don't have a nicer - # way to define variants yet, we define them here. - def candidate_behavior - end end diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index 53dbf65c43a..fc18bb1984a 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -69,7 +69,7 @@ class Admin::ProjectsFinder end def sort(items) - sort = params.fetch(:sort) { 'latest_activity_desc' } + sort = params.fetch(:sort, 'latest_activity_desc') items.sort_by_attribute(sort) end end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index fff17098c7b..4213a3f1965 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -60,6 +60,8 @@ class GroupMembersFinder < UnionFinder members = members.filter_by_2fa(params[:two_factor]) end + members = apply_additional_filters(members) + by_created_at(members) end @@ -84,6 +86,11 @@ class GroupMembersFinder < UnionFinder raise ArgumentError, "#{(include_relations - RELATIONS).first} #{INVALID_RELATION_TYPE_ERROR_MSG}" end end + + def apply_additional_filters(members) + # overridden in EE to include additional filtering conditions. + members + end end GroupMembersFinder.prepend_mod_with('GroupMembersFinder') diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 3e436f30971..bf7b2265ded 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -46,6 +46,7 @@ class IssuableFinder requires_cross_project_access unless: -> { params.project? } + FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze attr_accessor :current_user, :params @@ -331,6 +332,8 @@ class IssuableFinder return items if items.is_a?(ActiveRecord::NullRelation) return items if Feature.enabled?(:disable_anonymous_search, type: :ops) && current_user.nil? + return items.pg_full_text_search(search) if use_full_text_search? + if use_cte_for_search? cte = Gitlab::SQL::CTE.new(klass.table_name, items) @@ -341,6 +344,13 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + def use_full_text_search? + params[:in].blank? && + klass.try(:pg_full_text_searchable_columns).present? && + params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX && + Feature.enabled?(:issues_full_text_search, params.project || params.group, default_enabled: :yaml) + end + # rubocop: disable CodeReuse/ActiveRecord def by_iids(items) params[:iids].present? ? items.where(iid: params[:iids]) : items diff --git a/app/finders/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb index 509370b49a8..babff65cc37 100644 --- a/app/finders/pending_todos_finder.rb +++ b/app/finders/pending_todos_finder.rb @@ -27,7 +27,8 @@ class PendingTodosFinder todos = by_target_id(todos) todos = by_target_type(todos) todos = by_discussion(todos) - by_commit_id(todos) + todos = by_commit_id(todos) + by_action(todos) end def by_project(todos) @@ -69,4 +70,10 @@ class PendingTodosFinder todos end end + + def by_action(todos) + return todos if params[:action].blank? + + todos.for_action(params[:action]) + end end diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index 4a6eed8f5ee..be266045951 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -17,6 +17,7 @@ class PersonalAccessTokensFinder tokens = by_users(tokens) tokens = by_impersonation(tokens) tokens = by_state(tokens) + tokens = by_owner_type(tokens) sort(tokens) end @@ -32,6 +33,15 @@ class PersonalAccessTokensFinder tokens end + def by_owner_type(tokens) + case @params[:owner_type] + when 'human' + tokens.owner_is_human + else + tokens + end + end + def by_user(tokens) return tokens unless @params[:user] diff --git a/app/finders/projects/members/effective_access_level_finder.rb b/app/finders/projects/members/effective_access_level_finder.rb index 4538fc4c855..90474aba02c 100644 --- a/app/finders/projects/members/effective_access_level_finder.rb +++ b/app/finders/projects/members/effective_access_level_finder.rb @@ -40,7 +40,7 @@ module Projects avenues = [authorizable_project_members] avenues << if project.personal? - project_owner_acting_as_maintainer + project_owner else authorizable_group_members end @@ -85,9 +85,11 @@ module Projects Member.from_union(members) end - def project_owner_acting_as_maintainer + # workaround until we migrate Project#owners to have membership with + # OWNER access level + def project_owner user_id = project.namespace.owner.id - access_level = Gitlab::Access::MAINTAINER + access_level = Gitlab::Access::OWNER Member .from(generate_from_statement([[user_id, access_level]])) # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb index 7c3abc27cf7..c26b166a786 100644 --- a/app/finders/projects/topics_finder.rb +++ b/app/finders/projects/topics_finder.rb @@ -12,7 +12,7 @@ module Projects end def execute - topics = Projects::Topic.order_by_total_projects_count + topics = Projects::Topic.order_by_non_private_projects_count by_search(topics) end diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb new file mode 100644 index 00000000000..d87ba8c0b03 --- /dev/null +++ b/app/finders/releases/group_releases_finder.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +module Releases + ## + # The GroupReleasesFinder does not support all the options of ReleasesFinder + # due to use of InOperatorOptimization for finding subprojects/subgroups + # + # order_by - only ordering by released_at is supported + # filter by tag - currently not supported + class GroupReleasesFinder + include Gitlab::Utils::StrongMemoize + + attr_reader :parent, :current_user, :params + + def initialize(parent, current_user = nil, params = {}) + @parent = parent + @current_user = current_user + @params = params + + params[:order_by] ||= 'released_at' + params[:sort] ||= 'desc' + params[:page] ||= 0 + params[:per] ||= 30 + end + + def execute(preload: true) + return Release.none unless Ability.allowed?(current_user, :read_release, parent) + + releases = get_releases(preload: preload) + + paginate_releases(releases) + end + + private + + def include_subgroups? + params.fetch(:include_subgroups, false) + end + + def accessible_projects_scope + if include_subgroups? + Project.for_group_and_its_subgroups(parent) + else + parent.projects + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def get_releases(preload: true) + Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( + scope: releases_scope(preload: preload), + array_scope: accessible_projects_scope.select(:id), + array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) }, + finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) } + ) + .execute + end + + def releases_scope(preload: true) + scope = Release.all + scope = order_releases(scope) + scope = scope.preloaded if preload + scope + end + + def order_releases(scope) + scope.sort_by_attribute("released_at_#{params[:sort]}").order(id: params[:sort]) + end + + def paginate_releases(releases) + releases.page(params[:page].to_i).per(params[:per]) + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/graphql/mutations/ci/pipeline/retry.rb b/app/graphql/mutations/ci/pipeline/retry.rb index ee93f99703e..895397a96ab 100644 --- a/app/graphql/mutations/ci/pipeline/retry.rb +++ b/app/graphql/mutations/ci/pipeline/retry.rb @@ -17,10 +17,11 @@ module Mutations pipeline = authorized_find!(id: id) project = pipeline.project - ::Ci::RetryPipelineService.new(project, current_user).execute(pipeline) + service_response = ::Ci::RetryPipelineService.new(project, current_user).execute(pipeline) + { pipeline: pipeline, - errors: errors_on_object(pipeline) + errors: errors_on_object(pipeline) + service_response.errors } end end diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb index 21c3d55881c..1713ec0bf6d 100644 --- a/app/graphql/mutations/ci/runner/delete.rb +++ b/app/graphql/mutations/ci/runner/delete.rb @@ -17,20 +17,11 @@ module Mutations def resolve(id:, **runner_attrs) runner = authorized_find!(id) - error = authenticate_delete_runner!(runner) - return { errors: [error] } if error - - ::Ci::UnregisterRunnerService.new(runner).execute + ::Ci::Runners::UnregisterRunnerService.new(runner, current_user).execute { errors: runner.errors.full_messages } end - def authenticate_delete_runner!(runner) - return if current_user.can_admin_all_resources? - - "Runner #{runner.to_global_id} associated with more than one project" if runner.runner_projects.count > 1 - end - def find_object(id) # TODO: remove this line when the compatibility layer is removed # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb index e6123b4283a..3432840f60f 100644 --- a/app/graphql/mutations/ci/runner/update.rb +++ b/app/graphql/mutations/ci/runner/update.rb @@ -53,7 +53,7 @@ module Mutations def resolve(id:, **runner_attrs) runner = authorized_find!(id) - unless ::Ci::UpdateRunnerService.new(runner).update(runner_attrs) + unless ::Ci::Runners::UpdateRunnerService.new(runner).update(runner_attrs) return { runner: nil, errors: runner.errors.full_messages } end diff --git a/app/graphql/mutations/ci/runners_registration_token/reset.rb b/app/graphql/mutations/ci/runners_registration_token/reset.rb index 7976e8fb70d..29ef7aa2e81 100644 --- a/app/graphql/mutations/ci/runners_registration_token/reset.rb +++ b/app/graphql/mutations/ci/runners_registration_token/reset.rb @@ -45,20 +45,19 @@ module Mutations def reset_token(type:, **args) id = args[:id] + scope = nil case type when 'instance_type' raise Gitlab::Graphql::Errors::ArgumentError, "id must not be specified for '#{type}' scope" if id.present? - authorize!(:global) - - ApplicationSetting.current.reset_runners_registration_token! - ApplicationSetting.current_without_cache.runners_registration_token + scope = ApplicationSetting.current + authorize!(scope) when 'group_type', 'project_type' - project_or_group = authorized_find!(type: type, id: id) - project_or_group.reset_runners_token! - project_or_group.runners_token + scope = authorized_find!(type: type, id: id) end + + ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute if scope end end end diff --git a/app/graphql/mutations/concerns/mutations/spam_protection.rb b/app/graphql/mutations/concerns/mutations/spam_protection.rb index 341067710b2..e61f66c02a5 100644 --- a/app/graphql/mutations/concerns/mutations/spam_protection.rb +++ b/app/graphql/mutations/concerns/mutations/spam_protection.rb @@ -16,30 +16,16 @@ module Mutations private - def spam_action_response(object) - fields = spam_action_response_fields(object) - - # If the SpamActionService detected something as spam, - # this is non-recoverable and the needs_captcha_response - # should not be considered - kind = if fields[:spam] - :spam - elsif fields[:needs_captcha_response] - :needs_captcha_response - end - - [kind, fields] - end - def check_spam_action_response!(object) - kind, fields = spam_action_response(object) + fields = spam_action_response_fields(object) - case kind - when :needs_captcha_response + if fields[:spam] + # If the SpamActionService detected something as spam, this is non-recoverable and the + # needs_captcha_response and other CAPTCHA-related fields should not be returned + raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true }) + elsif fields[:needs_captcha_response] fields.delete :spam raise NeedsCaptchaResponseError.new(NEEDS_CAPTCHA_RESPONSE_MESSAGE, extensions: fields) - when :spam - raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true }) else nil end diff --git a/app/graphql/mutations/notes/base.rb b/app/graphql/mutations/notes/base.rb index d6c8121eee7..65bb9e4644c 100644 --- a/app/graphql/mutations/notes/base.rb +++ b/app/graphql/mutations/notes/base.rb @@ -3,6 +3,12 @@ module Mutations module Notes class Base < BaseMutation + QUICK_ACTION_ONLY_WARNING = <<~NB + If the body of the Note contains only quick actions, + the Note will be destroyed during an update, and no Note will be + returned. + NB + field :note, Types::Notes::NoteType, null: true, diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb index 5a5d62a8c20..1cfc11c6b11 100644 --- a/app/graphql/mutations/notes/create/note.rb +++ b/app/graphql/mutations/notes/create/note.rb @@ -5,12 +5,18 @@ module Mutations module Create class Note < Base graphql_name 'CreateNote' + description "Creates a Note.\n#{QUICK_ACTION_ONLY_WARNING}" argument :discussion_id, ::Types::GlobalIDType[::Discussion], required: false, description: 'Global ID of the discussion this note is in reply to.' + argument :merge_request_diff_head_sha, + GraphQL::Types::String, + required: false, + description: 'SHA of the head commit which is used to ensure that the merge request has not been updated since the request was sent.' + private def create_note_params(noteable, args) @@ -28,7 +34,8 @@ module Mutations end super(noteable, args).merge({ - in_reply_to_discussion_id: discussion_id + in_reply_to_discussion_id: discussion_id, + merge_request_diff_head_sha: args[:merge_request_diff_head_sha] }) end diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb index 2dfa7b815a1..4c6df2776cc 100644 --- a/app/graphql/mutations/notes/update/base.rb +++ b/app/graphql/mutations/notes/update/base.rb @@ -6,12 +6,6 @@ module Mutations # This is a Base class for the Note update mutations and is not # mounted as a GraphQL mutation itself. class Base < Mutations::Notes::Base - QUICK_ACTION_ONLY_WARNING = <<~NB - If the body of the Note contains only quick actions, - the Note will be destroyed during the update, and no Note will be - returned. - NB - authorize :admin_note argument :id, diff --git a/app/graphql/mutations/saved_replies/base.rb b/app/graphql/mutations/saved_replies/base.rb new file mode 100644 index 00000000000..468263b0f9d --- /dev/null +++ b/app/graphql/mutations/saved_replies/base.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module SavedReplies + class Base < BaseMutation + field :saved_reply, Types::SavedReplyType, + null: true, + description: 'Updated saved reply.' + + private + + def present_result(result) + if result.success? + { + saved_reply: result[:saved_reply], + errors: [] + } + else + { + saved_reply: nil, + errors: result.message + } + end + end + + def feature_enabled? + Feature.enabled?(:saved_replies, current_user, default_enabled: :yaml) + end + + def find_object(id) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::Users::SavedReply].coerce_isolated_input(id) + + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/mutations/saved_replies/create.rb b/app/graphql/mutations/saved_replies/create.rb new file mode 100644 index 00000000000..d97461a1c2a --- /dev/null +++ b/app/graphql/mutations/saved_replies/create.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module SavedReplies + class Create < Base + graphql_name 'SavedReplyCreate' + + authorize :create_saved_replies + + argument :name, GraphQL::Types::String, + required: true, + description: copy_field_description(Types::SavedReplyType, :name) + + argument :content, GraphQL::Types::String, + required: true, + description: copy_field_description(Types::SavedReplyType, :content) + + def resolve(name:, content:) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? + + result = ::Users::SavedReplies::CreateService.new(current_user: current_user, name: name, content: content).execute + present_result(result) + end + end + end +end diff --git a/app/graphql/mutations/saved_replies/update.rb b/app/graphql/mutations/saved_replies/update.rb new file mode 100644 index 00000000000..bacc6ceb39e --- /dev/null +++ b/app/graphql/mutations/saved_replies/update.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module SavedReplies + class Update < Base + graphql_name 'SavedReplyUpdate' + + authorize :update_saved_replies + + argument :id, Types::GlobalIDType[::Users::SavedReply], + required: true, + description: copy_field_description(Types::SavedReplyType, :id) + + argument :name, GraphQL::Types::String, + required: true, + description: copy_field_description(Types::SavedReplyType, :name) + + argument :content, GraphQL::Types::String, + required: true, + description: copy_field_description(Types::SavedReplyType, :content) + + def resolve(id:, name:, content:) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? + + saved_reply = authorized_find!(id) + result = ::Users::SavedReplies::UpdateService.new(current_user: current_user, saved_reply: saved_reply, name: name, content: content).execute + present_result(result) + end + end + end +end diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 81454db62b1..48f0f470988 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -33,7 +33,7 @@ module Mutations def resolve(project_path:, **attributes) project = authorized_find!(project_path) - unless Feature.enabled?(:work_items, project) + unless Feature.enabled?(:work_items, project, default_enabled: :yaml) return { errors: ['`work_items` feature flag disabled for this project'] } end diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb new file mode 100644 index 00000000000..16d1e646167 --- /dev/null +++ b/app/graphql/mutations/work_items/create_from_task.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + class CreateFromTask < BaseMutation + graphql_name 'WorkItemCreateFromTask' + + include Mutations::SpamProtection + + description "Creates a work item from a task in another work item's description." \ + " Available only when feature flag `work_items` is enabled. This feature is experimental and is subject to change without notice." + + authorize :update_work_item + + argument :id, ::Types::GlobalIDType[::WorkItem], + required: true, + description: 'Global ID of the work item.' + argument :work_item_data, ::Types::WorkItems::ConvertTaskInputType, + required: true, + description: 'Arguments necessary to convert a task into a work item.', + prepare: ->(attributes, _ctx) { attributes.to_h } + + field :work_item, Types::WorkItemType, + null: true, + description: 'Updated work item.' + + field :new_work_item, Types::WorkItemType, + null: true, + description: 'New work item created from task.' + + def resolve(id:, work_item_data:) + work_item = authorized_find!(id: id) + + unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml) + return { errors: ['`work_items` feature flag disabled for this project'] } + end + + 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 + ).execute + + check_spam_action_response!(result[:work_item]) if result[:work_item] + + response = { errors: result.errors } + response.merge!(work_item: work_item, new_work_item: result[:work_item]) if result.success? + + response + end + + private + + def find_object(id:) + # TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb index 71792a802c0..f32354878ec 100644 --- a/app/graphql/mutations/work_items/delete.rb +++ b/app/graphql/mutations/work_items/delete.rb @@ -20,7 +20,7 @@ module Mutations def resolve(id:) work_item = authorized_find!(id: id) - unless Feature.enabled?(:work_items, work_item.project) + unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml) return { errors: ['`work_items` feature flag disabled for this project'] } end diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index 3ab9ba2d502..2700cbdb709 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -28,7 +28,7 @@ module Mutations def resolve(id:, **attributes) work_item = authorized_find!(id: id) - unless Feature.enabled?(:work_items, work_item.project) + unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml) return { errors: ['`work_items` feature flag disabled for this project'] } end diff --git a/app/graphql/queries/burndown_chart/burnup.query.graphql b/app/graphql/queries/burndown_chart/burnup.query.graphql index 7a389a6def5..0795645f8b7 100644 --- a/app/graphql/queries/burndown_chart/burnup.query.graphql +++ b/app/graphql/queries/burndown_chart/burnup.query.graphql @@ -1,4 +1,9 @@ -query BurnupTimesSeriesData($id: ID!, $isIteration: Boolean = false, $weight: Boolean = false) { +query BurnupTimesSeriesData( + $id: ID! + $isIteration: Boolean = false + $weight: Boolean = false + $fullPath: String +) { milestone(id: $id) @skip(if: $isIteration) { __typename id @@ -37,7 +42,7 @@ query BurnupTimesSeriesData($id: ID!, $isIteration: Boolean = false, $weight: Bo __typename id title - report { + report(fullPath: $fullPath) { __typename burnupTimeSeries { __typename diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb index d0eb2deaf48..0704a845bb0 100644 --- a/app/graphql/resolvers/blobs_resolver.rb +++ b/app/graphql/resolvers/blobs_resolver.rb @@ -30,8 +30,17 @@ module Resolvers return [] if repository.empty? ref ||= repository.root_ref + validate_ref(ref) repository.blobs_at(paths.map { |path| [ref, path] }) end + + private + + def validate_ref(ref) + unless Gitlab::GitRefValidator.validate(ref) + raise Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid' + end + end end end diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb index 387185b5171..f9d60650443 100644 --- a/app/graphql/resolvers/ci/config_resolver.rb +++ b/app/graphql/resolvers/ci/config_resolver.rb @@ -38,6 +38,8 @@ module Resolvers .validate(content, dry_run: dry_run) response(result).merge(merged_yaml: result.merged_yaml) + rescue GRPC::InvalidArgument => error + Gitlab::ErrorTracking.track_and_raise_exception(error, sha: sha) end private diff --git a/app/graphql/resolvers/concerns/group_issuable_resolver.rb b/app/graphql/resolvers/concerns/group_issuable_resolver.rb index 542ff5374ff..92d22409ff2 100644 --- a/app/graphql/resolvers/concerns/group_issuable_resolver.rb +++ b/app/graphql/resolvers/concerns/group_issuable_resolver.rb @@ -3,12 +3,21 @@ module GroupIssuableResolver extend ActiveSupport::Concern - class_methods do - def include_subgroups(name_of_things) - argument :include_subgroups, GraphQL::Types::Boolean, - required: false, - default_value: false, - description: "Include #{name_of_things} belonging to subgroups" - end + included do + argument :include_subgroups, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: "Include #{issuable_collection_name} belonging to subgroups" + + argument :include_archived, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: "Return #{issuable_collection_name} from archived projects" + end + + def resolve(**args) + args[:non_archived] = !args.delete(:include_archived) + + super end end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index 75f1ee478a8..a72b9a09118 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -51,7 +51,8 @@ module ResolvesMergeRequests milestone: [:milestone], security_auto_fix: [:author], head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }], - timelogs: [:timelogs] + timelogs: [:timelogs], + committers: [merge_request_diff: [:merge_request_diff_commits]] } end end diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index 42c4c22a938..764ed9b15fd 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -20,11 +20,22 @@ module ResolvesPipelines GraphQL::Types::String, required: false, description: "Filter pipelines by the sha of the commit they are run for." - argument :source, GraphQL::Types::String, required: false, description: "Filter pipelines by their source." + + argument :updated_after, Types::TimeType, + required: false, + description: 'Pipelines updated after this date.' + argument :updated_before, Types::TimeType, + required: false, + description: 'Pipelines updated before this date.' + + argument :username, + GraphQL::Types::String, + required: false, + description: "Filter pipelines by the user that triggered the pipeline." end class_methods do diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb index 28f9266974f..05c5e803539 100644 --- a/app/graphql/resolvers/group_issues_resolver.rb +++ b/app/graphql/resolvers/group_issues_resolver.rb @@ -3,9 +3,11 @@ module Resolvers class GroupIssuesResolver < BaseIssuesResolver - include GroupIssuableResolver + def self.issuable_collection_name + 'issues' + end - include_subgroups 'issues' + include GroupIssuableResolver def ready?(**args) if args.dig(:not, :release_tag).present? diff --git a/app/graphql/resolvers/group_members/notification_email_resolver.rb b/app/graphql/resolvers/group_members/notification_email_resolver.rb new file mode 100644 index 00000000000..6cff4fbf531 --- /dev/null +++ b/app/graphql/resolvers/group_members/notification_email_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + module GroupMembers + class NotificationEmailResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type GraphQL::Types::String, null: true + + def resolve + authorize! + + BatchLoader::GraphQL.for(object.user_id).batch do |user_ids, loader| + User.find(user_ids).each do |user| + loader.call(user.id, user.notification_email_for(object.group)) + end + end + end + + def authorize! + raise_resource_not_available_error! unless user_is_admin? + end + + def user_is_admin? + context[:current_user].present? && context[:current_user].can_admin_all_resources? + end + end + end +end diff --git a/app/graphql/resolvers/group_merge_requests_resolver.rb b/app/graphql/resolvers/group_merge_requests_resolver.rb index 34a4c67bc56..da1b6169c07 100644 --- a/app/graphql/resolvers/group_merge_requests_resolver.rb +++ b/app/graphql/resolvers/group_merge_requests_resolver.rb @@ -2,13 +2,16 @@ module Resolvers class GroupMergeRequestsResolver < MergeRequestsResolver + def self.issuable_collection_name + 'merge requests' + end + include GroupIssuableResolver alias_method :group, :object type Types::MergeRequestType.connection_type, null: true - include_subgroups 'merge requests' accept_assignee accept_author diff --git a/app/graphql/resolvers/topics_resolver.rb b/app/graphql/resolvers/topics_resolver.rb index d8199f3d89b..68e2ff69282 100644 --- a/app/graphql/resolvers/topics_resolver.rb +++ b/app/graphql/resolvers/topics_resolver.rb @@ -10,9 +10,9 @@ module Resolvers def resolve(**args) if args[:search].present? - ::Projects::Topic.search(args[:search]).order_by_total_projects_count + ::Projects::Topic.search(args[:search]).order_by_non_private_projects_count else - ::Projects::Topic.order_by_total_projects_count + ::Projects::Topic.order_by_non_private_projects_count end end end diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb new file mode 100644 index 00000000000..7cf52339815 --- /dev/null +++ b/app/graphql/resolvers/work_item_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + class WorkItemResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_work_item + + type Types::WorkItemType, null: true + + argument :id, ::Types::GlobalIDType[::WorkItem], required: true, description: 'Global ID of the work item.' + + def resolve(id:) + work_item = authorized_find!(id: id) + return unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml) + + work_item + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end +end diff --git a/app/graphql/resolvers/work_items/types_resolver.rb b/app/graphql/resolvers/work_items/types_resolver.rb index b7a32e13423..67a9d57d42f 100644 --- a/app/graphql/resolvers/work_items/types_resolver.rb +++ b/app/graphql/resolvers/work_items/types_resolver.rb @@ -5,10 +5,20 @@ module Resolvers class TypesResolver < BaseResolver type Types::WorkItems::TypeType.connection_type, null: true - def resolve + argument :taskable, ::GraphQL::Types::Boolean, + required: false, + description: 'If `true`, only taskable work item types will be returned.' \ + ' Argument is experimental and can be removed in the future without notice.' + + def resolve(taskable: nil) + return unless Feature.enabled?(:work_items, object, default_enabled: :yaml) + # This will require a finder in the future when groups/projects get their work item types # All groups/projects use the default types for now - ::WorkItems::Type.default.order_by_name_asc + base_scope = ::WorkItems::Type.default + base_scope = base_scope.by_type(:task) if taskable + + base_scope.order_by_name_asc end end end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 7495d46179c..43b7bbb419f 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -9,6 +9,7 @@ module Types present_using ::AlertManagement::AlertPresenter implements(Types::Notes::NoteableInterface) + implements(Types::TodoableInterface) authorize :read_alert_management_alert @@ -127,6 +128,12 @@ module Types null: true, description: 'Alert condition for Prometheus.' + field :web_url, + GraphQL::Types::String, + method: :details_url, + null: false, + description: 'URL of the alert.' + def notes object.ordered_notes end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index d70236f16f9..0224aeddac6 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Graphql/GraphqlNamePosition module Types class BaseEnum < GraphQL::Schema::Enum class CustomValue < GraphQL::Schema::EnumValue @@ -37,7 +38,7 @@ module Types description(enum_mod.description) if use_description enum_mod.definition.each do |key, content| - value(key.to_s.upcase, **content) + value(key.to_s.upcase, value: key.to_s, description: content[:description]) end end # rubocop: enable Graphql/Descriptions diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index 733006369ea..7f4c49df429 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -14,18 +14,18 @@ module Types null: false, description: 'ID (global ID) of the list.' - field :title, GraphQL::Types::String, null: false, - description: 'Title of the list.' - field :list_type, GraphQL::Types::String, null: false, - description: 'Type of the list.' - field :position, GraphQL::Types::Int, null: true, - description: 'Position of list within the board.' - field :label, Types::LabelType, null: true, - description: 'Label of the list.' field :collapsed, GraphQL::Types::Boolean, null: true, description: 'Indicates if the list is collapsed for this user.' field :issues_count, GraphQL::Types::Int, null: true, description: 'Count of issues in the list.' + field :label, Types::LabelType, null: true, + description: 'Label of the list.' + field :list_type, GraphQL::Types::String, null: false, + description: 'Type of the list.' + field :position, GraphQL::Types::Int, null: true, + description: 'Position of list within the board.' + field :title, GraphQL::Types::String, null: false, + description: 'Title of the list.' field :issues, ::Types::IssueType.connection_type, null: true, description: 'Board issues.', diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb index f52b9eae229..a77b8026f86 100644 --- a/app/graphql/types/ci/analytics_type.rb +++ b/app/graphql/types/ci/analytics_type.rb @@ -6,28 +6,28 @@ module Types class AnalyticsType < BaseObject graphql_name 'PipelineAnalytics' - field :week_pipelines_totals, [GraphQL::Types::Int], null: true, - description: 'Total weekly pipeline count.' - field :week_pipelines_successful, [GraphQL::Types::Int], null: true, - description: 'Total weekly successful pipeline count.' - field :week_pipelines_labels, [GraphQL::Types::String], null: true, - description: 'Labels for the weekly pipeline count.' - field :month_pipelines_totals, [GraphQL::Types::Int], null: true, - description: 'Total monthly pipeline count.' - field :month_pipelines_successful, [GraphQL::Types::Int], null: true, - description: 'Total monthly successful pipeline count.' field :month_pipelines_labels, [GraphQL::Types::String], null: true, description: 'Labels for the monthly pipeline count.' - field :year_pipelines_totals, [GraphQL::Types::Int], null: true, - description: 'Total yearly pipeline count.' - field :year_pipelines_successful, [GraphQL::Types::Int], null: true, - description: 'Total yearly successful pipeline count.' - field :year_pipelines_labels, [GraphQL::Types::String], null: true, - description: 'Labels for the yearly pipeline count.' - field :pipeline_times_values, [GraphQL::Types::Int], null: true, - description: 'Pipeline times.' + field :month_pipelines_successful, [GraphQL::Types::Int], null: true, + description: 'Total monthly successful pipeline count.' + field :month_pipelines_totals, [GraphQL::Types::Int], null: true, + description: 'Total monthly pipeline count.' field :pipeline_times_labels, [GraphQL::Types::String], null: true, description: 'Pipeline times labels.' + field :pipeline_times_values, [GraphQL::Types::Int], null: true, + description: 'Pipeline times.' + field :week_pipelines_labels, [GraphQL::Types::String], null: true, + description: 'Labels for the weekly pipeline count.' + field :week_pipelines_successful, [GraphQL::Types::Int], null: true, + description: 'Total weekly successful pipeline count.' + field :week_pipelines_totals, [GraphQL::Types::Int], null: true, + description: 'Total weekly pipeline count.' + field :year_pipelines_labels, [GraphQL::Types::String], null: true, + description: 'Labels for the yearly pipeline count.' + field :year_pipelines_successful, [GraphQL::Types::Int], null: true, + description: 'Total yearly successful pipeline count.' + field :year_pipelines_totals, [GraphQL::Types::Int], null: true, + description: 'Total yearly pipeline count.' end end end diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index 790deab8f68..e43af6f3e78 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -7,18 +7,18 @@ module Types authorize :admin_project + field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true, + description: 'Indicates CI job tokens generated in this project have restricted access to resources.', + method: :job_token_scope_enabled? + field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, + description: 'Whether to keep the latest builds artifacts.', + method: :keep_latest_artifacts_available? field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true, description: 'Whether merge pipelines are enabled.', method: :merge_pipelines_enabled? field :merge_trains_enabled, GraphQL::Types::Boolean, null: true, description: 'Whether merge trains are enabled.', method: :merge_trains_enabled? - field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, - description: 'Whether to keep the latest builds artifacts.', - method: :keep_latest_artifacts_available? - field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates CI job tokens generated in this project have restricted access to resources.', - method: :job_token_scope_enabled? field :project, Types::ProjectType, null: true, description: 'Project the CI/CD settings belong to.' end diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb index e5cb0d4e72f..19076fe9c20 100644 --- a/app/graphql/types/ci/config/group_type.rb +++ b/app/graphql/types/ci/config/group_type.rb @@ -7,10 +7,10 @@ module Types class GroupType < BaseObject graphql_name 'CiConfigGroup' - field :name, GraphQL::Types::String, null: true, - description: 'Name of the job group.' field :jobs, Types::Ci::Config::JobType.connection_type, null: true, description: 'Jobs in group.' + field :name, GraphQL::Types::String, null: true, + description: 'Name of the job group.' field :size, GraphQL::Types::Int, null: true, description: 'Size of the job group.' end diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb index 4cf6780ef60..20279143635 100644 --- a/app/graphql/types/ci/config/job_type.rb +++ b/app/graphql/types/ci/config/job_type.rb @@ -7,33 +7,33 @@ module Types class JobType < BaseObject graphql_name 'CiConfigJob' - field :name, GraphQL::Types::String, null: true, - description: 'Name of the job.' - field :group_name, GraphQL::Types::String, null: true, - description: 'Name of the job group.' - field :stage, GraphQL::Types::String, null: true, - description: 'Name of the job stage.' - field :needs, Types::Ci::Config::NeedType.connection_type, null: true, - description: 'Builds that must complete before the jobs run.' + field :after_script, [GraphQL::Types::String], null: true, + description: 'Override a set of commands that are executed after the job.' field :allow_failure, GraphQL::Types::Boolean, null: true, description: 'Allow job to fail.' field :before_script, [GraphQL::Types::String], null: true, description: 'Override a set of commands that are executed before the job.' - field :script, [GraphQL::Types::String], null: true, - description: 'Shell script that is executed by a runner.' - field :after_script, [GraphQL::Types::String], null: true, - description: 'Override a set of commands that are executed after the job.' - field :when, GraphQL::Types::String, null: true, - description: 'When to run the job.', - resolver_method: :restrict_when_to_run_jobs field :environment, GraphQL::Types::String, null: true, description: 'Name of an environment to which the job deploys.' field :except, Types::Ci::Config::JobRestrictionType, null: true, description: 'Limit when jobs are not created.' + field :group_name, GraphQL::Types::String, null: true, + description: 'Name of the job group.' + field :name, GraphQL::Types::String, null: true, + description: 'Name of the job.' + field :needs, Types::Ci::Config::NeedType.connection_type, null: true, + description: 'Builds that must complete before the jobs run.' field :only, Types::Ci::Config::JobRestrictionType, null: true, description: 'Jobs are created when these conditions do not apply.' + field :script, [GraphQL::Types::String], null: true, + description: 'Shell script that is executed by a runner.' + field :stage, GraphQL::Types::String, null: true, + description: 'Name of the job stage.' field :tags, [GraphQL::Types::String], null: true, description: 'List of tags that are used to select a runner.' + field :when, GraphQL::Types::String, null: true, + description: 'When to run the job.', + resolver_method: :restrict_when_to_run_jobs def restrict_when_to_run_jobs object[:when] diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb index 7e2aa9470f2..5b1163edac2 100644 --- a/app/graphql/types/ci/config/stage_type.rb +++ b/app/graphql/types/ci/config/stage_type.rb @@ -7,10 +7,10 @@ module Types class StageType < BaseObject graphql_name 'CiConfigStage' - field :name, GraphQL::Types::String, null: true, - description: 'Name of the stage.' field :groups, Types::Ci::Config::GroupType.connection_type, null: true, description: 'Groups of jobs for the stage.' + field :name, GraphQL::Types::String, null: true, + description: 'Name of the stage.' end end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index 4433e921971..e3413551a3f 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -6,20 +6,23 @@ module Types class DetailedStatusType < BaseObject graphql_name 'DetailedStatus' - field :id, GraphQL::Types::String, null: false, - description: 'ID for a detailed status.', - extras: [:parent] - field :group, GraphQL::Types::String, null: true, - description: 'Group of the status.' - field :icon, GraphQL::Types::String, null: true, - description: 'Icon of the status.' - field :favicon, GraphQL::Types::String, null: true, - description: 'Favicon of the status.' + field :action, Types::Ci::StatusActionType, null: true, + calls_gitaly: true, + description: 'Action information for the status. This includes method, button title, icon, path, and title.' field :details_path, GraphQL::Types::String, null: true, description: 'Path of the details for the status.' + field :favicon, GraphQL::Types::String, null: true, + description: 'Favicon of the status.' + field :group, GraphQL::Types::String, null: true, + description: 'Group of the status.' field :has_details, GraphQL::Types::Boolean, null: true, description: 'Indicates if the status has further details.', method: :has_details? + field :icon, GraphQL::Types::String, null: true, + description: 'Icon of the status.' + field :id, GraphQL::Types::String, null: false, + description: 'ID for a detailed status.', + extras: [:parent] field :label, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'Label of the status.' @@ -28,9 +31,6 @@ module Types field :tooltip, GraphQL::Types::String, null: true, description: 'Tooltip associated with the status.', method: :status_tooltip - field :action, Types::Ci::StatusActionType, null: true, - calls_gitaly: true, - description: 'Action information for the status. This includes method, button title, icon, path, and title.' def id(parent:) "#{object.id}-#{parent.object.object.id}" diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb index 3ae23ba9bd4..c3c73ef170c 100644 --- a/app/graphql/types/ci/group_type.rb +++ b/app/graphql/types/ci/group_type.rb @@ -6,16 +6,16 @@ module Types class GroupType < BaseObject graphql_name 'CiGroup' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the group.' field :id, GraphQL::Types::String, null: false, description: 'ID for a group.' + field :jobs, Ci::JobType.connection_type, null: true, + description: 'Jobs in group.' field :name, GraphQL::Types::String, null: true, description: 'Name of the job group.' field :size, GraphQL::Types::Int, null: true, description: 'Size of the group.' - field :jobs, Ci::JobType.connection_type, null: true, - description: 'Jobs in group.' - field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the group.' def detailed_status object.detailed_status(context[:current_user]) diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 1320b96907e..83054553bd8 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -11,38 +11,38 @@ module Types expose_permissions Types::PermissionTypes::Ci::Job + field :allow_failure, ::GraphQL::Types::Boolean, null: false, + description: 'Whether the job is allowed to fail.' + field :duration, GraphQL::Types::Int, null: true, + description: 'Duration of the job in seconds.' field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true, description: 'ID of the job.' - field :pipeline, Types::Ci::PipelineType, null: true, - description: 'Pipeline the job belongs to.' field :name, GraphQL::Types::String, null: true, description: 'Name of the job.' field :needs, BuildNeedType.connection_type, null: true, description: 'References to builds that must complete before the jobs run.' + field :pipeline, Types::Ci::PipelineType, null: true, + description: 'Pipeline the job belongs to.' + field :stage, Types::Ci::StageType, null: true, + description: 'Stage of the job.' field :status, type: ::Types::Ci::JobStatusEnum, null: true, description: "Status of the job." - field :stage, Types::Ci::StageType, null: true, - description: 'Stage of the job.' - field :allow_failure, ::GraphQL::Types::Boolean, null: false, - description: 'Whether the job is allowed to fail.' - field :duration, GraphQL::Types::Int, null: true, - description: 'Duration of the job in seconds.' field :tags, [GraphQL::Types::String], null: true, description: 'Tags for the current job.' # Life-cycle timestamps: field :created_at, Types::TimeType, null: false, description: "When the job was created." - field :queued_at, Types::TimeType, null: true, - description: 'When the job was enqueued and marked as pending.' - field :started_at, Types::TimeType, null: true, - description: 'When the job was started.' field :finished_at, Types::TimeType, null: true, description: 'When a job has finished running.' + field :queued_at, Types::TimeType, null: true, + description: 'When the job was enqueued and marked as pending.' field :scheduled_at, Types::TimeType, null: true, description: 'Schedule for the build.' + field :started_at, Types::TimeType, null: true, + description: 'When the job was started.' # Life-cycle durations: field :queued_duration, @@ -50,40 +50,40 @@ module Types null: true, description: 'How long the job was enqueued before starting.' - field :downstream_pipeline, Types::Ci::PipelineType, null: true, - description: 'Downstream pipeline for a bridge.' - field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, - description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' - field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the job.' + field :active, GraphQL::Types::Boolean, null: false, method: :active?, + description: 'Indicates the job is active.' field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, description: 'Artifacts generated by the job.' - field :short_sha, type: GraphQL::Types::String, null: false, - description: 'Short SHA1 ID of the commit.' - field :scheduling_type, GraphQL::Types::String, null: true, - description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.' + field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?, + description: 'Indicates the job can be canceled.' field :commit_path, GraphQL::Types::String, null: true, description: 'Path to the commit that triggered the job.' + field :coverage, GraphQL::Types::Float, null: true, + description: 'Coverage level of the job.' + field :created_by_tag, GraphQL::Types::Boolean, null: false, + description: 'Whether the job was created by a tag.', method: :tag? + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the job.' + field :downstream_pipeline, Types::Ci::PipelineType, null: true, + description: 'Downstream pipeline for a bridge.' + field :manual_job, GraphQL::Types::Boolean, null: true, + description: 'Whether the job has a manual action.' + field :playable, GraphQL::Types::Boolean, null: false, method: :playable?, + description: 'Indicates the job can be played.' + field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, + description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' field :ref_name, GraphQL::Types::String, null: true, description: 'Ref name of the job.' field :ref_path, GraphQL::Types::String, null: true, description: 'Path to the ref.' - field :playable, GraphQL::Types::Boolean, null: false, method: :playable?, - description: 'Indicates the job can be played.' field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?, description: 'Indicates the job can be retried.' - field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?, - description: 'Indicates the job can be canceled.' - field :active, GraphQL::Types::Boolean, null: false, method: :active?, - description: 'Indicates the job is active.' + field :scheduling_type, GraphQL::Types::String, null: true, + description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.' + field :short_sha, type: GraphQL::Types::String, null: false, + description: 'Short SHA1 ID of the commit.' field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?, description: 'Indicates the job is stuck.' - field :coverage, GraphQL::Types::Float, null: true, - description: 'Coverage level of the job.' - field :created_by_tag, GraphQL::Types::Boolean, null: false, - description: 'Whether the job was created by a tag.' - field :manual_job, GraphQL::Types::Boolean, null: true, - description: 'Whether the job has a manual action.' field :triggered, GraphQL::Types::Boolean, null: true, description: 'Whether the job was triggered.' @@ -173,10 +173,6 @@ module Types object&.coverage end - def created_by_tag - object.tag? - end - def manual_job object.try(:action?) end diff --git a/app/graphql/types/ci/runner_architecture_type.rb b/app/graphql/types/ci/runner_architecture_type.rb index 08d3f98592b..eb576cf09ce 100644 --- a/app/graphql/types/ci/runner_architecture_type.rb +++ b/app/graphql/types/ci/runner_architecture_type.rb @@ -6,10 +6,10 @@ module Types class RunnerArchitectureType < BaseObject graphql_name 'RunnerArchitecture' - field :name, GraphQL::Types::String, null: false, - description: 'Name of the runner platform architecture.' field :download_location, GraphQL::Types::String, null: false, description: 'Download location for the runner for the platform architecture.' + field :name, GraphQL::Types::String, null: false, + description: 'Name of the runner platform architecture.' end end end diff --git a/app/graphql/types/ci/runner_platform_type.rb b/app/graphql/types/ci/runner_platform_type.rb index ffcf6364968..3c893615b20 100644 --- a/app/graphql/types/ci/runner_platform_type.rb +++ b/app/graphql/types/ci/runner_platform_type.rb @@ -6,12 +6,12 @@ module Types class RunnerPlatformType < BaseObject graphql_name 'RunnerPlatform' - field :name, GraphQL::Types::String, null: false, - description: 'Name slug of the runner platform.' - field :human_readable_name, GraphQL::Types::String, null: false, - description: 'Human readable name of the runner platform.' field :architectures, Types::Ci::RunnerArchitectureType.connection_type, null: true, description: 'Runner architectures supported for the platform.' + field :human_readable_name, GraphQL::Types::String, null: false, + description: 'Human readable name of the runner platform.' + field :name, GraphQL::Types::String, null: false, + description: 'Name slug of the runner platform.' end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 9094c6b96e4..a7f0730f07e 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -16,54 +16,20 @@ module Types alias_method :runner, :object - field :id, ::Types::GlobalIDType[::Ci::Runner], null: false, - description: 'ID of the runner.' - field :description, GraphQL::Types::String, null: true, - description: 'Description of the runner.' - field :created_at, Types::TimeType, null: true, - description: 'Timestamp of creation of this runner.' - field :contacted_at, Types::TimeType, null: true, - description: 'Timestamp of last contact from this runner.', - method: :contacted_at - field :token_expires_at, Types::TimeType, null: true, - description: 'Runner token expiration time.', - method: :token_expires_at - field :maximum_timeout, GraphQL::Types::Int, null: true, - description: 'Maximum timeout (in seconds) for jobs processed by the runner.' field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false, description: 'Access level of the runner.' field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates the runner is allowed to receive jobs.', deprecated: { reason: 'Use paused', milestone: '14.8' } - field :paused, GraphQL::Types::Boolean, null: false, - description: 'Indicates the runner is paused and not available to run jobs.' - field :status, - Types::Ci::RunnerStatusEnum, - null: false, - description: 'Status of the runner.', - resolver: ::Resolvers::Ci::RunnerStatusResolver - field :version, GraphQL::Types::String, null: true, - description: 'Version of the runner.' - field :short_sha, GraphQL::Types::String, null: true, - description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.) - field :revision, GraphQL::Types::String, null: true, - description: 'Revision of the runner.' - field :locked, GraphQL::Types::Boolean, null: true, - description: 'Indicates the runner is locked.' - field :run_untagged, GraphQL::Types::Boolean, null: false, - description: 'Indicates the runner is able to run untagged jobs.' - field :ip_address, GraphQL::Types::String, null: true, - description: 'IP address of the runner.' - field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false, - description: 'Type of the runner.' - field :tag_list, [GraphQL::Types::String], null: true, - description: 'Tags associated with the runner.' - field :project_count, GraphQL::Types::Int, null: true, - description: 'Number of projects that the runner is associated with.' - field :job_count, GraphQL::Types::Int, null: true, - description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." field :admin_url, GraphQL::Types::String, null: true, description: 'Admin URL of the runner. Only available for administrators.' + field :contacted_at, Types::TimeType, null: true, + description: 'Timestamp of last contact from this runner.', + method: :contacted_at + field :created_at, Types::TimeType, null: true, + description: 'Timestamp of creation of this runner.' + field :description, GraphQL::Types::String, null: true, + description: 'Description of the runner.' field :edit_admin_url, GraphQL::Types::String, null: true, description: 'Admin form URL of the runner. Only available for administrators.' field :executor_name, GraphQL::Types::String, null: true, @@ -72,12 +38,46 @@ module Types feature_flag: :graphql_ci_runner_executor field :groups, ::Types::GroupType.connection_type, null: true, description: 'Groups the runner is associated with. For group runners only.' - field :projects, ::Types::ProjectType.connection_type, null: true, - description: 'Projects the runner is associated with. For project runners only.' + field :id, ::Types::GlobalIDType[::Ci::Runner], null: false, + description: 'ID of the runner.' + field :ip_address, GraphQL::Types::String, null: true, + description: 'IP address of the runner.' + field :job_count, GraphQL::Types::Int, null: true, + description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." field :jobs, ::Types::Ci::JobType.connection_type, null: true, description: 'Jobs assigned to the runner.', authorize: :read_builds, resolver: ::Resolvers::Ci::RunnerJobsResolver + field :locked, GraphQL::Types::Boolean, null: true, + description: 'Indicates the runner is locked.' + field :maximum_timeout, GraphQL::Types::Int, null: true, + description: 'Maximum timeout (in seconds) for jobs processed by the runner.' + field :paused, GraphQL::Types::Boolean, null: false, + description: 'Indicates the runner is paused and not available to run jobs.' + field :project_count, GraphQL::Types::Int, null: true, + description: 'Number of projects that the runner is associated with.' + field :projects, ::Types::ProjectType.connection_type, null: true, + description: 'Projects the runner is associated with. For project runners only.' + field :revision, GraphQL::Types::String, null: true, + description: 'Revision of the runner.' + field :run_untagged, GraphQL::Types::Boolean, null: false, + description: 'Indicates the runner is able to run untagged jobs.' + field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false, + description: 'Type of the runner.' + field :short_sha, GraphQL::Types::String, null: true, + description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.) + field :status, + Types::Ci::RunnerStatusEnum, + null: false, + description: 'Status of the runner.', + resolver: ::Resolvers::Ci::RunnerStatusResolver + field :tag_list, [GraphQL::Types::String], null: true, + description: 'Tags associated with the runner.' + field :token_expires_at, Types::TimeType, null: true, + description: 'Runner token expiration time.', + method: :token_expires_at + field :version, GraphQL::Types::String, null: true, + description: 'Version of the runner.' def job_count # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb index 368e16f972c..035d75c22c6 100644 --- a/app/graphql/types/ci/runner_web_url_edge.rb +++ b/app/graphql/types/ci/runner_web_url_edge.rb @@ -6,6 +6,9 @@ module Types class RunnerWebUrlEdge < ::Types::BaseEdge include FindClosest + field :edit_url, GraphQL::Types::String, null: true, + description: 'Web URL of the runner edit page. The value depends on where you put this field in the query. You can use it for projects or groups.', + extras: [:parent] field :web_url, GraphQL::Types::String, null: true, description: 'Web URL of the runner. The value depends on where you put this field in the query. You can use it for projects or groups.', extras: [:parent] @@ -16,14 +19,26 @@ module Types @runner = node.node end + def edit_url(parent:) + runner_url(parent: parent, url_type: :edit_url) + end + def web_url(parent:) + runner_url(parent: parent, url_type: :default) + end + + private + + def runner_url(parent:, url_type: :default) owner = closest_parent([::Types::ProjectType, ::Types::GroupType], parent) + # Only ::Group is supported at the moment, future iterations will include ::Project. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/16338 case owner when ::Group + return Gitlab::Routing.url_helpers.edit_group_runner_url(owner, @runner) if url_type == :edit_url + Gitlab::Routing.url_helpers.group_runner_url(owner, @runner) - when ::Project - Gitlab::Routing.url_helpers.project_runner_url(owner, @runner) end end end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index 70e78e391a7..dcb3092d15a 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -6,17 +6,17 @@ module Types graphql_name 'CiStage' authorize :read_build - field :id, GraphQL::Types::ID, null: false, - description: 'ID of the stage.' - field :name, type: GraphQL::Types::String, null: true, - description: 'Name of the stage.' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the stage.' field :groups, type: Ci::GroupType.connection_type, null: true, extras: [:lookahead], description: 'Group of jobs for the stage.' - field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the stage.' + field :id, GraphQL::Types::ID, null: false, + description: 'ID of the stage.' field :jobs, Types::Ci::JobType.connection_type, null: true, description: 'Jobs for the stage.' + field :name, type: GraphQL::Types::String, null: true, + description: 'Name of the stage.' field :status, GraphQL::Types::String, null: true, description: 'Status of the pipeline stage.' diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb index 15e5344e130..26ca3c1438a 100644 --- a/app/graphql/types/ci/status_action_type.rb +++ b/app/graphql/types/ci/status_action_type.rb @@ -5,13 +5,13 @@ module Types class StatusActionType < BaseObject graphql_name 'StatusAction' - field :id, GraphQL::Types::String, null: false, - description: 'ID for a status action.', - extras: [:parent] field :button_title, GraphQL::Types::String, null: true, description: 'Title for the button, for example: Retry this job.' field :icon, GraphQL::Types::String, null: true, description: 'Icon used in the action button.' + field :id, GraphQL::Types::String, null: false, + description: 'ID for a status action.', + extras: [:parent] field :method, GraphQL::Types::String, null: true, description: 'Method for the action, for example: :post.', resolver_method: :action_method diff --git a/app/graphql/types/ci/template_type.rb b/app/graphql/types/ci/template_type.rb index 7e7ee44025f..4f1ec6436de 100644 --- a/app/graphql/types/ci/template_type.rb +++ b/app/graphql/types/ci/template_type.rb @@ -7,10 +7,10 @@ module Types graphql_name 'CiTemplate' description 'GitLab CI/CD configuration template.' - field :name, GraphQL::Types::String, null: false, - description: 'Name of the CI template.' field :content, GraphQL::Types::String, null: false, description: 'Contents of the CI template.' + field :name, GraphQL::Types::String, null: false, + description: 'Name of the CI template.' end end end diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb index 6f6d6a418dc..1aa3a4e7ee1 100644 --- a/app/graphql/types/commit_action_type.rb +++ b/app/graphql/types/commit_action_type.rb @@ -4,17 +4,17 @@ module Types class CommitActionType < BaseInputObject argument :action, type: Types::CommitActionModeEnum, required: true, description: 'Action to perform: create, delete, move, update, or chmod.' - argument :file_path, type: GraphQL::Types::String, required: true, - description: 'Full path to the file.' argument :content, type: GraphQL::Types::String, required: false, description: 'Content of the file.' - argument :previous_path, type: GraphQL::Types::String, required: false, - description: 'Original full path to the file being moved.' - argument :last_commit_id, type: GraphQL::Types::String, required: false, - description: 'Last known file commit ID.' - argument :execute_filemode, type: GraphQL::Types::Boolean, required: false, - description: 'Enables/disables the execute flag on the file.' argument :encoding, type: Types::CommitEncodingEnum, required: false, description: 'Encoding of the file. Default is text.' + argument :execute_filemode, type: GraphQL::Types::Boolean, required: false, + description: 'Enables/disables the execute flag on the file.' + argument :file_path, type: GraphQL::Types::String, required: true, + description: 'Full path to the file.' + argument :last_commit_id, type: GraphQL::Types::String, required: false, + description: 'Last known file commit ID.' + argument :previous_path, type: GraphQL::Types::String, required: false, + description: 'Original full path to the file being moved.' end end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index 8bc00359ccb..c3a6d6f7faa 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -8,6 +8,8 @@ module Types present_using CommitPresenter + implements(Types::TodoableInterface) + field :id, type: GraphQL::Types::ID, null: false, description: 'ID (global ID) of the commit.' @@ -41,12 +43,12 @@ module Types field :signature_html, type: GraphQL::Types::String, null: true, calls_gitaly: true, description: 'Rendered HTML of the commit signature.' - field :author_name, type: GraphQL::Types::String, null: true, - description: 'Commit authors name.' field :author_email, type: GraphQL::Types::String, null: true, description: "Commit author's email." field :author_gravatar, type: GraphQL::Types::String, null: true, description: 'Commit authors gravatar.' + field :author_name, type: GraphQL::Types::String, null: true, + description: 'Commit authors name.' # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true, diff --git a/app/graphql/types/container_expiration_policy_type.rb b/app/graphql/types/container_expiration_policy_type.rb index 6d6df21fe3f..0e9534be684 100644 --- a/app/graphql/types/container_expiration_policy_type.rb +++ b/app/graphql/types/container_expiration_policy_type.rb @@ -8,14 +8,14 @@ module Types authorize :destroy_container_image + field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule.' field :created_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was created.' - field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated.' field :enabled, GraphQL::Types::Boolean, null: false, description: 'Indicates whether this container expiration policy is enabled.' - field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire.' - field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule.' field :keep_n, Types::ContainerExpirationPolicyKeepEnum, null: true, description: 'Number of tags to retain.' field :name_regex, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will expire.' field :name_regex_keep, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will be preserved.' field :next_run_at, Types::TimeType, null: true, description: 'Next time that this container expiration policy will get executed.' + field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire.' + field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated.' end end diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb index e713aaebe36..1ee9e76a1c8 100644 --- a/app/graphql/types/container_repository_details_type.rb +++ b/app/graphql/types/container_repository_details_type.rb @@ -15,8 +15,19 @@ module Types max_page_size: 20, resolver: Resolvers::ContainerRepositoryTagsResolver + field :size, + GraphQL::Types::Float, + null: true, + description: 'Deduplicated size of the image repository in bytes. This is only available on GitLab.com for repositories created after `2021-11-04`.' + def can_delete Ability.allowed?(current_user, :destroy_container_image, object) end + + def size + object.size + rescue Faraday::Error + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, "Can't connect to the Container Registry. If this error persists, please review the troubleshooting documentation." + end end end diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb index 206d6a3426c..d9665175449 100644 --- a/app/graphql/types/container_repository_tag_type.rb +++ b/app/graphql/types/container_repository_tag_type.rb @@ -8,15 +8,15 @@ module Types authorize :read_container_image + field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete this tag.' + field :created_at, Types::TimeType, null: true, description: 'Timestamp when the tag was created.' + field :digest, GraphQL::Types::String, null: true, description: 'Digest of the tag.' + field :location, GraphQL::Types::String, null: false, description: 'URL of the tag.' field :name, GraphQL::Types::String, null: false, description: 'Name of the tag.' field :path, GraphQL::Types::String, null: false, description: 'Path of the tag.' - field :location, GraphQL::Types::String, null: false, description: 'URL of the tag.' - field :digest, GraphQL::Types::String, null: true, description: 'Digest of the tag.' field :revision, GraphQL::Types::String, null: true, description: 'Revision of the tag.' field :short_revision, GraphQL::Types::String, null: true, description: 'Short revision of the tag.' field :total_size, GraphQL::Types::BigInt, null: true, description: 'Size of the tag.' - field :created_at, Types::TimeType, null: true, description: 'Timestamp when the tag was created.' - field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete this tag.' def can_delete Ability.allowed?(current_user, :destroy_container_image, object) diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb index 1fe5cf112f0..3cd3730010b 100644 --- a/app/graphql/types/container_repository_type.rb +++ b/app/graphql/types/container_repository_type.rb @@ -8,18 +8,18 @@ module Types authorize :read_container_image + field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete the container repository.' + field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.' + field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.' + field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.' field :id, GraphQL::Types::ID, null: false, description: 'ID of the container repository.' + field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.' field :name, GraphQL::Types::String, null: false, description: 'Name of the container repository.' field :path, GraphQL::Types::String, null: false, description: 'Path of the container repository.' - field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.' - field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.' - field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.' - field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.' - field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.' + field :project, Types::ProjectType, null: false, description: 'Project of the container registry.' field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.' field :tags_count, GraphQL::Types::Int, null: false, description: 'Number of tags associated with this image.' - field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete the container repository.' - field :project, Types::ProjectType, null: false, description: 'Project of the container registry.' + field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.' def can_delete Ability.allowed?(current_user, :update_container_image, object) diff --git a/app/graphql/types/dependency_proxy/blob_type.rb b/app/graphql/types/dependency_proxy/blob_type.rb index f5a78fbb3ba..b5cebe516aa 100644 --- a/app/graphql/types/dependency_proxy/blob_type.rb +++ b/app/graphql/types/dependency_proxy/blob_type.rb @@ -9,8 +9,8 @@ module Types authorize :read_dependency_proxy field :created_at, Types::TimeType, null: false, description: 'Date of creation.' - field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' field :file_name, GraphQL::Types::String, null: false, description: 'Name of the blob.' field :size, GraphQL::Types::String, null: false, description: 'Size of the blob file.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' end end diff --git a/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb b/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb index 29bba7122d0..9ab7c50998d 100644 --- a/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb +++ b/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb @@ -8,9 +8,9 @@ module Types authorize :read_dependency_proxy + field :created_at, Types::TimeType, null: true, description: 'Timestamp of creation.' field :enabled, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the policy is enabled or disabled.' field :ttl, GraphQL::Types::Int, null: true, description: 'Number of days to retain a cached image file.' - field :created_at, Types::TimeType, null: true, description: 'Timestamp of creation.' field :updated_at, Types::TimeType, null: true, description: 'Timestamp of the most recent update.' end end diff --git a/app/graphql/types/dependency_proxy/manifest_type.rb b/app/graphql/types/dependency_proxy/manifest_type.rb index ef9f730df43..ab22f540f48 100644 --- a/app/graphql/types/dependency_proxy/manifest_type.rb +++ b/app/graphql/types/dependency_proxy/manifest_type.rb @@ -8,13 +8,13 @@ module Types authorize :read_dependency_proxy - field :id, ::Types::GlobalIDType[::DependencyProxy::Manifest], null: false, description: 'ID of the manifest.' field :created_at, Types::TimeType, null: false, description: 'Date of creation.' - field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' + field :digest, GraphQL::Types::String, null: false, description: 'Digest of the manifest.' field :file_name, GraphQL::Types::String, null: false, description: 'Name of the manifest.' + field :id, ::Types::GlobalIDType[::DependencyProxy::Manifest], null: false, description: 'ID of the manifest.' field :image_name, GraphQL::Types::String, null: false, description: 'Name of the image.' field :size, GraphQL::Types::String, null: false, description: 'Size of the manifest file.' - field :digest, GraphQL::Types::String, null: false, description: 'Digest of the manifest.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' def image_name object.file_name.chomp(File.extname(object.file_name)) diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb index 570eac907f3..91978aa37b0 100644 --- a/app/graphql/types/design_management/design_collection_type.rb +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -8,10 +8,10 @@ module Types authorize :read_design - field :project, Types::ProjectType, null: false, - description: 'Project associated with the design collection.' field :issue, Types::IssueType, null: false, description: 'Issue associated with the design collection.' + field :project, Types::ProjectType, null: false, + description: 'Project associated with the design collection.' field :designs, Types::DesignManagement::DesignType.connection_type, diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb index 75f1aaa8c60..364f72a519f 100644 --- a/app/graphql/types/design_management/design_fields.rb +++ b/app/graphql/types/design_management/design_fields.rb @@ -62,7 +62,7 @@ module Types def cached_actions_for_version(version) Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do - version.actions.to_h { |dv| [dv.design_id, dv] } + version.actions.index_by(&:design_id) end end diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb index 2f40bf5ebfd..4c0b1162306 100644 --- a/app/graphql/types/design_management/design_type.rb +++ b/app/graphql/types/design_management/design_type.rb @@ -13,6 +13,12 @@ module Types implements(Types::Notes::NoteableInterface) implements(Types::DesignManagement::DesignFields) implements(Types::CurrentUserTodos) + implements(Types::TodoableInterface) + + field :web_url, + GraphQL::Types::String, + null: false, + description: 'URL of the design.' field :versions, Types::DesignManagement::VersionType.connection_type, @@ -40,6 +46,10 @@ module Types def request_cache_base_key self.class.name end + + def web_url + Gitlab::UrlBuilder.build(object) + end end end end diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb index cdcff1a7e34..c5c75105fda 100644 --- a/app/graphql/types/diff_paths_input_type.rb +++ b/app/graphql/types/diff_paths_input_type.rb @@ -2,9 +2,9 @@ module Types class DiffPathsInputType < BaseInputObject - argument :old_path, GraphQL::Types::String, required: false, - description: 'Path of the file on the start SHA.' argument :new_path, GraphQL::Types::String, required: false, description: 'Path of the file on the HEAD SHA.' + argument :old_path, GraphQL::Types::String, required: false, + description: 'Path of the file on the start SHA.' end end diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb index b19d09c789c..a03d72a4dc2 100644 --- a/app/graphql/types/diff_refs_type.rb +++ b/app/graphql/types/diff_refs_type.rb @@ -6,10 +6,10 @@ module Types class DiffRefsType < BaseObject graphql_name 'DiffRefs' - field :head_sha, GraphQL::Types::String, null: false, - description: 'SHA of the HEAD at the time the comment was made.' field :base_sha, GraphQL::Types::String, null: true, description: 'Merge base of the branch the comment was made on.' + field :head_sha, GraphQL::Types::String, null: false, + description: 'SHA of the HEAD at the time the comment was made.' field :start_sha, GraphQL::Types::String, null: false, description: 'SHA of the branch being compared against.' end diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb index 079c73d0759..95705ddecf3 100644 --- a/app/graphql/types/diff_stats_summary_type.rb +++ b/app/graphql/types/diff_stats_summary_type.rb @@ -10,10 +10,10 @@ module Types field :additions, GraphQL::Types::Int, null: false, description: 'Number of lines added.' - field :deletions, GraphQL::Types::Int, null: false, - description: 'Number of lines deleted.' field :changes, GraphQL::Types::Int, null: false, description: 'Number of lines changed.' + field :deletions, GraphQL::Types::Int, null: false, + description: 'Number of lines deleted.' field :file_count, GraphQL::Types::Int, null: false, description: 'Number of files changed.' diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb index 60aacca8ce5..da366fec8c3 100644 --- a/app/graphql/types/diff_stats_type.rb +++ b/app/graphql/types/diff_stats_type.rb @@ -8,12 +8,12 @@ module Types description 'Changes to a single file' - field :path, GraphQL::Types::String, null: false, - description: 'File path, relative to repository root.' field :additions, GraphQL::Types::Int, null: false, description: 'Number of lines added to this file.' field :deletions, GraphQL::Types::Int, null: false, description: 'Number of lines deleted from this file.' + field :path, GraphQL::Types::String, null: false, + description: 'File path, relative to repository root.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index 826ae61a1a3..b19ab80f96d 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -10,46 +10,68 @@ module Types authorize :read_sentry_issue - field :id, GraphQL::Types::ID, - null: false, - description: 'ID (global ID) of the error.' - field :integrated, GraphQL::Types::Boolean, - null: true, - description: 'Error tracking backend.' - field :sentry_id, GraphQL::Types::String, - method: :id, - null: false, - description: 'ID (Sentry ID) of the error.' - field :title, GraphQL::Types::String, + field :count, GraphQL::Types::Int, null: false, - description: 'Title of the error.' - field :type, GraphQL::Types::String, + description: 'Count of occurrences.' + field :culprit, GraphQL::Types::String, null: false, - description: 'Type of the error.' - field :user_count, GraphQL::Types::Int, + description: 'Culprit of the error.' + field :external_base_url, GraphQL::Types::String, null: false, - description: 'Count of users affected by the error.' - field :count, GraphQL::Types::Int, + description: 'External Base URL of the Sentry Instance.' + field :external_url, GraphQL::Types::String, null: false, - description: 'Count of occurrences.' + description: 'External URL of the error.' + field :first_release_last_commit, GraphQL::Types::String, + null: true, + description: 'Commit the error was first seen.' + field :first_release_short_version, GraphQL::Types::String, + null: true, + description: 'Release short version the error was first seen.' + field :first_release_version, GraphQL::Types::String, + null: true, + description: 'Release version the error was first seen.' field :first_seen, Types::TimeType, null: false, description: 'Timestamp when the error was first seen.' + field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], + null: false, + description: 'Last 24hr stats of the error.' + field :gitlab_commit, GraphQL::Types::String, + null: true, + description: 'GitLab commit SHA attributed to the Error based on the release version.' + field :gitlab_commit_path, GraphQL::Types::String, + null: true, + description: 'Path to the GitLab page for the GitLab commit attributed to the error.' + field :gitlab_issue_path, GraphQL::Types::String, + method: :gitlab_issue, + null: true, + description: 'URL of GitLab Issue.' + field :id, GraphQL::Types::ID, + null: false, + description: 'ID (global ID) of the error.' + field :integrated, GraphQL::Types::Boolean, + null: true, + description: 'Error tracking backend.' + field :last_release_last_commit, GraphQL::Types::String, + null: true, + description: 'Commit the error was last seen.' + field :last_release_short_version, GraphQL::Types::String, + null: true, + description: 'Release short version the error was last seen.' + field :last_release_version, GraphQL::Types::String, + null: true, + description: 'Release version the error was last seen.' field :last_seen, Types::TimeType, null: false, description: 'Timestamp when the error was last seen.' field :message, GraphQL::Types::String, null: true, description: 'Sentry metadata message of the error.' - field :culprit, GraphQL::Types::String, - null: false, - description: 'Culprit of the error.' - field :external_base_url, GraphQL::Types::String, - null: false, - description: 'External Base URL of the Sentry Instance.' - field :external_url, GraphQL::Types::String, + field :sentry_id, GraphQL::Types::String, + method: :id, null: false, - description: 'External URL of the error.' + description: 'ID (Sentry ID) of the error.' field :sentry_project_id, GraphQL::Types::ID, method: :project_id, null: false, @@ -68,40 +90,18 @@ module Types field :status, Types::ErrorTracking::SentryErrorStatusEnum, null: false, description: 'Status of the error.' - field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], - null: false, - description: 'Last 24hr stats of the error.' - field :first_release_last_commit, GraphQL::Types::String, - null: true, - description: 'Commit the error was first seen.' - field :last_release_last_commit, GraphQL::Types::String, - null: true, - description: 'Commit the error was last seen.' - field :first_release_short_version, GraphQL::Types::String, - null: true, - description: 'Release short version the error was first seen.' - field :last_release_short_version, GraphQL::Types::String, - null: true, - description: 'Release short version the error was last seen.' - field :first_release_version, GraphQL::Types::String, - null: true, - description: 'Release version the error was first seen.' - field :last_release_version, GraphQL::Types::String, - null: true, - description: 'Release version the error was last seen.' - field :gitlab_commit, GraphQL::Types::String, - null: true, - description: 'GitLab commit SHA attributed to the Error based on the release version.' - field :gitlab_commit_path, GraphQL::Types::String, - null: true, - description: 'Path to the GitLab page for the GitLab commit attributed to the error.' - field :gitlab_issue_path, GraphQL::Types::String, - method: :gitlab_issue, - null: true, - description: 'URL of GitLab Issue.' field :tags, Types::ErrorTracking::SentryErrorTagsType, null: false, description: 'Tags associated with the Sentry Error.' + field :title, GraphQL::Types::String, + null: false, + description: 'Title of the error.' + field :type, GraphQL::Types::String, + null: false, + description: 'Type of the error.' + field :user_count, GraphQL::Types::Int, + null: false, + description: 'Count of users affected by the error.' end end end diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb index 2d8c3d3d326..9790560929b 100644 --- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -8,15 +8,15 @@ module Types authorize :read_sentry_issue - field :errors, - description: "Collection of Sentry Errors.", - resolver: Resolvers::ErrorTracking::SentryErrorsResolver field :detailed_error, description: 'Detailed version of a Sentry error on the project.', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver field :error_stack_trace, description: 'Stack Trace of Sentry Error.', resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver + field :errors, + description: "Collection of Sentry Errors.", + resolver: Resolvers::ErrorTracking::SentryErrorsResolver field :external_url, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb index 49a1b1e0476..f67becb3774 100644 --- a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb @@ -6,12 +6,12 @@ module Types class SentryErrorFrequencyType < ::Types::BaseObject graphql_name 'SentryErrorFrequency' - field :time, Types::TimeType, - null: false, - description: "Time the error frequency stats were recorded." field :count, GraphQL::Types::Int, null: false, description: "Count of errors received since the previously recorded time." + field :time, Types::TimeType, + null: false, + description: "Time the error frequency stats were recorded." end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb index ad31854b30c..d4b806c4e1e 100644 --- a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb @@ -7,14 +7,14 @@ module Types graphql_name 'SentryErrorStackTraceContext' description 'An object context for a Sentry error stack trace' - field :line, - GraphQL::Types::Int, - null: false, - description: 'Line number of the context.' field :code, GraphQL::Types::String, null: false, description: 'Code number of the context.' + field :line, + GraphQL::Types::Int, + null: false, + description: 'Line number of the context.' def line object[0] diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb index e8f78004569..c33baa06052 100644 --- a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb @@ -7,18 +7,18 @@ module Types graphql_name 'SentryErrorStackTraceEntry' description 'An object containing a stack trace entry for a Sentry error' - field :function, GraphQL::Types::String, + field :col, GraphQL::Types::String, null: true, description: 'Function in which the Sentry error occurred.' - field :col, GraphQL::Types::String, + field :file_name, GraphQL::Types::String, + null: true, + description: 'File in which the Sentry error occurred.' + field :function, GraphQL::Types::String, null: true, description: 'Function in which the Sentry error occurred.' field :line, GraphQL::Types::String, null: true, description: 'Function in which the Sentry error occurred.' - field :file_name, GraphQL::Types::String, - null: true, - description: 'File in which the Sentry error occurred.' field :trace_context, [Types::ErrorTracking::SentryErrorStackTraceContextType], null: true, description: 'Context of the Sentry error.' diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb index dff52d77109..5c7aecf16ee 100644 --- a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb @@ -8,12 +8,12 @@ module Types authorize :read_sentry_issue - field :issue_id, GraphQL::Types::String, - null: false, - description: 'ID of the Sentry error.' field :date_received, GraphQL::Types::String, null: false, description: 'Time the stack trace was received by Sentry.' + field :issue_id, GraphQL::Types::String, + null: false, + description: 'ID of the Sentry error.' field :stack_trace_entries, [Types::ErrorTracking::SentryErrorStackTraceEntryType], null: false, description: 'Stack trace entries for the Sentry error.' diff --git a/app/graphql/types/error_tracking/sentry_error_type.rb b/app/graphql/types/error_tracking/sentry_error_type.rb index aaa6cbfb28f..5f871155737 100644 --- a/app/graphql/types/error_tracking/sentry_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_type.rb @@ -9,49 +9,34 @@ module Types present_using SentryErrorPresenter - field :id, GraphQL::Types::ID, - null: false, - description: 'ID (global ID) of the error.' - field :sentry_id, GraphQL::Types::String, - method: :id, - null: false, - description: 'ID (Sentry ID) of the error.' - field :first_seen, Types::TimeType, - null: false, - description: 'Timestamp when the error was first seen.' - field :last_seen, Types::TimeType, - null: false, - description: 'Timestamp when the error was last seen.' - field :title, GraphQL::Types::String, - null: false, - description: 'Title of the error.' - field :type, GraphQL::Types::String, - null: false, - description: 'Type of the error.' - field :user_count, GraphQL::Types::Int, - null: false, - description: 'Count of users affected by the error.' field :count, GraphQL::Types::Int, null: false, description: 'Count of occurrences.' - field :message, GraphQL::Types::String, - null: true, - description: 'Sentry metadata message of the error.' field :culprit, GraphQL::Types::String, null: false, description: 'Culprit of the error.' field :external_url, GraphQL::Types::String, null: false, description: 'External URL of the error.' - field :short_id, GraphQL::Types::String, - null: false, - description: 'Short ID (Sentry ID) of the error.' - field :status, Types::ErrorTracking::SentryErrorStatusEnum, + field :first_seen, Types::TimeType, null: false, - description: 'Status of the error.' + description: 'Timestamp when the error was first seen.' field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], null: false, description: 'Last 24hr stats of the error.' + field :id, GraphQL::Types::ID, + null: false, + description: 'ID (global ID) of the error.' + field :last_seen, Types::TimeType, + null: false, + description: 'Timestamp when the error was last seen.' + field :message, GraphQL::Types::String, + null: true, + description: 'Sentry metadata message of the error.' + field :sentry_id, GraphQL::Types::String, + method: :id, + null: false, + description: 'ID (Sentry ID) of the error.' field :sentry_project_id, GraphQL::Types::ID, method: :project_id, null: false, @@ -64,6 +49,21 @@ module Types method: :project_slug, null: false, description: 'Slug of the project affected by the error.' + field :short_id, GraphQL::Types::String, + null: false, + description: 'Short ID (Sentry ID) of the error.' + field :status, Types::ErrorTracking::SentryErrorStatusEnum, + null: false, + description: 'Status of the error.' + field :title, GraphQL::Types::String, + null: false, + description: 'Title of the error.' + field :type, GraphQL::Types::String, + null: false, + description: 'Type of the error.' + field :user_count, GraphQL::Types::Int, + null: false, + description: 'Count of users affected by the error.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb index 33f46c712f1..ed644a4b2c6 100644 --- a/app/graphql/types/evidence_type.rb +++ b/app/graphql/types/evidence_type.rb @@ -9,13 +9,13 @@ module Types present_using Releases::EvidencePresenter + field :collected_at, Types::TimeType, null: true, + description: 'Timestamp when the evidence was collected.' + field :filepath, GraphQL::Types::String, null: true, + description: 'URL from where the evidence can be downloaded.' field :id, GraphQL::Types::ID, null: false, description: 'ID of the evidence.' field :sha, GraphQL::Types::String, null: true, description: 'SHA1 ID of the evidence hash.' - field :filepath, GraphQL::Types::String, null: true, - description: 'URL from where the evidence can be downloaded.' - field :collected_at, Types::TimeType, null: true, - description: 'Timestamp when the evidence was collected.' end end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index c44c268b43f..4f92b5e8cc2 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -49,7 +49,11 @@ module Types # Construct a restricted type, that can only be inhabited by an ID of # a given model class. def self.[](model_class) - @id_types ||= {} + @id_types ||= { + # WorkItem has a special class as we want to allow IssueID + # on WorkItemID while we transition into work items + ::WorkItem => ::Types::WorkItemIdType + } @id_types[model_class] ||= Class.new(self) do model_name = model_class.name diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb index 26fefd51e08..2bbc0d34db6 100644 --- a/app/graphql/types/grafana_integration_type.rb +++ b/app/graphql/types/grafana_integration_type.rb @@ -6,14 +6,14 @@ module Types authorize :admin_operations - field :id, GraphQL::Types::ID, null: false, - description: 'Internal ID of the Grafana integration.' - field :grafana_url, GraphQL::Types::String, null: false, - description: 'URL for the Grafana host for the Grafana integration.' - field :enabled, GraphQL::Types::Boolean, null: false, - description: 'Indicates whether Grafana integration is enabled.' field :created_at, Types::TimeType, null: false, description: 'Timestamp of the issue\'s creation.' + field :enabled, GraphQL::Types::Boolean, null: false, + description: 'Indicates whether Grafana integration is enabled.' + field :grafana_url, GraphQL::Types::String, null: false, + description: 'URL for the Grafana host for the Grafana integration.' + field :id, GraphQL::Types::ID, null: false, + description: 'Internal ID of the Grafana integration.' field :updated_at, Types::TimeType, null: false, description: 'Timestamp of the issue\'s last activity.' end diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb index d68abc11bba..18242f7b8b1 100644 --- a/app/graphql/types/group_member_type.rb +++ b/app/graphql/types/group_member_type.rb @@ -13,6 +13,10 @@ module Types field :group, Types::GroupType, null: true, description: 'Group that a User is a member of.' + field :notification_email, + resolver: Resolvers::GroupMembers::NotificationEmailResolver, + description: "Group notification email for User. Only availble for admins." + def group Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 5f63aa20953..a94cd6fad20 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -209,8 +209,9 @@ module Types field :work_item_types, Types::WorkItems::TypeType.connection_type, resolver: Resolvers::WorkItems::TypesResolver, - description: 'Work item types available to the group.', - feature_flag: :work_items + description: 'Work item types available to the group.' \ + ' Returns `null` if `work_items` feature flag is disabled.' \ + ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.' def label(title:) BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args| diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index ee57961ee4a..07450c38616 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -8,6 +8,7 @@ module Types implements(Types::Notes::NoteableInterface) implements(Types::CurrentUserTodos) + implements(Types::TodoableInterface) authorize :read_issue @@ -15,16 +16,16 @@ module Types present_using IssuePresenter + field :description, GraphQL::Types::String, null: true, + description: 'Description of the issue.' field :id, GraphQL::Types::ID, null: false, description: "ID of the issue." field :iid, GraphQL::Types::ID, null: false, description: "Internal ID of the issue." - field :title, GraphQL::Types::String, null: false, - description: 'Title of the issue.' - field :description, GraphQL::Types::String, null: true, - description: 'Description of the issue.' field :state, IssueStateEnum, null: false, description: 'State of the issue.' + field :title, GraphQL::Types::String, null: false, + description: 'Title of the issue.' field :reference, GraphQL::Types::String, null: false, description: 'Internal reference of the issue. Returned in shortened format by default.', @@ -47,52 +48,52 @@ module Types field :milestone, Types::MilestoneType, null: true, description: 'Milestone of the issue.' - field :due_date, Types::TimeType, null: true, - description: 'Due date of the issue.' field :confidential, GraphQL::Types::Boolean, null: false, description: 'Indicates the issue is confidential.' + field :discussion_locked, GraphQL::Types::Boolean, null: false, + description: 'Indicates discussion is locked on the issue.' + field :due_date, Types::TimeType, null: true, + description: 'Due date of the issue.' field :hidden, GraphQL::Types::Boolean, null: true, resolver_method: :hidden?, description: 'Indicates the issue is hidden because the author has been banned. ' \ 'Will always return `null` if `ban_user_feature_flag` feature flag is disabled.' - field :discussion_locked, GraphQL::Types::Boolean, null: false, - description: 'Indicates discussion is locked on the issue.' - field :upvotes, GraphQL::Types::Int, null: false, - description: 'Number of upvotes the issue has received.' field :downvotes, GraphQL::Types::Int, null: false, description: 'Number of downvotes the issue has received.' field :merge_requests_count, GraphQL::Types::Int, null: false, description: 'Number of merge requests that close the issue on merge.', resolver: Resolvers::MergeRequestsCountResolver - field :user_notes_count, GraphQL::Types::Int, null: false, - description: 'Number of user notes of the issue.', - resolver: Resolvers::UserNotesCountResolver + field :relative_position, GraphQL::Types::Int, null: true, + description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' + field :upvotes, GraphQL::Types::Int, null: false, + description: 'Number of upvotes the issue has received.' field :user_discussions_count, GraphQL::Types::Int, null: false, description: 'Number of user discussions in the issue.', resolver: Resolvers::UserDiscussionsCountResolver + field :user_notes_count, GraphQL::Types::Int, null: false, + description: 'Number of user notes of the issue.', + resolver: Resolvers::UserNotesCountResolver field :web_path, GraphQL::Types::String, null: false, method: :issue_path, description: 'Web path of the issue.' field :web_url, GraphQL::Types::String, null: false, description: 'Web URL of the issue.' - field :relative_position, GraphQL::Types::Int, null: true, - description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' - field :participants, Types::UserType.connection_type, null: true, complexity: 5, - description: 'List of participants in the issue.', - resolver: Resolvers::Users::ParticipantsResolver field :emails_disabled, GraphQL::Types::Boolean, null: false, method: :project_emails_disabled?, description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.' + field :human_time_estimate, GraphQL::Types::String, null: true, + description: 'Human-readable time estimate of the issue.' + field :human_total_time_spent, GraphQL::Types::String, null: true, + description: 'Human-readable total time reported as spent on the issue.' + field :participants, Types::UserType.connection_type, null: true, complexity: 5, + description: 'List of participants in the issue.', + resolver: Resolvers::Users::ParticipantsResolver field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, description: 'Indicates the currently logged in user is subscribed to the issue.' field :time_estimate, GraphQL::Types::Int, null: false, description: 'Time estimate of the issue.' field :total_time_spent, GraphQL::Types::Int, null: false, description: 'Total time reported as spent on the issue.' - field :human_time_estimate, GraphQL::Types::String, null: true, - description: 'Human-readable time estimate of the issue.' - field :human_total_time_spent, GraphQL::Types::String, null: true, - description: 'Human-readable total time reported as spent on the issue.' field :closed_at, Types::TimeType, null: true, description: 'Timestamp of when the issue was closed.' diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb index 73e090a4802..fc39efd2493 100644 --- a/app/graphql/types/issues/negated_issue_filter_input_type.rb +++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb @@ -5,6 +5,15 @@ module Types class NegatedIssueFilterInputType < BaseInputObject graphql_name 'NegatedIssueFilterInput' + argument :assignee_id, GraphQL::Types::String, + required: false, + description: 'ID of a user not assigned to the issues.' + argument :assignee_usernames, [GraphQL::Types::String], + required: false, + description: 'Usernames of users not assigned to the issue.' + argument :author_username, GraphQL::Types::String, + required: false, + description: "Username of a user who didn't author the issue." argument :iids, [GraphQL::Types::String], required: false, description: 'List of IIDs of issues to exclude. For example, `[1, 2]`.' @@ -14,24 +23,15 @@ module Types argument :milestone_title, [GraphQL::Types::String], required: false, description: 'Milestone not applied to this issue.' - argument :release_tag, [GraphQL::Types::String], - required: false, - description: "Release tag not associated with the issue's milestone. Ignored when parent is a group." - argument :author_username, GraphQL::Types::String, - required: false, - description: "Username of a user who didn't author the issue." - argument :assignee_usernames, [GraphQL::Types::String], - required: false, - description: 'Usernames of users not assigned to the issue.' - argument :assignee_id, GraphQL::Types::String, - required: false, - description: 'ID of a user not assigned to the issues.' argument :milestone_wildcard_id, ::Types::NegatedMilestoneWildcardIdEnum, required: false, description: 'Filter by negated milestone wildcard values.' argument :my_reaction_emoji, GraphQL::Types::String, required: false, description: 'Filter by reaction emoji applied by the current user.' + argument :release_tag, [GraphQL::Types::String], + required: false, + description: "Release tag not associated with the issue's milestone. Ignored when parent is a group." argument :types, [Types::IssueTypeEnum], as: :issue_types, description: 'Filters out issues by the given issue types.', diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb index 0cdfc178350..8477f0b97f0 100644 --- a/app/graphql/types/jira_import_type.rb +++ b/app/graphql/types/jira_import_type.rb @@ -8,16 +8,16 @@ module Types field :created_at, Types::TimeType, null: true, description: 'Timestamp of when the Jira import was created.' + field :failed_to_import_count, GraphQL::Types::Int, null: false, + description: 'Count of issues that failed to import.' + field :imported_issues_count, GraphQL::Types::Int, null: false, + description: 'Count of issues that were successfully imported.' + field :jira_project_key, GraphQL::Types::String, null: false, + description: 'Project key for the imported Jira project.' field :scheduled_at, Types::TimeType, null: true, description: 'Timestamp of when the Jira import was scheduled.' field :scheduled_by, Types::UserType, null: true, description: 'User that started the Jira import.' - field :jira_project_key, GraphQL::Types::String, null: false, - description: 'Project key for the imported Jira project.' - field :imported_issues_count, GraphQL::Types::Int, null: false, - description: 'Count of issues that were successfully imported.' - field :failed_to_import_count, GraphQL::Types::Int, null: false, - description: 'Count of issues that failed to import.' field :total_issue_count, GraphQL::Types::Int, null: false, description: 'Total count of issues that were attempted to import.' end diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb index 6e1c349726c..aba05385ece 100644 --- a/app/graphql/types/jira_user_type.rb +++ b/app/graphql/types/jira_user_type.rb @@ -6,18 +6,18 @@ module Types class JiraUserType < BaseObject graphql_name 'JiraUser' + field :gitlab_id, GraphQL::Types::Int, null: true, + description: 'ID of the matched GitLab user.' + field :gitlab_name, GraphQL::Types::String, null: true, + description: 'Name of the matched GitLab user.' + field :gitlab_username, GraphQL::Types::String, null: true, + description: 'Username of the matched GitLab user.' field :jira_account_id, GraphQL::Types::String, null: false, description: 'Account ID of the Jira user.' field :jira_display_name, GraphQL::Types::String, null: false, description: 'Display name of the Jira user.' field :jira_email, GraphQL::Types::String, null: true, description: 'Email of the Jira user, returned only for users with public emails.' - field :gitlab_id, GraphQL::Types::Int, null: true, - description: 'ID of the matched GitLab user.' - field :gitlab_username, GraphQL::Types::String, null: true, - description: 'Username of the matched GitLab user.' - field :gitlab_name, GraphQL::Types::String, null: true, - description: 'Name of the matched GitLab user.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb index 37fd05370c0..4df2e27b45a 100644 --- a/app/graphql/types/jira_users_mapping_input_type.rb +++ b/app/graphql/types/jira_users_mapping_input_type.rb @@ -4,13 +4,13 @@ module Types class JiraUsersMappingInputType < BaseInputObject graphql_name 'JiraUsersMappingInputType' - argument :jira_account_id, - GraphQL::Types::String, - required: true, - description: 'Jira account ID of the user.' argument :gitlab_id, GraphQL::Types::Int, required: false, description: 'ID of the GitLab user.' + argument :jira_account_id, + GraphQL::Types::String, + required: true, + description: 'Jira account ID of the user.' end end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 5a10bcfee74..b5b3e20bcbc 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -8,18 +8,18 @@ module Types authorize :read_label - field :id, GraphQL::Types::ID, null: false, - description: 'Label ID.' - field :description, GraphQL::Types::String, null: true, - description: 'Description of the label (Markdown rendered as HTML for caching).' - field :title, GraphQL::Types::String, null: false, - description: 'Content of the label.' field :color, GraphQL::Types::String, null: false, description: 'Background color of the label.' - field :text_color, GraphQL::Types::String, null: false, - description: 'Text color of the label.' field :created_at, Types::TimeType, null: false, description: 'When this label was created.' + field :description, GraphQL::Types::String, null: true, + description: 'Description of the label (Markdown rendered as HTML for caching).' + field :id, GraphQL::Types::ID, null: false, + description: 'Label ID.' + field :text_color, GraphQL::Types::String, null: false, + description: 'Text color of the label.' + field :title, GraphQL::Types::String, null: false, + description: 'Content of the label.' field :updated_at, Types::TimeType, null: false, description: 'When this label was last updated.' diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index ea05671c79c..af198d03c3f 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -8,6 +8,7 @@ module Types implements(Types::Notes::NoteableInterface) implements(Types::CurrentUserTodos) + implements(Types::TodoableInterface) authorize :read_merge_request @@ -15,94 +16,96 @@ module Types present_using MergeRequestPresenter + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the merge request was created.' + field :description, GraphQL::Types::String, null: true, + description: 'Description of the merge request (Markdown rendered as HTML for caching).' + field :diff_head_sha, GraphQL::Types::String, null: true, + description: 'Diff head SHA of the merge request.' + field :diff_refs, Types::DiffRefsType, null: true, + description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.' + field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true, + description: 'Details about which files were changed in this merge request.' do + argument :path, GraphQL::Types::String, required: false, description: 'Specific file path.' + end + field :draft, GraphQL::Types::Boolean, method: :draft?, null: false, + description: 'Indicates if the merge request is a draft.' field :id, GraphQL::Types::ID, null: false, description: 'ID of the merge request.' field :iid, GraphQL::Types::String, null: false, description: 'Internal ID of the merge request.' - field :title, GraphQL::Types::String, null: false, - description: 'Title of the merge request.' - field :description, GraphQL::Types::String, null: true, - description: 'Description of the merge request (Markdown rendered as HTML for caching).' - field :state, MergeRequestStateEnum, null: false, - description: 'State of the merge request.' - field :created_at, Types::TimeType, null: false, - description: 'Timestamp of when the merge request was created.' - field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of when the merge request was last updated.' + field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true, + description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).' field :merged_at, Types::TimeType, null: true, complexity: 5, description: 'Timestamp of when the merge request was merged, null if not merged.' - field :source_project, Types::ProjectType, null: true, - description: 'Source project of the merge request.' - field :target_project, Types::ProjectType, null: false, - description: 'Target project of the merge request.' - field :diff_refs, Types::DiffRefsType, null: true, - description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.' field :project, Types::ProjectType, null: false, description: 'Alias for target_project.' field :project_id, GraphQL::Types::Int, null: false, method: :target_project_id, description: 'ID of the merge request project.' - field :source_project_id, GraphQL::Types::Int, null: true, - description: 'ID of the merge request source project.' - field :target_project_id, GraphQL::Types::Int, null: false, - description: 'ID of the merge request target project.' field :source_branch, GraphQL::Types::String, null: false, description: 'Source branch of the merge request.' field :source_branch_protected, GraphQL::Types::Boolean, null: false, calls_gitaly: true, description: 'Indicates if the source branch is protected.' + field :source_project, Types::ProjectType, null: true, + description: 'Source project of the merge request.' + field :source_project_id, GraphQL::Types::Int, null: true, + description: 'ID of the merge request source project.' + field :state, MergeRequestStateEnum, null: false, + description: 'State of the merge request.' field :target_branch, GraphQL::Types::String, null: false, description: 'Target branch of the merge request.' - field :draft, GraphQL::Types::Boolean, method: :draft?, null: false, - description: 'Indicates if the merge request is a draft.' - field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).' - field :diff_head_sha, GraphQL::Types::String, null: true, - description: 'Diff head SHA of the merge request.' - field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true, - description: 'Details about which files were changed in this merge request.' do - argument :path, GraphQL::Types::String, required: false, description: 'Specific file path.' - end + field :target_project, Types::ProjectType, null: false, + description: 'Target project of the merge request.' + field :target_project_id, GraphQL::Types::Int, null: false, + description: 'ID of the merge request target project.' + field :title, GraphQL::Types::String, null: false, + description: 'Title of the merge request.' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of when the merge request was last updated.' + field :allow_collaboration, GraphQL::Types::Boolean, null: true, + description: 'Indicates if members of the target project can push to the fork.' + field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'Default merge commit message of the merge request.' + field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true, + description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.', + deprecated: { reason: 'Define merge commit template in project and use `defaultMergeCommitMessage`', milestone: '14.5' } + field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'Default squash commit message of the merge request.' field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true, description: 'Summary of which files were changed in this merge request.' - field :merge_commit_sha, GraphQL::Types::String, null: true, - description: 'SHA of the merge request commit (set once merged).' - field :user_notes_count, GraphQL::Types::Int, null: true, - description: 'User notes count of the merge request.', - resolver: Resolvers::UserNotesCountResolver - field :user_discussions_count, GraphQL::Types::Int, null: true, - description: 'Number of user discussions in the merge request.', - resolver: Resolvers::UserDiscussionsCountResolver - field :should_remove_source_branch, GraphQL::Types::Boolean, method: :should_remove_source_branch?, null: true, - description: 'Indicates if the source branch of the merge request will be deleted after merge.' + field :diverged_from_target_branch, GraphQL::Types::Boolean, + null: false, calls_gitaly: true, + method: :diverged_from_target_branch?, + description: 'Indicates if the source branch is behind the target branch.' + field :downvotes, GraphQL::Types::Int, null: false, + description: 'Number of downvotes for the merge request.' field :force_remove_source_branch, GraphQL::Types::Boolean, method: :force_remove_source_branch?, null: true, description: 'Indicates if the project settings will lead to source branch deletion after merge.' + field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true, + description: 'Commit SHA of the merge request if merge is in progress.' + field :merge_commit_sha, GraphQL::Types::String, null: true, + description: 'SHA of the merge request commit (set once merged).' + field :merge_error, GraphQL::Types::String, null: true, + description: 'Error message due to a merge error.' + field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false, + description: 'Indicates if a merge is currently occurring.' field :merge_status, GraphQL::Types::String, method: :public_merge_status, null: true, description: 'Status of the merge request.', deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' } field :merge_status_enum, ::Types::MergeRequests::MergeStatusEnum, method: :public_merge_status, null: true, description: 'Merge status of the merge request.' - field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true, - description: 'Commit SHA of the merge request if merge is in progress.' - field :merge_error, GraphQL::Types::String, null: true, - description: 'Error message due to a merge error.' - field :allow_collaboration, GraphQL::Types::Boolean, null: true, - description: 'Indicates if members of the target project can push to the fork.' - field :should_be_rebased, GraphQL::Types::Boolean, method: :should_be_rebased?, null: false, calls_gitaly: true, - description: 'Indicates if the merge request will be rebased.' + field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, + description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.' field :rebase_commit_sha, GraphQL::Types::String, null: true, description: 'Rebase commit SHA of the merge request.' field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true, description: 'Indicates if there is a rebase currently in progress for the merge request.' - field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Default merge commit message of the merge request.' - field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true, - description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.', - deprecated: { reason: 'Define merge commit template in project and use `defaultMergeCommitMessage`', milestone: '14.5' } - field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Default squash commit message of the merge request.' - field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false, - description: 'Indicates if a merge is currently occurring.' + field :should_be_rebased, GraphQL::Types::Boolean, method: :should_be_rebased?, null: false, calls_gitaly: true, + description: 'Indicates if the merge request will be rebased.' + field :should_remove_source_branch, GraphQL::Types::Boolean, method: :should_remove_source_branch?, null: true, + description: 'Indicates if the source branch of the merge request will be deleted after merge.' field :source_branch_exists, GraphQL::Types::Boolean, null: false, calls_gitaly: true, method: :source_branch_exists?, @@ -111,18 +114,16 @@ module Types null: false, calls_gitaly: true, method: :target_branch_exists?, description: 'Indicates if the target branch of the merge request exists.' - field :diverged_from_target_branch, GraphQL::Types::Boolean, - null: false, calls_gitaly: true, - method: :diverged_from_target_branch?, - description: 'Indicates if the source branch is behind the target branch.' - field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, - description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.' - field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL of the merge request.' field :upvotes, GraphQL::Types::Int, null: false, description: 'Number of upvotes for the merge request.' - field :downvotes, GraphQL::Types::Int, null: false, - description: 'Number of downvotes for the merge request.' + field :user_discussions_count, GraphQL::Types::Int, null: true, + description: 'Number of user discussions in the merge request.', + resolver: Resolvers::UserDiscussionsCountResolver + field :user_notes_count, GraphQL::Types::Int, null: true, + description: 'User notes count of the merge request.', + resolver: Resolvers::UserNotesCountResolver + field :web_url, GraphQL::Types::String, null: true, + description: 'Web URL of the merge request.' field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, description: 'Pipeline running on the branch HEAD of the merge request.' @@ -131,84 +132,82 @@ module Types description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.', resolver: Resolvers::MergeRequestPipelinesResolver - field :milestone, Types::MilestoneType, null: true, - description: 'Milestone of the merge request.' field :assignees, type: Types::MergeRequests::AssigneeType.connection_type, null: true, complexity: 5, description: 'Assignees of the merge request.' - field :reviewers, - type: Types::MergeRequests::ReviewerType.connection_type, - null: true, - complexity: 5, - description: 'Users from whom a review has been requested.' - field :author, Types::UserType, null: true, + field :author, Types::MergeRequests::AuthorType, null: true, description: 'User who created this merge request.' - field :participants, Types::UserType.connection_type, null: true, complexity: 15, - description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.', - resolver: Resolvers::Users::ParticipantsResolver - field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, - description: 'Indicates if the currently logged in user is subscribed to this merge request.' - field :labels, Types::LabelType.connection_type, null: true, complexity: 5, - description: 'Labels of the merge request.' field :discussion_locked, GraphQL::Types::Boolean, description: 'Indicates if comments on the merge request are locked to members only.', null: false - field :time_estimate, GraphQL::Types::Int, null: false, - description: 'Time estimate of the merge request.' - field :total_time_spent, GraphQL::Types::Int, null: false, - description: 'Total time reported as spent on the merge request.' field :human_time_estimate, GraphQL::Types::String, null: true, description: 'Human-readable time estimate of the merge request.' field :human_total_time_spent, GraphQL::Types::String, null: true, description: 'Human-readable total time reported as spent on the merge request.' + field :labels, Types::LabelType.connection_type, null: true, complexity: 5, + description: 'Labels of the merge request.' + field :milestone, Types::MilestoneType, null: true, + description: 'Milestone of the merge request.' + field :participants, Types::MergeRequests::ParticipantType.connection_type, null: true, complexity: 15, + description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.', + resolver: Resolvers::Users::ParticipantsResolver field :reference, GraphQL::Types::String, null: false, method: :to_reference, description: 'Internal reference of the merge request. Returned in shortened format by default.' do argument :full, GraphQL::Types::Boolean, required: false, default_value: false, description: 'Boolean option specifying whether the reference should be returned in full.' end - field :task_completion_status, Types::TaskCompletionStatus, null: false, - description: Types::TaskCompletionStatus.description + field :auto_merge_enabled, GraphQL::Types::Boolean, null: false, + description: 'Indicates if auto merge is enabled for the merge request.' field :commit_count, GraphQL::Types::Int, null: true, method: :commits_count, description: 'Number of commits in the merge request.' field :conflicts, GraphQL::Types::Boolean, null: false, method: :cannot_be_merged?, description: 'Indicates if the merge request has conflicts.' - field :auto_merge_enabled, GraphQL::Types::Boolean, null: false, - description: 'Indicates if auto merge is enabled for the merge request.' + field :reviewers, + type: Types::MergeRequests::ReviewerType.connection_type, + null: true, + complexity: 5, + description: 'Users from whom a review has been requested.' + field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, + description: 'Indicates if the currently logged in user is subscribed to this merge request.' + field :task_completion_status, Types::TaskCompletionStatus, null: false, + description: Types::TaskCompletionStatus.description + field :time_estimate, GraphQL::Types::Int, null: false, + description: 'Time estimate of the merge request.' + field :total_time_spent, GraphQL::Types::Int, null: false, + description: 'Total time reported as spent on the merge request.' field :approved_by, Types::UserType.connection_type, null: true, - description: 'Users who approved the merge request.' - field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?, - description: 'Indicates if squash on merge is enabled.' - field :squash, GraphQL::Types::Boolean, null: false, - description: 'Indicates if squash on merge is enabled.' + description: 'Users who approved the merge request.', method: :approved_by_users + field :auto_merge_strategy, GraphQL::Types::String, null: true, + description: 'Selected auto merge strategy.' field :available_auto_merge_strategies, [GraphQL::Types::String], null: true, calls_gitaly: true, description: 'Array of available auto merge strategies.' - field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?, - description: 'Indicates if the merge request has CI.' - field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true, - description: 'Indicates if the merge request is mergeable.' field :commits, Types::CommitType.connection_type, null: true, calls_gitaly: true, description: 'Merge request commits.' + field :committers, Types::UserType.connection_type, null: true, complexity: 5, + calls_gitaly: true, description: 'Users who have added commits to the merge request.' field :commits_without_merge_commits, Types::CommitType.connection_type, null: true, calls_gitaly: true, description: 'Merge request commits excluding merge commits.' - field :security_auto_fix, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' - field :auto_merge_strategy, GraphQL::Types::String, null: true, - description: 'Selected auto merge strategy.' + field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?, + description: 'Indicates if the merge request has CI.' field :merge_user, Types::UserType, null: true, description: 'User who merged this merge request or set it to merge when pipeline succeeds.' + field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true, + description: 'Indicates if the merge request is mergeable.' + field :security_auto_fix, GraphQL::Types::Boolean, null: true, + description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' + field :squash, GraphQL::Types::Boolean, null: false, + description: 'Indicates if squash on merge is enabled.' + field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?, + description: 'Indicates if squash on merge is enabled.' field :timelogs, Types::TimelogType.connection_type, null: false, description: 'Timelogs on the merge request.' markdown_field :title_html, null: true markdown_field :description_html, null: true - def approved_by - object.approved_by_users - end - def user_notes_count BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_notes_count) do |ids, loader, args| counts = Note.count_for_collection(ids, 'MergeRequest').index_by(&:noteable_id) @@ -279,10 +278,6 @@ module Types object.author == User.security_bot end - def reviewers - object.reviewers - end - def merge_user object.metrics&.merged_by || object.merge_user end diff --git a/app/graphql/types/merge_requests/assignee_type.rb b/app/graphql/types/merge_requests/assignee_type.rb index 24321d057a3..a0ba74597ba 100644 --- a/app/graphql/types/merge_requests/assignee_type.rb +++ b/app/graphql/types/merge_requests/assignee_type.rb @@ -6,7 +6,6 @@ module Types graphql_name 'MergeRequestAssignee' description 'A user assigned to a merge request.' - include FindClosest include ::Types::MergeRequests::InteractsWithMergeRequest authorize :read_user diff --git a/app/graphql/types/merge_requests/author_type.rb b/app/graphql/types/merge_requests/author_type.rb new file mode 100644 index 00000000000..56ad3190547 --- /dev/null +++ b/app/graphql/types/merge_requests/author_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class AuthorType < ::Types::UserType + graphql_name 'MergeRequestAuthor' + description 'The author of the merge request.' + + include ::Types::MergeRequests::InteractsWithMergeRequest + + authorize :read_user + end + end +end diff --git a/app/graphql/types/merge_requests/participant_type.rb b/app/graphql/types/merge_requests/participant_type.rb new file mode 100644 index 00000000000..86d627097b2 --- /dev/null +++ b/app/graphql/types/merge_requests/participant_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class ParticipantType < ::Types::UserType + graphql_name 'MergeRequestParticipant' + description 'A user participating in a merge request.' + + include ::Types::MergeRequests::InteractsWithMergeRequest + + authorize :read_user + end + end +end diff --git a/app/graphql/types/merge_requests/reviewer_type.rb b/app/graphql/types/merge_requests/reviewer_type.rb index 11f7ceaf461..e5bc5812816 100644 --- a/app/graphql/types/merge_requests/reviewer_type.rb +++ b/app/graphql/types/merge_requests/reviewer_type.rb @@ -6,7 +6,6 @@ module Types graphql_name 'MergeRequestReviewer' description 'A user assigned to a merge request as a reviewer.' - include FindClosest include ::Types::MergeRequests::InteractsWithMergeRequest authorize :read_user diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb index 54a8a6ec40d..6a8d54b6c7d 100644 --- a/app/graphql/types/metadata/kas_type.rb +++ b/app/graphql/types/metadata/kas_type.rb @@ -9,10 +9,10 @@ module Types field :enabled, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the Kubernetes Agent Server is enabled.' - field :version, GraphQL::Types::String, null: true, - description: 'KAS version.' field :external_url, GraphQL::Types::String, null: true, description: 'URL used by the Agents to communicate with KAS.' + field :version, GraphQL::Types::String, null: true, + description: 'KAS version.' end end end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb index ed1e697711d..6fb141a50c9 100644 --- a/app/graphql/types/metadata_type.rb +++ b/app/graphql/types/metadata_type.rb @@ -6,11 +6,11 @@ module Types authorize :read_instance_metadata - field :version, GraphQL::Types::String, null: false, - description: 'Version.' - field :revision, GraphQL::Types::String, null: false, - description: 'Revision.' field :kas, ::Types::Metadata::KasType, null: false, description: 'Metadata about KAS.' + field :revision, GraphQL::Types::String, null: false, + description: 'Revision.' + field :version, GraphQL::Types::String, null: false, + description: 'Version.' end end diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb index 0c787476f54..0621cf4d674 100644 --- a/app/graphql/types/metrics/dashboards/annotation_type.rb +++ b/app/graphql/types/metrics/dashboards/annotation_type.rb @@ -14,17 +14,14 @@ module Types description: 'ID of the annotation.' field :panel_id, GraphQL::Types::String, null: true, - description: 'ID of a dashboard panel to which the annotation should be scoped.' + description: 'ID of a dashboard panel to which the annotation should be scoped.', + method: :panel_xid field :starting_at, Types::TimeType, null: true, description: 'Timestamp marking start of annotated time span.' field :ending_at, Types::TimeType, null: true, description: 'Timestamp marking end of annotated time span.' - - def panel_id - object.panel_xid - end end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 3c735231595..e6072820eea 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -126,8 +126,11 @@ module Types mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Echo mount_mutation Mutations::WorkItems::Create + mount_mutation Mutations::WorkItems::CreateFromTask mount_mutation Mutations::WorkItems::Delete mount_mutation Mutations::WorkItems::Update + mount_mutation Mutations::SavedReplies::Create + mount_mutation Mutations::SavedReplies::Update end end diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index d573cc9ded5..cb546bbf3ec 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -8,9 +8,9 @@ module Types authorize :read_package_settings - field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' - field :maven_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' - field :generic_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate generic packages are allowed for this namespace.' field :generic_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' + field :generic_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate generic packages are allowed for this namespace.' + field :maven_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' + field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' end end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index ba90fb06cb2..de6a078c6ef 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -9,24 +9,28 @@ module Types field :id, GraphQL::Types::ID, null: false, description: 'ID of the namespace.' - field :name, GraphQL::Types::String, null: false, - description: 'Name of the namespace.' - field :path, GraphQL::Types::String, null: false, - description: 'Path of the namespace.' field :full_name, GraphQL::Types::String, null: false, description: 'Full name of the namespace.' field :full_path, GraphQL::Types::ID, null: false, description: 'Full path of the namespace.' + field :name, GraphQL::Types::String, null: false, + description: 'Name of the namespace.' + field :path, GraphQL::Types::String, null: false, + description: 'Path of the namespace.' + + field :cross_project_pipeline_available, GraphQL::Types::Boolean, null: false, + resolver_method: :cross_project_pipeline_available?, + description: 'Indicates if the cross_project_pipeline feature is available for the namespace.' field :description, GraphQL::Types::String, null: true, description: 'Description of the namespace.' - field :visibility, GraphQL::Types::String, null: true, - description: 'Visibility of the namespace.' field :lfs_enabled, GraphQL::Types::Boolean, null: true, method: :lfs_enabled?, description: 'Indicates if Large File Storage (LFS) is enabled for namespace.' field :request_access_enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates if users can request access to namespace.' + field :visibility, GraphQL::Types::String, null: true, + description: 'Visibility of the namespace.' field :root_storage_statistics, Types::RootStorageStatisticsType, null: true, @@ -48,6 +52,10 @@ module Types markdown_field :description_html, null: true + def cross_project_pipeline_available? + object.licensed_feature_available?(:cross_project_pipelines) + end + def root_storage_statistics Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find end diff --git a/app/graphql/types/notes/diff_image_position_input_type.rb b/app/graphql/types/notes/diff_image_position_input_type.rb index d56c67bbec8..d535dea2e07 100644 --- a/app/graphql/types/notes/diff_image_position_input_type.rb +++ b/app/graphql/types/notes/diff_image_position_input_type.rb @@ -5,14 +5,14 @@ module Types class DiffImagePositionInputType < DiffPositionBaseInputType graphql_name 'DiffImagePositionInput' + argument :height, GraphQL::Types::Int, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :height) + argument :width, GraphQL::Types::Int, required: true, + description: copy_field_description(Types::Notes::DiffPositionType, :width) argument :x, GraphQL::Types::Int, required: true, description: copy_field_description(Types::Notes::DiffPositionType, :x) argument :y, GraphQL::Types::Int, required: true, description: copy_field_description(Types::Notes::DiffPositionType, :y) - argument :width, GraphQL::Types::Int, required: true, - description: copy_field_description(Types::Notes::DiffPositionType, :width) - argument :height, GraphQL::Types::Int, required: true, - description: copy_field_description(Types::Notes::DiffPositionType, :height) end end end diff --git a/app/graphql/types/notes/diff_position_base_input_type.rb b/app/graphql/types/notes/diff_position_base_input_type.rb index e773fbbc8a1..2780dbab573 100644 --- a/app/graphql/types/notes/diff_position_base_input_type.rb +++ b/app/graphql/types/notes/diff_position_base_input_type.rb @@ -3,10 +3,10 @@ module Types module Notes class DiffPositionBaseInputType < BaseInputObject + argument :base_sha, GraphQL::Types::String, required: false, + description: copy_field_description(Types::DiffRefsType, :base_sha) argument :head_sha, GraphQL::Types::String, required: true, description: copy_field_description(Types::DiffRefsType, :head_sha) - argument :base_sha, GraphQL::Types::String, required: false, - description: copy_field_description(Types::DiffRefsType, :base_sha) argument :start_sha, GraphQL::Types::String, required: true, description: copy_field_description(Types::DiffRefsType, :start_sha) diff --git a/app/graphql/types/notes/diff_position_input_type.rb b/app/graphql/types/notes/diff_position_input_type.rb index 18ce6672d14..ccde4188f29 100644 --- a/app/graphql/types/notes/diff_position_input_type.rb +++ b/app/graphql/types/notes/diff_position_input_type.rb @@ -5,10 +5,10 @@ module Types class DiffPositionInputType < DiffPositionBaseInputType graphql_name 'DiffPositionInput' - argument :old_line, GraphQL::Types::Int, required: false, - description: copy_field_description(Types::Notes::DiffPositionType, :old_line) argument :new_line, GraphQL::Types::Int, required: false, - description: copy_field_description(Types::Notes::DiffPositionType, :new_line) + description: "#{copy_field_description(Types::Notes::DiffPositionType, :new_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field." + argument :old_line, GraphQL::Types::Int, required: false, + description: "#{copy_field_description(Types::Notes::DiffPositionType, :old_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field." end end end diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index 9c756d56b97..531bd0edac0 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -12,28 +12,28 @@ module Types field :file_path, GraphQL::Types::String, null: false, description: 'Path of the file that was changed.' - field :old_path, GraphQL::Types::String, null: true, - description: 'Path of the file on the start SHA.' field :new_path, GraphQL::Types::String, null: true, description: 'Path of the file on the HEAD SHA.' + field :old_path, GraphQL::Types::String, null: true, + description: 'Path of the file on the start SHA.' field :position_type, Types::Notes::PositionTypeEnum, null: false, description: 'Type of file the position refers to.' # Fields for text positions - field :old_line, GraphQL::Types::Int, null: true, - description: 'Line on start SHA that was changed.' field :new_line, GraphQL::Types::Int, null: true, description: 'Line on HEAD SHA that was changed.' + field :old_line, GraphQL::Types::Int, null: true, + description: 'Line on start SHA that was changed.' # Fields for image positions + field :height, GraphQL::Types::Int, null: true, + description: 'Total height of the image.' + field :width, GraphQL::Types::Int, null: true, + description: 'Total width of the image.' field :x, GraphQL::Types::Int, null: true, description: 'X position of the note.' field :y, GraphQL::Types::Int, null: true, description: 'Y position of the note.' - field :width, GraphQL::Types::Int, null: true, - description: 'Total width of the image.' - field :height, GraphQL::Types::Int, null: true, - description: 'Total height of the image.' def old_line object.old_line if object.on_text? diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb index ffe61c9ff88..89778b2a99a 100644 --- a/app/graphql/types/notes/discussion_type.rb +++ b/app/graphql/types/notes/discussion_type.rb @@ -11,16 +11,16 @@ module Types implements(Types::ResolvableInterface) - field :id, DiscussionID, null: false, - description: "ID of this discussion." - field :reply_id, DiscussionID, null: false, - description: 'ID used to reply to this discussion.' field :created_at, Types::TimeType, null: false, description: "Timestamp of the discussion's creation." - field :notes, Types::Notes::NoteType.connection_type, null: false, - description: 'All notes in the discussion.' + field :id, DiscussionID, null: false, + description: "ID of this discussion." field :noteable, Types::NoteableType, null: true, description: 'Object which the discussion belongs to.' + field :notes, Types::Notes::NoteType.connection_type, null: false, + description: 'All notes in the discussion.' + field :reply_id, DiscussionID, null: false, + description: 'ID used to reply to this discussion.' # DiscussionID.coerce_result is suitable here, but will always mark this # as being a 'Discussion'. Using `GlobalId.build` guarantees that we get diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 7314c137010..32f3ff7f556 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -33,17 +33,17 @@ module Types method: :note, description: 'Content of the note.' + field :confidential, GraphQL::Types::Boolean, null: true, + description: 'Indicates if this note is confidential.', + method: :confidential? field :created_at, Types::TimeType, null: false, description: 'Timestamp of the note creation.' - field :updated_at, Types::TimeType, null: false, - description: "Timestamp of the note's last activity." field :discussion, Types::Notes::DiscussionType, null: true, description: 'Discussion this note is a part of.' field :position, Types::Notes::DiffPositionType, null: true, description: 'Position of this note on a diff.' - field :confidential, GraphQL::Types::Boolean, null: true, - description: 'Indicates if this note is confidential.', - method: :confidential? + field :updated_at, Types::TimeType, null: false, + description: "Timestamp of the note's last activity." field :url, GraphQL::Types::String, null: true, description: 'URL to view this Note in the Web UI.' diff --git a/app/graphql/types/packages/composer/json_type.rb b/app/graphql/types/packages/composer/json_type.rb index 6c121043301..84f08adb021 100644 --- a/app/graphql/types/packages/composer/json_type.rb +++ b/app/graphql/types/packages/composer/json_type.rb @@ -8,9 +8,9 @@ module Types graphql_name 'PackageComposerJsonType' description 'Represents a composer JSON file' + field :license, GraphQL::Types::String, null: true, description: 'License set in the Composer JSON file.' field :name, GraphQL::Types::String, null: true, description: 'Name set in the Composer JSON file.' field :type, GraphQL::Types::String, null: true, description: 'Type set in the Composer JSON file.' - field :license, GraphQL::Types::String, null: true, description: 'License set in the Composer JSON file.' field :version, GraphQL::Types::String, null: true, description: 'Version set in the Composer JSON file.' end end diff --git a/app/graphql/types/packages/composer/metadatum_type.rb b/app/graphql/types/packages/composer/metadatum_type.rb index 092e729ec56..d28ee87b878 100644 --- a/app/graphql/types/packages/composer/metadatum_type.rb +++ b/app/graphql/types/packages/composer/metadatum_type.rb @@ -9,8 +9,8 @@ module Types authorize :read_package - field :target_sha, GraphQL::Types::String, null: false, description: 'Target SHA of the package.' field :composer_json, Types::Packages::Composer::JsonType, null: false, description: 'Data of the Composer JSON file.' + field :target_sha, GraphQL::Types::String, null: false, description: 'Target SHA of the package.' end end end diff --git a/app/graphql/types/packages/conan/file_metadatum_type.rb b/app/graphql/types/packages/conan/file_metadatum_type.rb index 9a26fd5de51..012e03ece8f 100644 --- a/app/graphql/types/packages/conan/file_metadatum_type.rb +++ b/app/graphql/types/packages/conan/file_metadatum_type.rb @@ -11,11 +11,11 @@ module Types authorize :read_package + field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false, description: 'Type of the Conan file.' + field :conan_package_reference, GraphQL::Types::String, null: true, description: 'Reference of the Conan package.' field :id, ::Types::GlobalIDType[::Packages::Conan::FileMetadatum], null: false, description: 'ID of the metadatum.' - field :recipe_revision, GraphQL::Types::String, null: false, description: 'Revision of the Conan recipe.' field :package_revision, GraphQL::Types::String, null: true, description: 'Revision of the package.' - field :conan_package_reference, GraphQL::Types::String, null: true, description: 'Reference of the Conan package.' - field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false, description: 'Type of the Conan file.' + field :recipe_revision, GraphQL::Types::String, null: false, description: 'Revision of the Conan recipe.' end end end diff --git a/app/graphql/types/packages/conan/metadatum_type.rb b/app/graphql/types/packages/conan/metadatum_type.rb index cdfd0aa4483..d410d6d6d33 100644 --- a/app/graphql/types/packages/conan/metadatum_type.rb +++ b/app/graphql/types/packages/conan/metadatum_type.rb @@ -9,13 +9,13 @@ module Types authorize :read_package - field :id, ::Types::GlobalIDType[::Packages::Conan::Metadatum], null: false, description: 'ID of the metadatum.' field :created_at, Types::TimeType, null: false, description: 'Date of creation.' - field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' - field :package_username, GraphQL::Types::String, null: false, description: 'Username of the Conan package.' + field :id, ::Types::GlobalIDType[::Packages::Conan::Metadatum], null: false, description: 'ID of the metadatum.' field :package_channel, GraphQL::Types::String, null: false, description: 'Channel of the Conan package.' + field :package_username, GraphQL::Types::String, null: false, description: 'Username of the Conan package.' field :recipe, GraphQL::Types::String, null: false, description: 'Recipe of the Conan package.' field :recipe_path, GraphQL::Types::String, null: false, description: 'Recipe path of the Conan package.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' end end end diff --git a/app/graphql/types/packages/helm/dependency_type.rb b/app/graphql/types/packages/helm/dependency_type.rb index 35598c2b1d7..72a47d0af51 100644 --- a/app/graphql/types/packages/helm/dependency_type.rb +++ b/app/graphql/types/packages/helm/dependency_type.rb @@ -9,14 +9,14 @@ module Types description 'Represents a Helm dependency' # Need to be synced with app/validators/json_schemas/helm_metadata.json#dependencies - field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.' - field :version, GraphQL::Types::String, null: true, description: 'Version of the dependency.' - field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.' + field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias field :condition, GraphQL::Types::String, null: true, description: 'Condition of the dependency.' - field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.' field :enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates the dependency is enabled.' field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.', hash_key: "import-values" # rubocop:disable Graphql/JSONType - field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias + field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.' + field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.' + field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.' + field :version, GraphQL::Types::String, null: true, description: 'Version of the dependency.' # field :alias` conflicts with a built-in method def resolve_alias diff --git a/app/graphql/types/packages/helm/maintainer_type.rb b/app/graphql/types/packages/helm/maintainer_type.rb index 6d25a26c46b..e029ff6fd94 100644 --- a/app/graphql/types/packages/helm/maintainer_type.rb +++ b/app/graphql/types/packages/helm/maintainer_type.rb @@ -9,8 +9,8 @@ module Types description 'Represents a Helm maintainer' # Need to be synced with app/validators/json_schemas/helm_metadata.json#maintainers - field :name, GraphQL::Types::String, null: true, description: 'Name of the maintainer.' field :email, GraphQL::Types::String, null: true, description: 'Email of the maintainer.' + field :name, GraphQL::Types::String, null: true, description: 'Name of the maintainer.' field :url, GraphQL::Types::String, null: true, description: 'URL of the maintainer.' end end diff --git a/app/graphql/types/packages/helm/metadata_type.rb b/app/graphql/types/packages/helm/metadata_type.rb index eeb3e8087a8..ccc5a3029cd 100644 --- a/app/graphql/types/packages/helm/metadata_type.rb +++ b/app/graphql/types/packages/helm/metadata_type.rb @@ -9,23 +9,23 @@ module Types description 'Represents the contents of a Helm Chart.yml file' # Need to be synced with app/validators/json_schemas/helm_metadata.json - field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.' - field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.' - field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.' - field :version, GraphQL::Types::String, null: false, description: 'Version of the chart.' - field :description, GraphQL::Types::String, null: true, description: 'Description of the chart.' - field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.' - field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.' - field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.' + field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType field :api_version, GraphQL::Types::String, null: false, description: 'API version of the chart.', hash_key: "apiVersion" - field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.' - field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.' field :app_version, GraphQL::Types::String, null: true, description: 'App version of the chart.', hash_key: "appVersion" + field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.' + field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.' field :deprecated, GraphQL::Types::Boolean, null: true, description: 'Indicates if the chart is deprecated.' - field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType + field :description, GraphQL::Types::String, null: true, description: 'Description of the chart.' + field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.' + field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.' + field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.' field :kube_version, GraphQL::Types::String, null: true, description: 'Kubernetes versions for the chart.', hash_key: "kubeVersion" - field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.' + field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.' + field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.' + field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.' + field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.' field :type, GraphQL::Types::String, null: true, description: 'Type of the chart.', hash_key: "appVersion" + field :version, GraphQL::Types::String, null: false, description: 'Version of the chart.' end end end diff --git a/app/graphql/types/packages/maven/metadatum_type.rb b/app/graphql/types/packages/maven/metadatum_type.rb index eb3829648d1..b59f5235d7b 100644 --- a/app/graphql/types/packages/maven/metadatum_type.rb +++ b/app/graphql/types/packages/maven/metadatum_type.rb @@ -9,13 +9,13 @@ module Types authorize :read_package - field :id, ::Types::GlobalIDType[::Packages::Maven::Metadatum], null: false, description: 'ID of the metadatum.' - field :created_at, Types::TimeType, null: false, description: 'Date of creation.' - field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' - field :path, GraphQL::Types::String, null: false, description: 'Path of the Maven package.' field :app_group, GraphQL::Types::String, null: false, description: 'App group of the Maven package.' - field :app_version, GraphQL::Types::String, null: true, description: 'App version of the Maven package.' field :app_name, GraphQL::Types::String, null: false, description: 'App name of the Maven package.' + field :app_version, GraphQL::Types::String, null: true, description: 'App version of the Maven package.' + field :created_at, Types::TimeType, null: false, description: 'Date of creation.' + field :id, ::Types::GlobalIDType[::Packages::Maven::Metadatum], null: false, description: 'ID of the metadatum.' + field :path, GraphQL::Types::String, null: false, description: 'Path of the Maven package.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' end end end diff --git a/app/graphql/types/packages/nuget/metadatum_type.rb b/app/graphql/types/packages/nuget/metadatum_type.rb index b58fd954a74..fd9f1039d3c 100644 --- a/app/graphql/types/packages/nuget/metadatum_type.rb +++ b/app/graphql/types/packages/nuget/metadatum_type.rb @@ -9,10 +9,10 @@ module Types authorize :read_package + field :icon_url, GraphQL::Types::String, null: true, description: 'Icon URL of the Nuget package.' field :id, ::Types::GlobalIDType[::Packages::Nuget::Metadatum], null: false, description: 'ID of the metadatum.' field :license_url, GraphQL::Types::String, null: true, description: 'License URL of the Nuget package.' field :project_url, GraphQL::Types::String, null: true, description: 'Project URL of the Nuget package.' - field :icon_url, GraphQL::Types::String, null: true, description: 'Icon URL of the Nuget package.' end end end diff --git a/app/graphql/types/packages/package_dependency_link_type.rb b/app/graphql/types/packages/package_dependency_link_type.rb index eceb8319748..8b1d4abf3ba 100644 --- a/app/graphql/types/packages/package_dependency_link_type.rb +++ b/app/graphql/types/packages/package_dependency_link_type.rb @@ -7,9 +7,9 @@ module Types description 'Represents a package dependency link' authorize :read_package - field :id, ::Types::GlobalIDType[::Packages::DependencyLink], null: false, description: 'ID of the dependency link.' - field :dependency_type, Types::Packages::PackageDependencyTypeEnum, null: false, description: 'Dependency type.' field :dependency, Types::Packages::PackageDependencyType, null: true, description: 'Dependency.' + field :dependency_type, Types::Packages::PackageDependencyTypeEnum, null: false, description: 'Dependency type.' + field :id, ::Types::GlobalIDType[::Packages::DependencyLink], null: false, description: 'ID of the dependency link.' field :metadata, Types::Packages::DependencyLinkMetadataType, null: true, description: 'Dependency link metadata.' # NOTE: This method must be kept in sync with the union diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb index f90a0992bf8..b058dc0ab0d 100644 --- a/app/graphql/types/packages/package_file_type.rb +++ b/app/graphql/types/packages/package_file_type.rb @@ -7,17 +7,17 @@ module Types description 'Represents a package file' authorize :read_package - field :id, ::Types::GlobalIDType[::Packages::PackageFile], null: false, description: 'ID of the file.' field :created_at, Types::TimeType, null: false, description: 'Created date.' - field :updated_at, Types::TimeType, null: false, description: 'Updated date.' - field :size, GraphQL::Types::String, null: false, description: 'Size of the package file.' - field :file_name, GraphQL::Types::String, null: false, description: 'Name of the package file.' field :download_path, GraphQL::Types::String, null: false, description: 'Download path of the package file.' field :file_md5, GraphQL::Types::String, null: true, description: 'Md5 of the package file.' - field :file_sha1, GraphQL::Types::String, null: true, description: 'Sha1 of the package file.' - field :file_sha256, GraphQL::Types::String, null: true, description: 'Sha256 of the package file.' field :file_metadata, Types::Packages::FileMetadataType, null: true, description: 'File metadata.' + field :file_name, GraphQL::Types::String, null: false, description: 'Name of the package file.' + field :file_sha1, GraphQL::Types::String, null: true, description: 'Sha1 of the package file.' + field :file_sha256, GraphQL::Types::String, null: true, description: 'Sha256 of the package file.' + field :id, ::Types::GlobalIDType[::Packages::PackageFile], null: false, description: 'ID of the file.' + field :size, GraphQL::Types::String, null: false, description: 'Size of the package file.' + field :updated_at, Types::TimeType, null: false, description: 'Updated date.' # NOTE: This method must be kept in sync with the union # type: `Types::Packages::FileMetadataType`. diff --git a/app/graphql/types/packages/package_tag_type.rb b/app/graphql/types/packages/package_tag_type.rb index f1f96c42e27..9d462e90b6f 100644 --- a/app/graphql/types/packages/package_tag_type.rb +++ b/app/graphql/types/packages/package_tag_type.rb @@ -7,9 +7,9 @@ module Types description 'Represents a package tag' authorize :read_package + field :created_at, Types::TimeType, null: false, description: 'Created date.' field :id, GraphQL::Types::ID, null: false, description: 'ID of the tag.' field :name, GraphQL::Types::String, null: false, description: 'Name of the tag.' - field :created_at, Types::TimeType, null: false, description: 'Created date.' field :updated_at, Types::TimeType, null: false, description: 'Updated date.' end end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index d1312cb963d..1155be28e08 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -13,23 +13,23 @@ module Types field :id, ::Types::GlobalIDType[::Packages::Package], null: false, description: 'ID of the package.' - field :name, GraphQL::Types::String, null: false, description: 'Name of the package.' + field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.' field :created_at, Types::TimeType, null: false, description: 'Date of creation.' - field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' - field :version, GraphQL::Types::String, null: true, description: 'Version string.' + field :metadata, Types::Packages::MetadataType, null: true, + description: 'Package metadata.' + field :name, GraphQL::Types::String, null: false, description: 'Name of the package.' field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.' - field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.' - field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' field :pipelines, Types::Ci::PipelineType.connection_type, null: true, description: 'Pipelines that built the package.', deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' } - field :metadata, Types::Packages::MetadataType, null: true, - description: 'Package metadata.' + field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' + field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.' + field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' + field :version, GraphQL::Types::String, null: true, description: 'Version string.' field :versions, ::Types::Packages::PackageType.connection_type, null: true, description: 'Other versions of the package.', deprecated: { reason: 'This field is now only returned in the PackageDetailsType', milestone: '13.11' } - field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.' - field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.' def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index ab2b9c2a3af..1146774b43c 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -9,23 +9,23 @@ module Types field :commit_count, GraphQL::Types::Float, null: false, description: 'Commit count of the project.' - field :storage_size, GraphQL::Types::Float, null: false, - description: 'Storage size of the project in bytes.' - field :repository_size, GraphQL::Types::Float, null: false, - description: 'Repository size of the project in bytes.' - field :lfs_objects_size, GraphQL::Types::Float, null: false, - description: 'Large File Storage (LFS) object size of the project in bytes.' field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'Build artifacts size of the project in bytes.' + field :lfs_objects_size, GraphQL::Types::Float, null: false, + description: 'Large File Storage (LFS) object size of the project in bytes.' field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size of the project in bytes.' - field :wiki_size, GraphQL::Types::Float, null: true, - description: 'Wiki size of the project in bytes.' - field :snippets_size, GraphQL::Types::Float, null: true, - description: 'Snippets size of the project in bytes.' field :pipeline_artifacts_size, GraphQL::Types::Float, null: true, description: 'CI Pipeline artifacts size in bytes.' + field :repository_size, GraphQL::Types::Float, null: false, + description: 'Repository size of the project in bytes.' + field :snippets_size, GraphQL::Types::Float, null: true, + description: 'Snippets size of the project in bytes.' + field :storage_size, GraphQL::Types::Float, null: false, + description: 'Storage size of the project in bytes.' field :uploads_size, GraphQL::Types::Float, null: true, description: 'Uploads size of the project in bytes.' + field :wiki_size, GraphQL::Types::Float, null: true, + description: 'Wiki size of the project in bytes.' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index dc428e7bdce..47e9a6c11fc 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -11,43 +11,43 @@ module Types field :id, GraphQL::Types::ID, null: false, description: 'ID of the project.' + field :ci_config_path_or_default, GraphQL::Types::String, null: false, + description: 'Path of the CI configuration file.' field :full_path, GraphQL::Types::ID, null: false, description: 'Full path of the project.' field :path, GraphQL::Types::String, null: false, description: 'Path of the project.' - field :ci_config_path_or_default, GraphQL::Types::String, null: false, - description: 'Path of the CI configuration file.' field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true, calls_gitaly: true, description: 'SAST CI configuration for the project.' - field :name_with_namespace, GraphQL::Types::String, null: false, - description: 'Full name of the project with its namespace.' field :name, GraphQL::Types::String, null: false, description: 'Name of the project (without namespace).' + field :name_with_namespace, GraphQL::Types::String, null: false, + description: 'Full name of the project with its namespace.' field :description, GraphQL::Types::String, null: true, description: 'Short description of the project.' field :tag_list, GraphQL::Types::String, null: true, deprecated: { reason: 'Use `topics`', milestone: '13.12' }, - description: 'List of project topics (not Git tags).' + description: 'List of project topics (not Git tags).', method: :topic_list field :topics, [GraphQL::Types::String], null: true, - description: 'List of project topics.' + description: 'List of project topics.', method: :topic_list - field :ssh_url_to_repo, GraphQL::Types::String, null: true, - description: 'URL to connect to the project via SSH.' field :http_url_to_repo, GraphQL::Types::String, null: true, description: 'URL to connect to the project via HTTPS.' + field :ssh_url_to_repo, GraphQL::Types::String, null: true, + description: 'URL to connect to the project via SSH.' field :web_url, GraphQL::Types::String, null: true, description: 'Web URL of the project.' - field :star_count, GraphQL::Types::Int, null: false, - description: 'Number of times the project has been starred.' field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, # 4 times description: 'Number of times the project has been forked.' + field :star_count, GraphQL::Types::Int, null: false, + description: 'Number of times the project has been starred.' field :created_at, Types::TimeType, null: true, description: 'Timestamp of the project creation.' @@ -60,12 +60,12 @@ module Types field :visibility, GraphQL::Types::String, null: true, description: 'Visibility of the project.' - field :shared_runners_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if shared runners are enabled for the project.' field :lfs_enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates if the project has Large File Storage (LFS) enabled.' field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + field :shared_runners_enabled, GraphQL::Types::Boolean, null: true, + description: 'Indicates if shared runners are enabled for the project.' field :service_desk_enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates if the project has service desk enabled.' @@ -85,33 +85,35 @@ module Types field :open_issues_count, GraphQL::Types::Int, null: true, description: 'Number of open issues for the project.' + field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true, + description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.' + field :autoclose_referenced_issues, GraphQL::Types::Boolean, null: true, + description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.' field :import_status, GraphQL::Types::String, null: true, description: 'Status of import background job of the project.' field :jira_import_status, GraphQL::Types::String, null: true, description: 'Status of Jira import background job of the project.' - field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true, - description: 'Indicates if merge requests of the project can only be merged with successful jobs.' - field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true, - description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.' - field :request_access_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if users can request member access to the project.' field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, null: true, description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' + field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true, + description: 'Indicates if merge requests of the project can only be merged with successful jobs.' field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.' field :remove_source_branch_after_merge, GraphQL::Types::Boolean, null: true, description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.' - field :autoclose_referenced_issues, GraphQL::Types::Boolean, null: true, - description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.' - field :suggestion_commit_message, GraphQL::Types::String, null: true, - description: 'Commit message used to apply merge request suggestions.' + field :request_access_enabled, GraphQL::Types::Boolean, null: true, + description: 'Indicates if users can request member access to the project.' field :squash_read_only, GraphQL::Types::Boolean, null: false, method: :squash_readonly?, description: 'Indicates if `squashReadOnly` is enabled.' + field :suggestion_commit_message, GraphQL::Types::String, null: true, + description: 'Commit message used to apply merge request suggestions.' + # No, the quotes are not a typo. Used to get around circular dependencies. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675 + field :group, 'Types::GroupType', null: true, + description: 'Group of the project.' field :namespace, Types::NamespaceType, null: true, description: 'Namespace of the project.' - field :group, Types::GroupType, null: true, - description: 'Group of the project.' field :statistics, Types::ProjectStatisticsType, null: true, @@ -397,8 +399,9 @@ module Types field :work_item_types, Types::WorkItems::TypeType.connection_type, resolver: Resolvers::WorkItems::TypesResolver, - description: 'Work item types available to the project.', - feature_flag: :work_items + description: 'Work item types available to the project.' \ + ' Returns `null` if `work_items` feature flag is disabled.' \ + ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.' def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| @@ -458,14 +461,6 @@ module Types object.service_desk_address end - def tag_list - object.topic_list - end - - def topics - object.topic_list - end - private def project diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb index 4a9e5dcbfe9..88b7b95aa57 100644 --- a/app/graphql/types/projects/service_type.rb +++ b/app/graphql/types/projects/service_type.rb @@ -10,9 +10,20 @@ module Types # https://gitlab.com/gitlab-org/gitlab/-/issues/213088 field :type, GraphQL::Types::String, null: true, description: 'Class name of the service.' + field :service_type, ::Types::Projects::ServiceTypeEnum, null: true, + description: 'Type of the service.' field :active, GraphQL::Types::Boolean, null: true, description: 'Indicates if the service is active.' + def type + enum = ::Types::Projects::ServiceTypeEnum.coerce_result(service_type, context) + enum.downcase.camelize + end + + def service_type + object.type + end + definition_methods do def resolve_type(object, context) if object.is_a?(::Integrations::Jira) diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb index 027026dc16c..d0cecbfea49 100644 --- a/app/graphql/types/projects/service_type_enum.rb +++ b/app/graphql/types/projects/service_type_enum.rb @@ -5,8 +5,21 @@ module Types class ServiceTypeEnum < BaseEnum graphql_name 'ServiceType' - ::Integration.available_integration_types(include_dev: false).each do |type| - value type.underscore.upcase, value: type, description: "#{type} type" + class << self + private + + def type_description(name, type) + "#{type} type" + end + end + + # This prepend must stay here because the dynamic block below depends on it. + prepend_mod # rubocop: disable Cop/InjectEnterpriseEditionModule + + ::Integration.available_integration_names(include_dev: false).each do |name| + type = "#{name.camelize}Service" + domain_value = Integration.integration_name_to_type(name) + value type.underscore.upcase, value: domain_value, description: type_description(name, type) end end end diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb index 957ac91db6b..0ff1b9d8903 100644 --- a/app/graphql/types/projects/services/jira_project_type.rb +++ b/app/graphql/types/projects/services/jira_project_type.rb @@ -9,11 +9,11 @@ module Types field :key, GraphQL::Types::String, null: false, description: 'Key of the Jira project.' + field :name, GraphQL::Types::String, null: true, + description: 'Name of the Jira project.' field :project_id, GraphQL::Types::Int, null: false, description: 'ID of the Jira project.', method: :id - field :name, GraphQL::Types::String, null: true, - description: 'Name of the Jira project.' end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 4a4d6727c3f..cc46c7e86e4 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -87,6 +87,12 @@ module Types argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'Global ID of the issue.' end + field :work_item, Types::WorkItemType, + null: true, + resolver: Resolvers::WorkItemResolver, + description: 'Find a work item. Returns `null` if `work_items` feature flag is disabled.' \ + ' The feature is experimental and is subject to change without notice.' + field :merge_request, Types::MergeRequestType, null: true, description: 'Find a merge request.' do @@ -145,6 +151,10 @@ module Types resolver: Resolvers::TopicsResolver, description: "Find project topics." + field :gitpod_enabled, GraphQL::Types::Boolean, + null: true, + description: "Whether Gitpod is enabled in application settings." + def design_management DesignManagementObject.new(nil) end @@ -189,6 +199,10 @@ module Types Gitlab::CurrentSettings.current_application_settings end + def gitpod_enabled + application_settings.gitpod_enabled + end + def query_complexity context.query end diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb index 02961f2f73f..33dcb5125e3 100644 --- a/app/graphql/types/release_asset_link_type.rb +++ b/app/graphql/types/release_asset_link_type.rb @@ -7,21 +7,21 @@ module Types authorize :read_release + field :external, GraphQL::Types::Boolean, null: true, method: :external?, + description: 'Indicates the link points to an external resource.' field :id, GraphQL::Types::ID, null: false, description: 'ID of the link.' + field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true, + description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.' field :name, GraphQL::Types::String, null: true, description: 'Name of the link.' field :url, GraphQL::Types::String, null: true, description: 'URL of the link.' - field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true, - description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.' - field :external, GraphQL::Types::Boolean, null: true, method: :external?, - description: 'Indicates the link points to an external resource.' - field :direct_asset_url, GraphQL::Types::String, null: true, - description: 'Direct asset URL of the link.' field :direct_asset_path, GraphQL::Types::String, null: true, method: :filepath, description: 'Relative path for the direct asset link.' + field :direct_asset_url, GraphQL::Types::String, null: true, + description: 'Direct asset URL of the link.' def direct_asset_url return object.url unless object.filepath diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb index 37ad52ce6d0..b7a1a5a9dbe 100644 --- a/app/graphql/types/release_links_type.rb +++ b/app/graphql/types/release_links_type.rb @@ -10,25 +10,25 @@ module Types present_using ReleasePresenter - field :self_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the release.' + field :closed_issues_url, GraphQL::Types::String, null: true, + description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.', + authorize: :download_code + field :closed_merge_requests_url, GraphQL::Types::String, null: true, + description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.', + authorize: :download_code field :edit_url, GraphQL::Types::String, null: true, description: "HTTP URL of the release's edit page.", authorize: :update_release - field :opened_merge_requests_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.', - authorize: :download_code field :merged_merge_requests_url, GraphQL::Types::String, null: true, description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.', authorize: :download_code - field :closed_merge_requests_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.', - authorize: :download_code field :opened_issues_url, GraphQL::Types::String, null: true, description: 'HTTP URL of the issues page, filtered by this release and `state=open`.', authorize: :download_code - field :closed_issues_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.', + field :opened_merge_requests_url, GraphQL::Types::String, null: true, + description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.', authorize: :download_code + field :self_url, GraphQL::Types::String, null: true, + description: 'HTTP URL of the release.' end end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index fbc3779ea9b..95b6b43bb46 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -13,30 +13,30 @@ module Types present_using ReleasePresenter - field :tag_name, GraphQL::Types::String, null: true, method: :tag, - description: 'Name of the tag associated with the release.' - field :tag_path, GraphQL::Types::String, null: true, - description: 'Relative web path to the tag associated with the release.', - authorize: :download_code + field :assets, Types::ReleaseAssetsType, null: true, method: :itself, + description: 'Assets of the release.' + field :created_at, Types::TimeType, null: true, + description: 'Timestamp of when the release was created.' field :description, GraphQL::Types::String, null: true, description: 'Description (also known as "release notes") of the release.' + field :evidences, Types::EvidenceType.connection_type, null: true, + description: 'Evidence for the release.' + field :links, Types::ReleaseLinksType, null: true, method: :itself, + description: 'Links of the release.' + field :milestones, Types::MilestoneType.connection_type, null: true, + description: 'Milestones associated to the release.', + resolver: ::Resolvers::ReleaseMilestonesResolver field :name, GraphQL::Types::String, null: true, description: 'Name of the release.' - field :created_at, Types::TimeType, null: true, - description: 'Timestamp of when the release was created.' field :released_at, Types::TimeType, null: true, description: 'Timestamp of when the release was released.' + field :tag_name, GraphQL::Types::String, null: true, method: :tag, + description: 'Name of the tag associated with the release.' + field :tag_path, GraphQL::Types::String, null: true, + description: 'Relative web path to the tag associated with the release.', + authorize: :download_code field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?, description: 'Indicates the release is an upcoming release.' - field :assets, Types::ReleaseAssetsType, null: true, method: :itself, - description: 'Assets of the release.' - field :links, Types::ReleaseLinksType, null: true, method: :itself, - description: 'Links of the release.' - field :milestones, Types::MilestoneType.connection_type, null: true, - description: 'Milestones associated to the release.', - resolver: ::Resolvers::ReleaseMilestonesResolver - field :evidences, Types::EvidenceType.connection_type, null: true, - description: 'Evidence for the release.' field :author, Types::UserType, null: true, description: 'User that created the release.' diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index bfd59763a07..652e2882584 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -41,6 +41,9 @@ module Types field :ide_fork_and_edit_path, GraphQL::Types::String, null: true, description: 'Web path to edit this blob in the Web IDE using a forked project.' + field :fork_and_view_path, GraphQL::Types::String, null: true, + description: 'Web path to view this blob using a forked project.' + field :size, GraphQL::Types::Int, null: true, description: 'Size (in bytes) of the blob.' @@ -74,6 +77,9 @@ module Types field :pipeline_editor_path, GraphQL::Types::String, null: true, description: 'Web path to edit .gitlab-ci.yml file.' + field :gitpod_blob_url, GraphQL::Types::String, null: true, + description: 'URL to the blob within Gitpod.' + field :find_file_path, GraphQL::Types::String, null: true, description: 'Web path to find file.' @@ -131,6 +137,12 @@ module Types null: true, calls_gitaly: true + field :code_navigation_path, GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'Web path for code navigation.' + + field :project_blob_path_root, GraphQL::Types::String, null: true, + description: 'Web path for the root of the blob.' + def raw_text_blob object.data unless object.binary? end diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index fc9860900c9..aa02f0058da 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -6,17 +6,6 @@ module Types authorize :download_code - field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Default branch of the repository.' - field :empty, GraphQL::Types::Boolean, null: false, method: :empty?, calls_gitaly: true, - description: 'Indicates repository has no visible content.' - field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true, - description: 'Indicates a corresponding Git repository exists on disk.' - field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true, - description: 'Tree of the repository.' - field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true, - max_page_size: 100, - description: 'Paginated tree of the repository.' field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true, description: 'Blobs contained within the repository' field :branch_names, [GraphQL::Types::String], null: true, calls_gitaly: true, @@ -26,5 +15,16 @@ module Types description: 'Shows a disk path of the repository.', null: true, authorize: :read_storage_disk_path + field :empty, GraphQL::Types::Boolean, null: false, method: :empty?, calls_gitaly: true, + description: 'Indicates repository has no visible content.' + field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true, + description: 'Indicates a corresponding Git repository exists on disk.' + field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true, + max_page_size: 100, + description: 'Paginated tree of the repository.' + field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'Default branch of the repository.' + field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true, + description: 'Tree of the repository.' end end diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb index 4dcadf1274f..467331c5643 100644 --- a/app/graphql/types/root_storage_statistics_type.rb +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -6,15 +6,15 @@ module Types authorize :read_statistics - field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.' - field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.' - field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.' field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.' + field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.' + field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.' field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.' - field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.' - field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.' field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.' + field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.' + field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.' + field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.' field :uploads_size, GraphQL::Types::Float, null: false, description: 'Uploads size in bytes.' - field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.' + field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.' end end diff --git a/app/graphql/types/saved_reply_type.rb b/app/graphql/types/saved_reply_type.rb new file mode 100644 index 00000000000..329f431b10e --- /dev/null +++ b/app/graphql/types/saved_reply_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + class SavedReplyType < BaseObject + graphql_name 'SavedReply' + + authorize :read_saved_replies + + field :id, Types::GlobalIDType[::Users::SavedReply], + null: false, + description: 'Global ID of the saved reply.' + + field :content, GraphQL::Types::String, + null: false, + description: 'Content of the saved reply.' + + field :name, GraphQL::Types::String, + null: false, + description: 'Name of the saved reply.' + end +end diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb index 3aa19ff9413..9a979b04d37 100644 --- a/app/graphql/types/task_completion_status.rb +++ b/app/graphql/types/task_completion_status.rb @@ -8,10 +8,10 @@ module Types graphql_name 'TaskCompletionStatus' description 'Completion status of tasks' - field :count, GraphQL::Types::Int, null: false, - description: 'Number of total tasks.' field :completed_count, GraphQL::Types::Int, null: false, description: 'Number of completed tasks.' + field :count, GraphQL::Types::Int, null: false, + description: 'Number of total tasks.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index 34ba2c75b5f..f21b2b261a3 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -18,7 +18,7 @@ module Types null: true, authorize: :read_project - field :group, Types::GroupType, + field :group, 'Types::GroupType', description: 'Group this to-do item is associated with.', null: true, authorize: :read_group @@ -31,6 +31,11 @@ module Types description: 'Action of the to-do item.', null: false + field :target, Types::TodoableInterface, + description: 'Target of the to-do item.', + calls_gitaly: true, + null: false + field :target_type, Types::TodoTargetEnum, description: 'Target type of the to-do item.', null: false @@ -59,5 +64,28 @@ module Types def author Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find end + + def target + if object.for_commit? + Gitlab::Graphql::Loaders::BatchCommitLoader.new( + container_class: Project, + container_id: object.project_id, + oid: object.commit_id + ).find + else + Gitlab::Graphql::Loaders::BatchModelLoader.new(target_type_class, object.target_id).find + end + end + + private + + def target_type_class + klass = object.target_type.safe_constantize + raise "Invalid target type \"#{object.target_type}\"" unless klass < Todoable + + klass + end end end + +Types::TodoType.prepend_mod diff --git a/app/graphql/types/todoable_interface.rb b/app/graphql/types/todoable_interface.rb new file mode 100644 index 00000000000..7d437973c12 --- /dev/null +++ b/app/graphql/types/todoable_interface.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module TodoableInterface + include Types::BaseInterface + + graphql_name 'Todoable' + + field :web_url, GraphQL::Types::String, null: true, description: 'URL of this object.' + + def self.resolve_type(object, context) + case object + when Issue + Types::IssueType + when MergeRequest + Types::MergeRequestType + when ::DesignManagement::Design + Types::DesignManagement::DesignType + when ::AlertManagement::Alert + Types::AlertManagement::AlertType + when Commit + Types::CommitType + else + raise "Unknown GraphQL type for #{object}" + end + end + end +end + +Types::TodoableInterface.prepend_mod diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index bcff65be652..284542e1d2a 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -9,15 +9,15 @@ module Types implements Types::Tree::EntryType present_using BlobPresenter - field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL of the blob.' - field :web_path, GraphQL::Types::String, null: true, - description: 'Web path of the blob.' field :lfs_oid, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'LFS ID of the blob.' field :mode, GraphQL::Types::String, null: true, description: 'Blob mode in numeric format.' + field :web_path, GraphQL::Types::String, null: true, + description: 'Web path of the blob.' + field :web_url, GraphQL::Types::String, null: true, + description: 'Web URL of the blob.' def lfs_oid Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb index bc7828dbffa..8f462011f0f 100644 --- a/app/graphql/types/tree/submodule_type.rb +++ b/app/graphql/types/tree/submodule_type.rb @@ -8,10 +8,10 @@ module Types implements Types::Tree::EntryType - field :web_url, type: GraphQL::Types::String, null: true, - description: 'Web URL for the sub-module.' field :tree_url, type: GraphQL::Types::String, null: true, description: 'Tree URL for the sub-module.' + field :web_url, type: GraphQL::Types::String, null: true, + description: 'Web URL for the sub-module.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb index cdc84c8e318..28024fd010b 100644 --- a/app/graphql/types/tree/tree_entry_type.rb +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -10,10 +10,10 @@ module Types implements Types::Tree::EntryType present_using TreeEntryPresenter - field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL for the tree entry (directory).' field :web_path, GraphQL::Types::String, null: true, description: 'Web path for the tree entry (directory).' + field :web_url, GraphQL::Types::String, null: true, + description: 'Web URL for the tree entry (directory).' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb index 0ff32d68400..526027322ef 100644 --- a/app/graphql/types/user_callout_type.rb +++ b/app/graphql/types/user_callout_type.rb @@ -4,9 +4,9 @@ module Types class UserCalloutType < BaseObject # rubocop:disable Graphql/AuthorizeTypes graphql_name 'UserCallout' - field :feature_name, UserCalloutFeatureNameEnum, null: true, - description: 'Name of the feature that the callout is for.' field :dismissed_at, Types::TimeType, null: true, description: 'Date when the callout was dismissed.' + field :feature_name, UserCalloutFeatureNameEnum, null: true, + description: 'Name of the feature that the callout is for.' end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 24fca80d5a9..2c9592a7f5a 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -115,6 +115,19 @@ module Types extras: [:lookahead], complexity: 5, resolver: ::Resolvers::TimelogResolver + field :saved_replies, + Types::SavedReplyType.connection_type, + null: true, + description: 'Saved replies authored by the user.' + + field :gitpod_enabled, GraphQL::Types::Boolean, null: true, + description: 'Whether Gitpod is enabled at the user level.' + + field :preferences_gitpod_path, GraphQL::Types::String, null: true, + description: 'Web path to the Gitpod section within user preferences.' + + field :profile_enable_gitpod_path, GraphQL::Types::String, null: true, + description: 'Web path to enable Gitpod for the user.' definition_methods do def resolve_type(object, context) @@ -125,14 +138,7 @@ module Types end def redacted_name - return object.name unless object.project_bot? - - return object.name if context[:current_user]&.can?(:read_project, object.projects.first) - - # If the requester does not have permission to read the project bot name, - # the API returns an arbitrary string. UI changes will be addressed in a follow up issue: - # https://gitlab.com/gitlab-org/gitlab/-/issues/346058 - '****' + object.redacted_name(context[:current_user]) end end end diff --git a/app/graphql/types/user_status_type.rb b/app/graphql/types/user_status_type.rb index 61abec0ba96..68c00bffe48 100644 --- a/app/graphql/types/user_status_type.rb +++ b/app/graphql/types/user_status_type.rb @@ -7,11 +7,11 @@ module Types markdown_field :message_html, null: true, description: 'HTML of the user status message' - field :message, GraphQL::Types::String, null: true, - description: 'User status message.' - field :emoji, GraphQL::Types::String, null: true, - description: 'String representation of emoji.' field :availability, Types::AvailabilityEnum, null: false, description: 'User availability status.' + field :emoji, GraphQL::Types::String, null: true, + description: 'String representation of emoji.' + field :message, GraphQL::Types::String, null: true, + description: 'User status message.' end end diff --git a/app/graphql/types/work_item_id_type.rb b/app/graphql/types/work_item_id_type.rb new file mode 100644 index 00000000000..ddcf3416014 --- /dev/null +++ b/app/graphql/types/work_item_id_type.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Types + # rubocop:disable Graphql/AuthorizeTypes + # TODO: This type should be removed when Work Items become generally available. + # This mechanism is introduced temporarily to make the client implementation easier during this transition. + class WorkItemIdType < GlobalIDType + graphql_name 'WorkItemID' + description <<~DESC + A `WorkItemID` is a global ID. It is encoded as a string. + + An example `WorkItemID` is: `"gid://gitlab/WorkItem/1"`. + + While we transition from Issues into Work Items this type will temporarily support + `IssueID` like: `"gid://gitlab/Issue/1"`. This behavior will be removed without notice in the future. + DESC + + class << self + def coerce_result(gid, ctx) + global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: 'WorkItem') + + raise GraphQL::CoercionError, "Expected a WorkItem ID, got #{global_id}" unless suitable?(global_id) + + # Always return a WorkItemID even if an Issue is returned by a resolver + work_item_gid(global_id).to_s + end + + def coerce_input(string, ctx) + gid = super + # Always return a WorkItemID even if an Issue Global ID is provided as input + return work_item_gid(gid) if suitable?(gid) + + raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of WorkItem" + end + + def suitable?(gid) + return false if gid&.model_name&.safe_constantize.blank? + + [::WorkItem, ::Issue].any? { |model_class| gid.model_class == model_class } + end + + private + + def work_item_gid(gid) + GlobalID.new(::Gitlab::GlobalId.build(model_name: 'WorkItem', id: gid.model_id)) + end + end + end + # rubocop:enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index 15a5557b489..512b9ef64d2 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -4,7 +4,7 @@ module Types class WorkItemType < BaseObject graphql_name 'WorkItem' - authorize :read_issue + authorize :read_work_item field :description, GraphQL::Types::String, null: true, description: 'Description of the work item.' @@ -12,6 +12,8 @@ module Types description: 'Global ID of the work item.' field :iid, GraphQL::Types::ID, null: false, description: 'Internal ID of the work item.' + field :lock_version, GraphQL::Types::Int, null: false, + description: 'Lock version of the work item. Incremented each time the work item is updated.' field :state, WorkItemStateEnum, null: false, description: 'State of the work item.' field :title, GraphQL::Types::String, null: false, diff --git a/app/graphql/types/work_items/convert_task_input_type.rb b/app/graphql/types/work_items/convert_task_input_type.rb new file mode 100644 index 00000000000..1f142c6815c --- /dev/null +++ b/app/graphql/types/work_items/convert_task_input_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class ConvertTaskInputType < BaseInputObject + graphql_name 'WorkItemConvertTaskInput' + + argument :line_number_end, GraphQL::Types::Int, + required: true, + description: 'Last line in the Markdown source that defines the list item task.' + argument :line_number_start, GraphQL::Types::Int, + required: true, + description: 'First line in the Markdown source that defines the list item task.' + argument :lock_version, GraphQL::Types::Int, + required: true, + description: 'Current lock version of the work item containing the task in the description.' + argument :title, GraphQL::Types::String, + required: true, + description: 'Full string of the task to be replaced. New title for the created work item.' + argument :work_item_type_id, ::Types::GlobalIDType[::WorkItems::Type], + required: true, + description: 'Global ID of the work item type used to create the new work item.', + prepare: ->(attribute, _ctx) { work_item_type_global_id(attribute) } + + class << self + def work_item_type_global_id(global_id) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + global_id = ::Types::GlobalIDType[::WorkItems::Type].coerce_isolated_input(global_id) + + global_id&.model_id + end + end + end + end +end diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb index 1d38262159f..d8d44601327 100644 --- a/app/helpers/access_tokens_helper.rb +++ b/app/helpers/access_tokens_helper.rb @@ -27,4 +27,10 @@ module AccessTokensHelper } }.to_json end + + def expires_at_field_data + {} + end end + +AccessTokensHelper.prepend_mod diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 5ca360f38da..cb43d911a2f 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -38,6 +38,8 @@ module AppearancesHelper def brand_header_logo if current_appearance&.header_logo? image_tag current_appearance.header_logo_path, class: 'brand-header-logo' + elsif Feature.enabled?(:ukraine_support_tanuki) + render partial: 'shared/logo_ukraine', formats: :svg else render partial: 'shared/logo', formats: :svg end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e675c01bcbb..feeedb0a501 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -18,6 +18,28 @@ module ApplicationHelper end end + def dispensable_render(...) + render(...) + rescue StandardError => error + if Feature.enabled?(:dispensable_render, default_enabled: :yaml) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error) + nil + else + raise error + end + end + + def dispensable_render_if_exists(...) + render_if_exists(...) + rescue StandardError => error + if Feature.enabled?(:dispensable_render, default_enabled: :yaml) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error) + nil + else + raise error + end + end + def partial_exists?(partial) lookup_context.exists?(partial, [], true) end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index fa9b3bfc912..a9c13b2fdeb 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -212,6 +212,7 @@ module ApplicationSettingsHelper :auto_devops_enabled, :auto_devops_domain, :container_expiration_policies_enable_historic_entries, + :container_registry_expiration_policies_caching, :container_registry_token_expire_delay, :default_artifacts_expire_in, :default_branch_name, @@ -423,7 +424,8 @@ module ApplicationSettingsHelper :sidekiq_job_limiter_compression_threshold_bytes, :sidekiq_job_limiter_limit_bytes, :suggest_pipeline_enabled, - :user_email_lookup_limit, + :search_rate_limit, + :search_rate_limit_unauthenticated, :users_get_by_id_limit, :users_get_by_id_limit_allowlist_raw, :runner_token_expiration_interval, @@ -463,7 +465,10 @@ module ApplicationSettingsHelper end def instance_clusters_enabled? - can?(current_user, :read_cluster, Clusters::Instance.new) + clusterable = Clusters::Instance.new + + Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops) && + can?(current_user, :read_cluster, clusterable) end def omnibus_protected_paths_throttle? diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index fb2fa547447..ba6c0380edf 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -178,7 +178,7 @@ module AuthHelper end def google_tag_manager_enabled? - return false unless Gitlab.dev_env_or_com? + return false unless Gitlab.com? if Feature.enabled?(:gtm_nonce, type: :ops) extra_config.has_key?('google_tag_manager_nonce_id') && diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index f0e8ff7778e..fcf6a177984 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -65,40 +65,13 @@ module BlobHelper return unless blob = readable_blob(options, path, project, ref) common_classes = "btn gl-button btn-confirm js-edit-blob gl-ml-3 #{options[:extra_class]}" - data = { track_action: 'click_edit', track_label: 'edit' } - - if Feature.enabled?(:web_ide_primary_edit, project.group) - common_classes += " btn-inverted" - data[:track_property] = 'secondary' - end edit_button_tag(blob, common_classes, _('Edit'), edit_blob_path(project, ref, path, options), project, - ref, - data) - end - - def ide_edit_button(project = @project, ref = @ref, path = @path, blob:) - return unless blob - - common_classes = 'btn gl-button btn-confirm ide-edit-button gl-ml-3' - data = { track_action: 'click_edit_ide', track_label: 'web_ide' } - - unless Feature.enabled?(:web_ide_primary_edit, project.group) - common_classes += " btn-inverted" - data[:track_property] = 'secondary' - end - - edit_button_tag(blob, - common_classes, - _('Web IDE'), - ide_edit_path(project, ref, path), - project, - ref, - data) + ref) end def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:) @@ -363,16 +336,16 @@ module BlobHelper content_tag(:span, button, class: 'has-tooltip', title: _('You can only edit files when you are on a branch'), data: { container: 'body' }) end - def edit_link_tag(link_text, edit_path, common_classes, data) - link_to link_text, edit_path, class: "#{common_classes}", data: data + def edit_link_tag(link_text, edit_path, common_classes) + link_to link_text, edit_path, class: "#{common_classes}" end - def edit_button_tag(blob, common_classes, text, edit_path, project, ref, data) + def edit_button_tag(blob, common_classes, text, edit_path, project, ref) if !on_top_of_branch?(project, ref) edit_disabled_button_tag(text, common_classes) # This condition only applies to users who are logged in elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) - edit_link_tag(text, edit_path, common_classes, data) + edit_link_tag(text, edit_path, common_classes) elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project) edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path)) end diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 881e11b10ea..dda834ee2c5 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -1,14 +1,22 @@ # frozen_string_literal: true module BroadcastMessagesHelper + include Gitlab::Utils::StrongMemoize + def current_broadcast_banner_messages - BroadcastMessage.current_banner_messages(request.path).select do |message| + BroadcastMessage.current_banner_messages( + current_path: request.path, + user_access_level: current_user_access_level_for_project_or_group + ).select do |message| cookies["hide_broadcast_message_#{message.id}"].blank? end end def current_broadcast_notification_message - not_hidden_messages = BroadcastMessage.current_notification_messages(request.path).select do |message| + not_hidden_messages = BroadcastMessage.current_notification_messages( + current_path: request.path, + user_access_level: current_user_access_level_for_project_or_group + ).select do |message| cookies["hide_broadcast_message_#{message.id}"].blank? end not_hidden_messages.last @@ -61,4 +69,35 @@ module BroadcastMessagesHelper def broadcast_type_options BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] } end + + def target_access_level_options + BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level| + [Gitlab::Access.human_access(access_level), access_level] + end + end + + def target_access_levels_display(access_levels) + access_levels.map do |access_level| + Gitlab::Access.human_access(access_level) + end.join(', ') + end + + private + + def current_user_access_level_for_project_or_group + return if Feature.disabled?(:role_targeted_broadcast_messages, default_enabled: :yaml) + return unless current_user.present? + + strong_memoize(:current_user_access_level_for_project_or_group) do + if controller.is_a? Projects::ApplicationController + next unless @project + + @project.team.max_member_access(current_user.id) + elsif controller.is_a? Groups::ApplicationController + next unless @group + + @group.max_member_access_for_user(current_user) + end + end + end end diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index c0dca66bac8..14e52b120f3 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -14,8 +14,7 @@ module Ci "build_stage" => @build.stage, "log_state" => '', "build_options" => javascript_build_options, - "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs'), - "code_quality_help_url" => help_page_path('user/project/merge_requests/code_quality', anchor: 'troubleshooting') + "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') } end diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 6104a1256d5..8d2f83409be 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -78,6 +78,37 @@ module Ci pipeline.stuck? end + def pipelines_list_data(project, list_url) + artifacts_endpoint_placeholder = ':pipeline_artifacts_id' + + data = { + endpoint: list_url, + project_id: project.id, + default_branch_name: project.default_branch, + params: params.to_json, + artifacts_endpoint: downloadable_artifacts_project_pipeline_path(project, artifacts_endpoint_placeholder, format: :json), + artifacts_endpoint_placeholder: artifacts_endpoint_placeholder, + pipeline_schedule_url: pipeline_schedules_path(project), + empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'), + error_state_svg_path: image_path('illustrations/pipelines_failed.svg'), + no_pipelines_svg_path: image_path('illustrations/pipelines_pending.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), + reset_cache_path: can?(current_user, :admin_pipeline, project) && reset_cache_project_settings_ci_cd_path(project), + has_gitlab_ci: has_gitlab_ci?(project).to_s, + pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project), + suggested_ci_templates: suggested_ci_templates.to_json, + ci_runner_settings_path: project_settings_ci_cd_path(project, ci_runner_templates: true, anchor: 'js-runners-settings') + } + + experiment(:runners_availability_section, namespace: project.root_ancestor) do |e| + e.candidate { data[:any_runners_available] = project.active_runners.exists?.to_s } + end + + data + end + private def warning_markdown(pipeline) diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 1475a26ca09..959dac1254e 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -1,17 +1,6 @@ # frozen_string_literal: true module ClustersHelper - def create_new_cluster_label(provider: nil) - case provider - when 'aws' - s_('ClusterIntegration|Create new cluster on EKS') - when 'gcp' - s_('ClusterIntegration|Create new cluster on GKE') - else - s_('ClusterIntegration|Create new cluster') - end - end - def display_cluster_agents?(clusterable) clusterable.is_a?(Project) end @@ -26,22 +15,19 @@ module ClustersHelper gcp: { path: image_path('illustrations/logos/google_gke.svg'), text: s_('ClusterIntegration|Google GKE') } }, clusters_empty_state_image: image_path('illustrations/empty-state/empty-state-clusters.svg'), + empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'), empty_state_help_text: clusterable.empty_state_help_text, - new_cluster_path: clusterable.new_path(tab: 'create'), + new_cluster_path: clusterable.new_path, + add_cluster_path: clusterable.connect_path, can_add_cluster: clusterable.can_add_cluster?.to_s, - can_admin_cluster: clusterable.can_admin_cluster?.to_s - } - end - - def js_clusters_data(clusterable) - { - default_branch_name: clusterable.default_branch, - empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'), - project_path: clusterable.full_path, - add_cluster_path: clusterable.new_path(tab: 'add'), + can_admin_cluster: clusterable.can_admin_cluster?.to_s, + display_cluster_agents: display_cluster_agents?(clusterable).to_s, + certificate_based_clusters_enabled: Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops).to_s, + default_branch_name: default_branch_name(clusterable), + project_path: clusterable_project_path(clusterable), kas_address: Gitlab::Kas.external_url, gitlab_version: Gitlab.version_info - }.merge(js_clusters_list_data(clusterable)) + } end def js_cluster_form_data(cluster, can_edit) @@ -122,4 +108,14 @@ module ClustersHelper def can_admin_cluster?(user, cluster) can?(user, :admin_cluster, cluster) end + + private + + def default_branch_name(clusterable) + clusterable.default_branch if clusterable.is_a?(Project) + end + + def clusterable_project_path(clusterable) + clusterable.full_path if clusterable.is_a?(Project) + end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 43e727ac483..c78e906e052 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -182,6 +182,19 @@ module CommitsHelper project_commit_path(project, DEFAULT_SHA).sub("/#{DEFAULT_SHA}", '/$COMMIT_SHA') end + def diff_mode_swap_button(mode, file_hash) + icon = mode == 'raw' ? 'doc-code' : 'doc-text' + entity = mode == 'raw' ? 'toHideBtn' : 'toShowBtn' + title = "Display #{mode} diff" + + link_to("##{mode}-diff-#{file_hash}", + class: "btn gl-button btn-default btn-file-option has-tooltip btn-show-#{mode}-diff", + title: title, + data: { file_hash: file_hash, diff_toggle_entity: entity }) do + sprite_icon(icon) + end + end + protected # Private: Returns a link to a person. If the person has a matching user and diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb index 52f68ac53f0..0005682e979 100644 --- a/app/helpers/container_expiration_policies_helper.rb +++ b/app/helpers/container_expiration_policies_helper.rb @@ -25,8 +25,7 @@ module ContainerExpirationPoliciesHelper end end - def container_expiration_policies_historic_entry_enabled?(project) - Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries || - Feature.enabled?(:container_expiration_policies_historic_entry, project) + def container_expiration_policies_historic_entry_enabled? + Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries end end diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb index 1b77b639ce1..255b8183164 100644 --- a/app/helpers/container_registry_helper.rb +++ b/app/helpers/container_registry_helper.rb @@ -2,8 +2,7 @@ module ContainerRegistryHelper def container_registry_expiration_policies_throttling? - Feature.enabled?(:container_registry_expiration_policies_throttling) && - ContainerRegistry::Client.supports_tag_delete? + Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml) end def container_repository_gid_prefix diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index f0e1f252917..bcb1f63840d 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -15,6 +15,10 @@ module DashboardHelper merge_requests_dashboard_path(reviewer_username: current_user.username) end + def attention_requested_mrs_dashboard_path + merge_requests_dashboard_path(attention: current_user.username) + end + def dashboard_nav_links @dashboard_nav_links ||= get_dashboard_nav_links end diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb index d6fbe0b6b45..560d2fcd29f 100644 --- a/app/helpers/deploy_tokens_helper.rb +++ b/app/helpers/deploy_tokens_helper.rb @@ -16,4 +16,11 @@ module DeployTokensHelper Gitlab.config.packages.enabled && can?(current_user, :read_package, group_or_project) end + + def deploy_token_revoke_button_data(token:, group_or_project:) + { + token: token.to_json(only: [:id, :name]), + revoke_path: revoke_deploy_token_path(group_or_project, token) + } + end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 2b5f726dad1..100d5c0281c 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -28,7 +28,7 @@ module DiffHelper end def diff_options - options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? } + options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded?, use_extra_viewer_as_main: true } if action_name == 'diff_for_path' options[:expanded] = true @@ -74,7 +74,7 @@ module DiffHelper end def diff_link_number(line_type, match, text) - line_type == match ? " " : text + line_type == match || text == 0 ? " " : text end def parallel_diff_discussions(left, right, diff_file) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 0092743f96e..a910d3d7c9d 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -129,7 +129,7 @@ module DropdownsHelper end def dropdown_loading - spinner = loading_icon(container: true, size: "md", css_class: "gl-mt-7") + spinner = gl_loading_icon(size: "md", css_class: "gl-mt-7") content_tag(:div, spinner, class: "dropdown-loading") end end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 026dbd60ac6..1defe480059 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -19,26 +19,10 @@ module ExploreHelper request_path_with_options(options) end - def filter_audit_path(options = {}) - exist_opts = { - entity_type: params[:entity_type], - entity_id: params[:entity_id], - created_before: params[:created_before], - created_after: params[:created_after], - sort: params[:sort] - } - options = exist_opts.merge(options).delete_if { |key, value| value.blank? } - request_path_with_options(options) - end - def filter_groups_path(options = {}) request_path_with_options(options) end - def explore_controller? - controller.class.name.split("::").first == "Explore" - end - def explore_nav_links @explore_nav_links ||= get_explore_nav_links end @@ -47,14 +31,27 @@ module ExploreHelper explore_nav_links.include?(link) end - def any_explore_nav_link?(links) - links.any? { |link| explore_nav_link?(link) } - end - def public_visibility_restricted? Gitlab::VisibilityLevel.public_visibility_restricted? end + def projects_filter_items + [ + { value: _('Any'), text: _('Any'), href: filter_projects_path(visibility_level: nil) }, + *Gitlab::VisibilityLevel.options.keys.map do |key| + { + value: key, + text: key, + href: filter_projects_path(visibility_level: Gitlab::VisibilityLevel.options[key]) + } + end + ] + end + + def projects_filter_selected(visibility_level) + visibility_level.present? ? visibility_level_label(visibility_level.to_i) : _('Any') + end + private def get_explore_nav_links diff --git a/app/helpers/groups/crm_settings_helper.rb b/app/helpers/groups/crm_settings_helper.rb index ab47ec40b13..d7ca25a9d1b 100644 --- a/app/helpers/groups/crm_settings_helper.rb +++ b/app/helpers/groups/crm_settings_helper.rb @@ -2,7 +2,7 @@ module Groups module CrmSettingsHelper - def crm_feature_flag_enabled?(group) + def crm_feature_available?(group) Feature.enabled?(:customer_relations, group) end end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 07ab246b089..a719d80a1a1 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -9,10 +9,6 @@ module Groups::GroupMembersHelper { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } end - def render_invite_member_for_group(group, default_access_level) - render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level - end - def group_members_app_data(group, members:, invited:, access_requests:) { user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }), diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 32d808c960c..6f7ac069fe4 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -49,13 +49,39 @@ module IconsHelper end end - def loading_icon(container: false, color: 'orange', size: 'sm', css_class: nil) - css_classes = ['gl-spinner', "gl-spinner-#{color}", "gl-spinner-#{size}"] - css_classes << "#{css_class}" unless css_class.blank? - - spinner = content_tag(:span, "", { class: css_classes.join(' '), aria: { label: _('Loading') } }) - - container == true ? content_tag(:div, spinner, { class: 'gl-spinner-container' }) : spinner + # Creates a GitLab UI loading icon/spinner. + # + # Examples: + # # Default + # gl_loading_icon + # + # # Sizes + # gl_loading_icon(size: 'md') + # gl_loading_icon(size: 'lg') + # gl_loading_icon(size: 'xl') + # + # # Colors + # gl_loading_icon(color: 'light') + # + # # Block/Inline + # gl_loading_icon(inline: true) + # + # # Custom classes + # gl_loading_icon(css_class: "foo-bar") + # + # See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-loading-icon--default + def gl_loading_icon(inline: false, color: 'dark', size: 'sm', css_class: nil) + spinner = content_tag(:span, "", { + class: %[gl-spinner gl-spinner-#{color} gl-spinner-#{size} gl-vertical-align-text-bottom!], + aria: { label: _('Loading') } + }) + + container_classes = ['gl-spinner-container'] + container_classes << css_class unless css_class.blank? + content_tag(inline ? :span : :div, spinner, { + class: container_classes, + role: 'status' + }) end def external_snippet_icon(name) diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index f5ba978e860..b960ed46ba9 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -1,6 +1,35 @@ # frozen_string_literal: true module IntegrationsHelper + def integration_event_title(event) + case event + when "push", "push_events" + _("Push") + when "tag_push", "tag_push_events" + _("Tag push") + when "note", "note_events" + _("Note") + when "confidential_note", "confidential_note_events" + _("Confidential note") + when "issue", "issue_events" + _("Issue") + when "confidential_issue", "confidential_issue_events" + _("Confidential issue") + when "merge_request", "merge_request_events" + _("Merge request") + when "pipeline", "pipeline_events" + _("Pipeline") + when "wiki_page", "wiki_page_events" + _("Wiki page") + when "commit", "commit_events" + _("Commit") + when "deployment" + _("Deployment") + when "alert" + _("Alert") + end + end + def integration_event_description(integration, event) case integration when Integrations::Jira @@ -75,7 +104,8 @@ module IntegrationsHelper form_data = { id: integration.id, show_active: integration.show_active_box?.to_s, - activated: (integration.active || integration.new_record?).to_s, + activated: (integration.active || (integration.new_record? && integration.activate_disabled_reason.nil?)).to_s, + activate_disabled: integration.activate_disabled_reason.present?.to_s, type: integration.to_param, merge_request_events: integration.merge_requests_events.to_s, commit_events: integration.commit_events.to_s, @@ -83,6 +113,7 @@ module IntegrationsHelper comment_detail: integration.comment_detail, learn_more_path: integrations_help_page_path, trigger_events: trigger_events_for_integration(integration), + sections: integration.sections.to_json, fields: fields_for_integration(integration), inherit_from_id: integration.inherit_from_id, integration_level: integration_level(integration), diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 1f225e9c0e5..a2dde29e25d 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -6,7 +6,7 @@ module InviteMembersHelper def can_invite_members_for_project?(project) # do not use the can_admin_project_member? helper here due to structure of the view and how membership_locked? # is leveraged for inviting groups - Feature.enabled?(:invite_members_group_modal, project.group, default_enabled: :yaml) && can?(current_user, :admin_project_member, project) + can?(current_user, :admin_project_member, project) end def invite_accepted_notice(member) @@ -73,7 +73,7 @@ module InviteMembersHelper def show_invite_members_for_task?(source) return unless current_user - invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).variant.name == 'candidate' + invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).assigned.name == 'candidate' params[:open_modal] == 'invite_members_for_task' || invite_for_help_continuous_onboarding end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 8e7f5060412..298162fe970 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -169,7 +169,7 @@ module IssuesHelper end def issue_header_actions_data(project, issuable, current_user) - new_issuable_params = { issue: { description: _('Related to #%{issue_id}.') % { issue_id: issuable.iid } + "\n\n" } } + new_issuable_params = { issue: {}, add_related_issue: issuable.iid } if issuable.incident? new_issuable_params[:issuable_template] = 'incident' new_issuable_params[:issue][:issue_type] = 'incident' @@ -209,7 +209,7 @@ module IssuesHelper } end - def project_issues_list_data(project, current_user, finder) + def project_issues_list_data(project, current_user) common_issues_list_data(project, current_user).merge( can_bulk_update: can?(current_user, :admin_issue, project).to_s, can_edit: can?(current_user, :admin_project, project).to_s, @@ -223,7 +223,7 @@ module IssuesHelper is_project: true.to_s, markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), - new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }), + new_issue_path: new_project_issue_path(project), project_import_jira_path: project_import_jira_path(project), quick_actions_help_path: help_page_path('user/project/quick_actions'), releases_path: project_releases_path(project, format: :json), diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index 9a0f0944fd1..67b85b26f9e 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -9,12 +9,38 @@ module JiraConnectHelper subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json, subscriptions_path: jira_connect_subscriptions_path, users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in - gitlab_user_path: current_user ? user_path(current_user) : nil + gitlab_user_path: current_user ? user_path(current_user) : nil, + oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil } end private + def jira_connect_oauth_data + oauth_authorize_url = oauth_authorization_url( + client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'], + response_type: 'code', + scope: 'api', + redirect_uri: jira_connect_oauth_callbacks_url, + state: oauth_state + ) + + { + oauth_authorize_url: oauth_authorize_url, + oauth_token_url: oauth_token_url, + state: oauth_state, + oauth_token_payload: { + grant_type: :authorization_code, + client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'], + redirect_uri: jira_connect_oauth_callbacks_url + } + } + end + + def oauth_state + @oauth_state ||= SecureRandom.hex(32) + end + def serialize_subscription(subscription) { group: { diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 2150729cb2a..877785c9eaf 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -61,7 +61,7 @@ module LabelsHelper render_label_text( label.name, suffix: suffix, - css_class: "gl-label-text #{text_color_class_for_bg(label.color)}", + css_class: "gl-label-text #{label.text_color_class}", bg_color: label.color ) end @@ -114,30 +114,8 @@ module LabelsHelper end end - def text_color_class_for_bg(bg_color) - if light_color?(bg_color) - 'gl-label-text-dark' - else - 'gl-label-text-light' - end - end - def text_color_for_bg(bg_color) - if light_color?(bg_color) - '#333333' - else - '#FFFFFF' - end - end - - def light_color?(color) - if color.length == 4 - r, g, b = color[1, 4].scan(/./).map { |v| (v * 2).hex } - else - r, g, b = color[1, 7].scan(/.{2}/).map(&:hex) - end - - (r + g + b) > 500 + ::Gitlab::Color.of(bg_color).contrast end def labels_filter_path_with_defaults(only_group_labels: false, include_ancestor_groups: true, include_descendant_groups: false) diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb index 0c5744b46ae..d0bdaaae5f8 100644 --- a/app/helpers/lazy_image_tag_helper.rb +++ b/app/helpers/lazy_image_tag_helper.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true module LazyImageTagHelper + include PreferencesHelper + def placeholder_image "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" end # Override the default ActionView `image_tag` helper to support lazy-loading def image_tag(source, options = {}) + source = options[:dark_variant] if options[:dark_variant] && user_application_dark_mode? options = options.symbolize_keys unless options.delete(:lazy) == false diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb index 7dfd9ed47e3..60f3b12d736 100644 --- a/app/helpers/learn_gitlab_helper.rb +++ b/app/helpers/learn_gitlab_helper.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true module LearnGitlabHelper + IMAGE_PATH_PLAN = "learn_gitlab/section_plan.svg" + IMAGE_PATH_DEPLOY = "learn_gitlab/section_deploy.svg" + IMAGE_PATH_WORKSPACE = "learn_gitlab/section_workspace.svg" + def learn_gitlab_enabled?(project) return false unless current_user @@ -25,19 +29,7 @@ module LearnGitlabHelper def onboarding_actions_data(project) attributes = onboarding_progress(project).attributes.symbolize_keys - urls_to_use = nil - - experiment( - :change_continuous_onboarding_link_urls, - namespace: project.namespace, - actor: current_user, - sticky_to: project.namespace - ) do |e| - e.control { urls_to_use = action_urls } - e.candidate { urls_to_use = new_action_urls(project) } - end - - urls_to_use.to_h do |action, url| + action_urls(project).to_h do |action, url| [ action, url: url, @@ -50,13 +42,13 @@ module LearnGitlabHelper def onboarding_sections_data { workspace: { - svg: image_path("learn_gitlab/section_workspace.svg") + svg: image_path(IMAGE_PATH_WORKSPACE) }, plan: { - svg: image_path("learn_gitlab/section_plan.svg") + svg: image_path(IMAGE_PATH_PLAN) }, deploy: { - svg: image_path("learn_gitlab/section_deploy.svg") + svg: image_path(IMAGE_PATH_DEPLOY) } } end @@ -65,22 +57,20 @@ module LearnGitlabHelper { name: project.name } end - def action_urls - LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } - .merge(LearnGitlab::Onboarding::ACTION_DOC_URLS) - end - - def new_action_urls(project) - action_urls.merge( + def action_urls(project) + action_issue_urls.merge( issue_created: project_issues_path(project), git_write: project_path(project), - pipeline_created: project_pipelines_path(project), merge_request_created: project_merge_requests_path(project), user_added: project_members_url(project), security_scan_enabled: project_security_configuration_path(project) ) end + def action_issue_urls + LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } + end + def learn_gitlab_project @learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project end diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb index d24680bc0b0..16caf862c7b 100644 --- a/app/helpers/listbox_helper.rb +++ b/app/helpers/listbox_helper.rb @@ -16,8 +16,10 @@ module ListboxHelper # the sort key), `text` is the user-facing string for the item, and `href` is # the path to redirect to when that item is selected. # - # The `selected` parameter is the currently selected `value`, and must - # correspond to one of the `items`, or be `nil`. When `selected.nil?`, the first item is selected. + # The `selected` parameter is the currently selected `value`, and should + # correspond to one of the `items`, or be `nil`. When `selected.nil?` or + # a value which does not correspond to one of the items, the first item is + # selected. # # The final parameter `html_options` applies arbitrary attributes to the # returned tag. Some of these are passed to the underlying Vue component as @@ -37,9 +39,12 @@ module ListboxHelper webpack_bundle_tag 'redirect_listbox' end - selected ||= items.first[:value] selected_option = items.find { |opt| opt[:value] == selected } - raise ArgumentError, "cannot find #{selected} in #{items}" unless selected_option + + unless selected_option + selected_option = items.first + selected = selected_option[:value] + end button = button_tag(type: :button, class: DROPDOWN_BUTTON_CLASSES) do content_tag(:span, selected_option[:text], class: DROPDOWN_INNER_CLASS) + diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index f16d9f6325b..7a4cc61af79 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -127,7 +127,7 @@ module MarkupHelper text = wiki_page.content return '' unless text.present? - context = render_wiki_content_context(@wiki, wiki_page, context) + context = render_wiki_content_context(wiki_page.wiki, wiki_page, context) html = markup_unsafe(wiki_page.path, text, context) prepare_for_rendering(html, context) @@ -181,7 +181,8 @@ module MarkupHelper wiki: wiki, repository: wiki.repository, page_slug: wiki_page.slug, - issuable_reference_expansion_enabled: true + issuable_reference_expansion_enabled: true, + requested_path: wiki_page.path ).merge(render_wiki_content_context_container(wiki)) end @@ -263,7 +264,7 @@ module MarkupHelper end def asciidoc_unsafe(text, context = {}) - context.merge!( + context.reverse_merge!( commit: @commit, ref: @ref, requested_path: @path diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index abb7128470f..84a3802c72c 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -150,11 +150,20 @@ module MergeRequestsHelper review_requested_count = review_requested_merge_requests_count total_count = assigned_count + review_requested_count - { + counts = { assigned: assigned_count, review_requested: review_requested_count, total: total_count } + + if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml) + attention_requested_count = attention_requested_merge_requests_count + + counts[:attention_requested_count] = attention_requested_count + counts[:total] = attention_requested_count + end + + counts end end @@ -205,6 +214,10 @@ module MergeRequestsHelper current_user.review_requested_open_merge_requests_count end + def attention_requested_merge_requests_count + current_user.attention_requested_open_merge_requests_count + end + def default_suggestion_commit_message @project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 402a363349f..01075862618 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -50,8 +50,6 @@ module PackagesHelper Gitlab.com? && Gitlab.config.registry.enabled && project.feature_available?(:container_registry, current_user) && - !Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries && - Feature.enabled?(:container_expiration_policies_historic_entry, project) && project.container_expiration_policy.nil? && project.container_repositories.exists? end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index 3167142e193..88bf09f0c03 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -22,4 +22,8 @@ module PaginationHelper def paginate_with_count(collection, remote: nil, total_pages: nil) paginate(collection, remote: remote, theme: 'gitlab', total_pages: total_pages) end + + def page_size + Kaminari.config.default_per_page + end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 17450e5b26b..6a8c39b5b15 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -62,6 +62,10 @@ module PreferencesHelper @user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class end + def user_application_dark_mode? + user_application_theme == 'gl-dark' + end + def user_application_theme_css_filename @user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename end diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb index 43d520d0eab..c17cb787c9f 100644 --- a/app/helpers/projects/cluster_agents_helper.rb +++ b/app/helpers/projects/cluster_agents_helper.rb @@ -7,7 +7,9 @@ module Projects::ClusterAgentsHelper agent_name: agent_name, can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s, empty_state_svg_path: image_path('illustrations/operations-dashboard_empty.svg'), - project_path: project.full_path + project_path: project.full_path, + kas_address: Gitlab::Kas.external_url, + can_admin_cluster: can?(current_user, :admin_cluster, project).to_s } end end diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index 5be4f67bde8..471565d162c 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -12,7 +12,8 @@ module Projects::ErrorTrackingHelper 'error-tracking-enabled' => error_tracking_enabled.to_s, 'project-path' => project.full_path, 'list-path' => project_error_tracking_index_path(project), - 'illustration-path' => image_path('illustrations/cluster_popover.svg') + 'illustration-path' => image_path('illustrations/cluster_popover.svg'), + 'show-integrated-tracking-disabled-alert' => show_integrated_tracking_disabled_alert?(project).to_s } end @@ -27,4 +28,15 @@ module Projects::ErrorTrackingHelper 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) } end + + private + + def show_integrated_tracking_disabled_alert?(project) + return false if ::Feature.enabled?(:integrated_error_tracking, project) + + setting ||= project.error_tracking_setting || + project.build_error_tracking_setting + + setting.integrated_enabled? + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 6098ef63ec3..8a75f545a32 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -160,7 +160,7 @@ module ProjectsHelper end def link_to_autodeploy_doc - link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank' + link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener' end def autodeploy_flash_notice(branch_name) @@ -431,19 +431,26 @@ module ProjectsHelper end def import_from_bitbucket_message - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/bitbucket") } + configure_oauth_import_message('Bitbucket', help_page_path("integration/bitbucket")) + end + + def import_from_gitlab_message + configure_oauth_import_message('GitLab.com', help_page_path("integration/gitlab")) + end + private + + def configure_oauth_import_message(provider, help_url) str = if current_user.admin? - 'ImportProjects|To enable importing projects from Bitbucket, as administrator you need to configure %{link_start}OAuth integration%{link_end}' + 'ImportProjects|To enable importing projects from %{provider}, as administrator you need to configure %{link_start}OAuth integration%{link_end}' else - 'ImportProjects|To enable importing projects from Bitbucket, ask your GitLab administrator to configure %{link_start}OAuth integration%{link_end}' + 'ImportProjects|To enable importing projects from %{provider}, ask your GitLab administrator to configure %{link_start}OAuth integration%{link_end}' end - s_(str).html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_url } + s_(str).html_safe % { provider: provider, link_start: link_start, link_end: '</a>'.html_safe } end - private - def tab_ability_map { cycle_analytics: :read_cycle_analytics, diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index fd9907edc37..f1fafd563ce 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -15,7 +15,7 @@ module Routing end def mask_params - return default_root_url + @request.original_fullpath unless has_maskable_params? + return @request.original_url unless has_maskable_params? masked_params = @request.path_parameters.to_h do |key, value| case key @@ -66,10 +66,6 @@ module Routing query_string_hash end - - def default_root_url - Gitlab::Routing.url_helpers.root_url(only_path: false) - end end def masked_page_url(group:, project:) diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index e9466a9e97e..f0389000eb3 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -5,7 +5,7 @@ module SessionsHelper def recently_confirmed_com? strong_memoize(:recently_confirmed_com) do - ::Gitlab.dev_env_or_com? && + ::Gitlab.com? && !!flash[:notice]&.include?(t(:confirmed, scope: [:devise, :confirmations])) end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index fb30e8ca059..4db14d5cc4d 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -328,6 +328,16 @@ module SortingHelper sort_direction_button(url, reverse_sort, sort_value) end + + def admin_users_sort_options(path_params) + users_sort_options_hash.map do |value, text| + { + value: value, + text: text, + href: admin_users_path(sort: value, **path_params) + } + end + end end SortingHelper.prepend_mod_with('SortingHelper') diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index 34ba66db444..a075ccc38f5 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -25,16 +25,17 @@ module StorageHelper end def storage_enforcement_banner_info(namespace) + return unless can?(current_user, :admin_namespace, namespace) return if namespace.paid? return unless namespace.storage_enforcement_date && namespace.storage_enforcement_date >= Date.today return if user_dismissed_storage_enforcement_banner?(namespace) { text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \ - "View and manage your usage in %{strong_start}Group Settings > Usage quotas%{strong_end}.")).html_safe % - { storage_enforcement_date: namespace.storage_enforcement_date, strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe }, + "View and manage your usage in %{strong_start}%{namespace_type} settings > Usage quotas%{strong_end}.")).html_safe % + { storage_enforcement_date: namespace.storage_enforcement_date, strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe, namespace_type: namespace.type }, variant: 'warning', - callouts_path: group_callouts_path, + callouts_path: namespace.user_namespace? ? callouts_path : group_callouts_path, callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace), learn_more_link: link_to(_('Learn more.'), help_page_path('/'), rel: 'noopener noreferrer', target: '_blank') # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632 } @@ -52,13 +53,17 @@ module StorageHelper return :first if days_to_enforcement_date > 30 return :second if days_to_enforcement_date > 15 && days_to_enforcement_date <= 30 return :third if days_to_enforcement_date > 7 && days_to_enforcement_date <= 15 - return :fourth if days_to_enforcement_date > 0 && days_to_enforcement_date <= 7 + return :fourth if days_to_enforcement_date >= 0 && days_to_enforcement_date <= 7 end def user_dismissed_storage_enforcement_banner?(namespace) return false unless current_user - current_user.dismissed_callout_for_group?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace), - group: namespace) + if namespace.user_namespace? + current_user.dismissed_callout?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace)) + else + current_user.dismissed_callout_for_group?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace), + group: namespace) + end end end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index f1e0be3a622..d3af6a00181 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -32,7 +32,7 @@ module SubmoduleHelper namespace.sub!(%r{\A/}, '') project.rstrip! - project.sub!(/\.git\z/, '') + project.delete_suffix!('.git') if self_url?(url, namespace, project) [ diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 79767ca76b7..60bf79f3114 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -18,7 +18,7 @@ module TodosHelper when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you' when Todo::REVIEW_REQUESTED then 'requested a review of' when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on" - when Todo::BUILD_FAILED then 'The build failed for' + when Todo::BUILD_FAILED then 'The pipeline failed in' when Todo::MARKED then 'added a todo for' when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for" when Todo::UNMERGEABLE then 'Could not merge' diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 23a9601aed7..2fef4ae98a9 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -203,9 +203,11 @@ module TreeHelper show_edit_button: show_edit_button?(options), show_web_ide_button: show_web_ide_button?, show_gitpod_button: show_gitpod_button?, + show_pipeline_editor_button: show_pipeline_editor_button?(@project, @path), web_ide_url: web_ide_url, edit_url: edit_url(options), + pipeline_editor_url: project_ci_pipeline_editor_path(@project, branch_name: @ref), gitpod_url: gitpod_url, user_preferences_gitpod_path: profile_preferences_path(anchor: 'user_gitpod_enabled'), diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 32b0d7b3fe3..87c8bf5cb28 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -10,6 +10,7 @@ module Users REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' + REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -47,7 +48,8 @@ module Users !Gitlab.com? && current_user&.admin? && signup_enabled? && - !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) + !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) && + REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS.any? { |path| controller.controller_path.match?(path) } end def dismiss_two_factor_auth_recovery_settings_check diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb index 6c73d365e8e..9ec22a659d3 100644 --- a/app/helpers/web_ide_button_helper.rb +++ b/app/helpers/web_ide_button_helper.rb @@ -29,6 +29,10 @@ module WebIdeButtonHelper show_web_ide_button? && Gitlab::CurrentSettings.gitpod_enabled end + def show_pipeline_editor_button?(project, path) + can_view_pipeline_editor?(project) && path == project.ci_config_path_or_default + end + def can_push_code? current_user&.can?(:push_code, @project) end diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb index ccccfcb930b..bbf56c51c6d 100644 --- a/app/helpers/whats_new_helper.rb +++ b/app/helpers/whats_new_helper.rb @@ -10,7 +10,7 @@ module WhatsNewHelper end def display_whats_new? - (Gitlab.dev_env_org_or_com? || user_signed_in?) && + (Gitlab.org_or_com? || user_signed_in?) && !Gitlab::CurrentSettings.current_application_settings.whats_new_variant_disabled? end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index e0c95370072..94ed83a7d4a 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -7,6 +7,7 @@ class ApplicationMailer < ActionMailer::Base helper MarkupHelper attr_accessor :current_user + helper_method :current_user, :can? default from: proc { default_sender_address.format } diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 592c394bb48..28e51ba311b 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -58,6 +58,18 @@ module Emails end # rubocop: enable CodeReuse/ActiveRecord + def access_token_created_email(user, token_name) + return unless user&.active? + + @user = user + @target_url = profile_personal_access_tokens_url + @token_name = token_name + + Gitlab::I18n.with_locale(@user.preferred_language) do + mail(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) + end + end + def access_token_about_to_expire_email(user, token_names) return unless user diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb new file mode 100644 index 00000000000..44d2dc369f7 --- /dev/null +++ b/app/models/analytics/cycle_analytics/aggregation.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Analytics::CycleAnalytics::Aggregation < ApplicationRecord + include FromUnion + + belongs_to :group, optional: false + + validates :incremental_runtimes_in_seconds, :incremental_processed_records, :last_full_run_runtimes_in_seconds, :last_full_run_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true + + scope :priority_order, -> (column_to_sort = :last_incremental_run_at) { order(arel_table[column_to_sort].asc.nulls_first) } + scope :enabled, -> { where('enabled IS TRUE') } + + def estimated_next_run_at + return unless enabled + return if last_incremental_run_at.nil? + + estimation = duration_until_the_next_aggregation_job + + average_aggregation_duration + + (last_incremental_run_at - earliest_last_run_at) + + estimation < 1 ? nil : estimation.from_now + end + + def self.safe_create_for_group(group) + top_level_group = group.root_ancestor + aggregation = find_by(group_id: top_level_group.id) + return aggregation if aggregation.present? + + insert({ group_id: top_level_group.id }, unique_by: :group_id) + find_by(group_id: top_level_group.id) + end + + private + + # The aggregation job is scheduled every 10 minutes: */10 * * * * + def duration_until_the_next_aggregation_job + (10 - (DateTime.current.minute % 10)).minutes.seconds + end + + def average_aggregation_duration + return 0.seconds if incremental_runtimes_in_seconds.empty? + + average = incremental_runtimes_in_seconds.sum.fdiv(incremental_runtimes_in_seconds.size) + average.seconds + end + + def earliest_last_run_at + max = self.class.select(:last_incremental_run_at) + .where(enabled: true) + .where.not(last_incremental_run_at: nil) + .priority_order + .limit(1) + .to_sql + + connection.select_value("(#{max})") + end + + def self.load_batch(last_run_at, column_to_query = :last_incremental_run_at, batch_size = 100) + last_run_at_not_set = Analytics::CycleAnalytics::Aggregation + .enabled + .where(column_to_query => nil) + .priority_order(column_to_query) + .limit(batch_size) + + last_run_at_before = Analytics::CycleAnalytics::Aggregation + .enabled + .where(arel_table[column_to_query].lt(last_run_at)) + .priority_order(column_to_query) + .limit(batch_size) + + Analytics::CycleAnalytics::Aggregation + .from_union([last_run_at_not_set, last_run_at_before], remove_order: false, remove_duplicates: false) + .limit(batch_size) + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 06ff18ca409..198a3653cd3 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -5,6 +5,7 @@ class ApplicationRecord < ActiveRecord::Base include Transactions include LegacyBulkInsert include CrossDatabaseModification + include SensitiveSerializableHash self.abstract_class = true @@ -60,8 +61,10 @@ class ApplicationRecord < ActiveRecord::Base end # Start a new transaction with a shorter-than-usual statement timeout. This is - # currently one third of the default 15-second timeout - def self.with_fast_read_statement_timeout(timeout_ms = 5000) + # currently one third of the default 15-second timeout with a 500ms buffer + # to allow callers gracefully handling the errors to still complete within + # the 5s target duration of a low urgency request. + def self.with_fast_read_statement_timeout(timeout_ms = 4500) ::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}") @@ -99,6 +102,10 @@ class ApplicationRecord < ActiveRecord::Base where('EXISTS (?)', query.select(1)) end + def self.where_not_exists(query) + where('NOT EXISTS (?)', query.select(1)) + end + def self.declarative_enum(enum_mod) enum(enum_mod.key => enum_mod.values) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 02fbf0f855e..c7aad7ff861 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22' ignore_columns %i[static_objects_external_storage_auth_token], remove_with: '14.9', remove_after: '2022-03-22' ignore_column %i[max_package_files_for_package_destruction], remove_with: '14.9', remove_after: '2022-03-22' + ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -362,6 +363,9 @@ class ApplicationSetting < ApplicationRecord :container_registry_expiration_policies_worker_capacity, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :container_registry_expiration_policies_caching, + inclusion: { in: [true, false], message: _('must be a boolean value') } + validates :container_registry_import_max_tags_count, :container_registry_import_max_retries, :container_registry_import_start_max_retries, @@ -516,9 +520,12 @@ class ApplicationSetting < ApplicationRecord validates :notes_create_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :user_email_lookup_limit, + validates :search_rate_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :search_rate_limit_unauthenticated, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :notes_create_limit_allowlist, length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false @@ -650,7 +657,17 @@ class ApplicationSetting < ApplicationRecord users_count >= INSTANCE_REVIEW_MIN_USERS end + Recursion = Class.new(RuntimeError) + def self.create_from_defaults + # this is posssible if calls to create the record depend on application + # settings themselves. This was seen in the case of a feature flag called by + # `transaction` that ended up requiring application settings to determine metrics behavior. + # If something like that happens, we break the loop here, and let the caller decide how to manage it. + raise Recursion if Thread.current[:application_setting_create_from_defaults] + + Thread.current[:application_setting_create_from_defaults] = true + check_schema! transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions @@ -659,6 +676,8 @@ class ApplicationSetting < ApplicationRecord rescue ActiveRecord::RecordNotUnique # We already have an ApplicationSetting record, so just return it. current_without_cache + ensure + Thread.current[:application_setting_create_from_defaults] = nil end def self.find_or_create_without_cache diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 415f0b35f3a..42049713883 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -218,7 +218,9 @@ module ApplicationSettingImplementation valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES, wiki_page_max_content_bytes: 50.megabytes, container_registry_delete_tags_service_timeout: 250, - container_registry_expiration_policies_worker_capacity: 0, + container_registry_expiration_policies_worker_capacity: 4, + container_registry_cleanup_tags_service_max_list_size: 200, + container_registry_expiration_policies_caching: true, container_registry_import_max_tags_count: 100, container_registry_import_max_retries: 3, container_registry_import_start_max_retries: 50, @@ -231,7 +233,8 @@ module ApplicationSettingImplementation rate_limiting_response_text: nil, whats_new_variant: 0, user_deactivation_emails_enabled: true, - user_email_lookup_limit: 60, + search_rate_limit: 30, + search_rate_limit_unauthenticated: 10, users_get_by_id_limit: 300, users_get_by_id_limit_allowlist: [] } @@ -402,7 +405,7 @@ module ApplicationSettingImplementation def normalized_repository_storage_weights strong_memoize(:normalized_repository_storage_weights) do repository_storages_weights = repository_storages_weighted.slice(*Gitlab.config.repositories.storages.keys) - weights_total = repository_storages_weights.values.reduce(:+) + weights_total = repository_storages_weights.values.sum repository_storages_weights.transform_values do |w| next w if weights_total == 0 diff --git a/app/models/blobs/notebook.rb b/app/models/blobs/notebook.rb new file mode 100644 index 00000000000..bdb438cccd9 --- /dev/null +++ b/app/models/blobs/notebook.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Blobs + class Notebook < ::Blob + attr_reader :data + + def initialize(blob, data) + super(blob.__getobj__, blob.container) + @data = data + end + end +end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 1ee5c081840..949902fbb77 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -4,12 +4,21 @@ class BroadcastMessage < ApplicationRecord include CacheMarkdownField include Sortable + ALLOWED_TARGET_ACCESS_LEVELS = [ + Gitlab::Access::GUEST, + Gitlab::Access::REPORTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::MAINTAINER, + Gitlab::Access::OWNER + ].freeze + cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true validates :message, presence: true validates :starts_at, presence: true validates :ends_at, presence: true validates :broadcast_type, presence: true + validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS } validates :color, allow_blank: true, color: true validates :font, allow_blank: true, color: true @@ -29,20 +38,20 @@ class BroadcastMessage < ApplicationRecord } class << self - def current_banner_messages(current_path = nil) - fetch_messages BANNER_CACHE_KEY, current_path do + def current_banner_messages(current_path: nil, user_access_level: nil) + fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do current_and_future_messages.banner end end - def current_notification_messages(current_path = nil) - fetch_messages NOTIFICATION_CACHE_KEY, current_path do + 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 end end - def current(current_path = nil) - fetch_messages CACHE_KEY, current_path do + def current(current_path: nil, user_access_level: nil) + fetch_messages CACHE_KEY, current_path, user_access_level do current_and_future_messages end end @@ -53,7 +62,7 @@ class BroadcastMessage < ApplicationRecord def cache ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do - Gitlab::JsonCache.new(cache_key_with_version: false) + Gitlab::JsonCache.new end end @@ -63,7 +72,7 @@ class BroadcastMessage < ApplicationRecord private - def fetch_messages(cache_key, current_path) + def fetch_messages(cache_key, current_path, user_access_level) messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do yield end @@ -74,7 +83,13 @@ class BroadcastMessage < ApplicationRecord # displaying we'll refresh the cache so we don't need to keep filtering. cache.expire(cache_key) if now_or_future != messages - now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) } + messages = now_or_future.select(&:now?) + messages = messages.select do |message| + message.matches_current_user_access_level?(user_access_level) + end + messages.select do |message| + message.matches_current_path(current_path) + end end end @@ -102,6 +117,13 @@ class BroadcastMessage < ApplicationRecord now? || future? end + def matches_current_user_access_level?(user_access_level) + return false if target_access_levels.present? && Feature.disabled?(:role_targeted_broadcast_messages, default_enabled: :yaml) + return true unless target_access_levels.present? + + target_access_levels.include? user_access_level + end + def matches_current_path(current_path) return false if current_path.blank? && target_path.present? return true if current_path.blank? || target_path.blank? diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 38b7da76306..a7e1384641c 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -20,6 +20,8 @@ class BulkImports::Entity < ApplicationRecord self.table_name = 'bulk_import_entities' + FailedError = Class.new(StandardError) + belongs_to :bulk_import, optional: false belongs_to :parent, class_name: 'BulkImports::Entity', optional: true diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb index abf064adaae..cae6aad27da 100644 --- a/app/models/bulk_imports/export_status.rb +++ b/app/models/bulk_imports/export_status.rb @@ -30,7 +30,12 @@ module BulkImports def export_status strong_memoize(:export_status) do - fetch_export_status.find { |item| item['relation'] == relation } + status = fetch_export_status + + # Consider empty response as failed export + raise StandardError, 'Empty export status response' unless status&.present? + + status.find { |item| item['relation'] == relation } end rescue StandardError => e { 'status' => Export::FAILED, 'error' => e.message } diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 50bda64d537..2ff777bfc89 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -11,6 +11,11 @@ module Ci InvalidBridgeTypeError = Class.new(StandardError) InvalidTransitionError = Class.new(StandardError) + FORWARD_DEFAULTS = { + yaml_variables: true, + pipeline_variables: false + }.freeze + belongs_to :project belongs_to :trigger_request has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline", @@ -199,12 +204,13 @@ module Ci end def downstream_variables - variables = scoped_variables.concat(pipeline.persisted_variables) - - variables.to_runner_variables.yield_self do |all_variables| - yaml_variables.to_a.map do |hash| - { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) } - end + if ::Feature.enabled?(:ci_trigger_forward_variables, project, default_enabled: :yaml) + calculate_downstream_variables + .reverse # variables priority + .uniq { |var| var[:key] } # only one variable key to pass + .reverse + else + legacy_downstream_variables end end @@ -250,6 +256,58 @@ module Ci } } end + + def legacy_downstream_variables + variables = scoped_variables.concat(pipeline.persisted_variables) + + variables.to_runner_variables.yield_self do |all_variables| + yaml_variables.to_a.map do |hash| + { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) } + end + end + end + + def calculate_downstream_variables + expand_variables = scoped_variables + .concat(pipeline.persisted_variables) + .to_runner_variables + + # The order of this list refers to the priority of the variables + downstream_yaml_variables(expand_variables) + + downstream_pipeline_variables(expand_variables) + end + + def downstream_yaml_variables(expand_variables) + return [] unless forward_yaml_variables? + + yaml_variables.to_a.map do |hash| + { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) } + end + end + + def downstream_pipeline_variables(expand_variables) + return [] unless forward_pipeline_variables? + + pipeline.variables.to_a.map do |variable| + { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) } + end + end + + def forward_yaml_variables? + strong_memoize(:forward_yaml_variables) do + result = options&.dig(:trigger, :forward, :yaml_variables) + + result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result + end + end + + def forward_pipeline_variables? + strong_memoize(:forward_pipeline_variables) do + result = options&.dig(:trigger, :forward, :pipeline_variables) + + result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result + end + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index c4d1a2c740b..68ec196a9ee 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -10,6 +10,8 @@ module Ci include Presentable include Importable include Ci::HasRef + include HasDeploymentName + extend ::Gitlab::Utils::Override BuildArchivedError = Class.new(StandardError) @@ -35,6 +37,8 @@ module Ci DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD' RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute + DEPLOYMENT_NAMES = %w[deploy release rollout].freeze + has_one :deployment, as: :deployable, class_name: 'Deployment' has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id @@ -68,6 +72,7 @@ module Ci delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :service_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project + delegate :harbor_integration, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true ## @@ -579,6 +584,7 @@ module Ci .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) .concat(deploy_token_variables) + .concat(harbor_variables) end end @@ -615,6 +621,12 @@ module Ci end end + def harbor_variables + return [] unless harbor_integration.try(:activated?) + + Gitlab::Ci::Variables::Collection.new(harbor_integration.ci_variables) + end + def features { trace_sections: true, @@ -1123,6 +1135,10 @@ module Ci .include?(exit_code) end + def track_deployment_usage + Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment? + end + protected def run_status_commit_hooks! diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 165bee5c54d..0af5533613f 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -18,5 +18,6 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } + scope :for_groups, ->(group_ids) { where(group_id: group_ids) } end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a1311b8555f..ae3ea7aa03f 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -25,6 +25,7 @@ module Ci }.freeze CONFIG_EXTENSION = '.gitlab-ci.yml' DEFAULT_CONFIG_PATH = CONFIG_EXTENSION + CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze BridgeStatusError = Class.new(StandardError) @@ -421,9 +422,7 @@ module Ci sql = sql.where(ref: ref) if ref - sql.each_with_object({}) do |pipeline, hash| - hash[pipeline.sha] = pipeline - end + sql.index_by(&:sha) end def self.latest_successful_ids_per_project @@ -653,7 +652,7 @@ module Ci def coverage coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 - coverage_array.reduce(:+) / coverage_array.size + coverage_array.sum / coverage_array.size end end @@ -1165,11 +1164,7 @@ module Ci end def merge_request? - if Feature.enabled?(:ci_pipeline_merge_request_presence_check, default_enabled: :yaml) - merge_request_id.present? && merge_request - else - merge_request_id.present? - end + merge_request_id.present? && merge_request.present? end def external_pull_request? diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index b915495ac38..96e5567e85e 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -66,6 +66,18 @@ module Ci project.actual_limits.limit_for(:ci_daily_pipeline_schedule_triggers) end + def ref_for_display + return unless ref.present? + + ref.gsub(%r{^refs/(heads|tags)/}, '') + end + + def for_tag? + return false unless ref.present? + + ref.start_with? 'refs/tags/' + end + private def worker_cron_expression diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 372df8cc264..4d119706a43 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -16,7 +16,7 @@ module Ci scope :with_needs, -> (names = nil) do needs = Ci::BuildNeed.scoped_build.select(1) needs = needs.where(name: names) if names - where('EXISTS (?)', needs).preload(:needs) + where('EXISTS (?)', needs) end scope :without_needs, -> (names = nil) do diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 11150e839a3..4228da279a4 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -59,7 +59,7 @@ module Ci AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze - AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648 + AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: not_connected. In %16.0: active, paused. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648 AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze @@ -200,7 +200,7 @@ module Ci validates :config, json_schema: { filename: 'ci_runner_config' } - validates :maintenance_note, length: { maximum: 255 } + validates :maintenance_note, length: { maximum: 1024 } alias_attribute :maintenance_note, :maintainer_note @@ -329,9 +329,9 @@ module Ci end # DEPRECATED - # TODO Remove in %15.0 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 + # TODO Remove in %16.0 in favor of `status` for REST calls def deprecated_rest_status - if contacted_at.nil? + if contacted_at.nil? # TODO Remove in %15.0, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 :not_connected elsif active? online? ? :online : :offline diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb index 56f632b6232..18f0093ea41 100644 --- a/app/models/ci/secure_file.rb +++ b/app/models/ci/secure_file.rb @@ -3,10 +3,14 @@ module Ci class SecureFile < Ci::ApplicationRecord include FileStoreMounter + include Limitable FILE_SIZE_LIMIT = 5.megabytes.freeze CHECKSUM_ALGORITHM = 'sha256' + self.limit_scope = :project + self.limit_name = 'project_ci_secure_files' + belongs_to :project, optional: false validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT } diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb deleted file mode 100644 index 18c00532d78..00000000000 --- a/app/models/concerns/blocks_json_serialization.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Overrides `as_json` and `to_json` to raise an exception when called in order -# to prevent accidentally exposing attributes -# -# Not that would ever happen... but just in case. -module BlocksJsonSerialization - extend ActiveSupport::Concern - - JsonSerializationError = Class.new(StandardError) - - def to_json(*) - raise JsonSerializationError, - "JSON serialization has been disabled on #{self.class.name}" - end - - alias_method :as_json, :to_json -end diff --git a/app/models/concerns/blocks_unsafe_serialization.rb b/app/models/concerns/blocks_unsafe_serialization.rb new file mode 100644 index 00000000000..72adbe70f15 --- /dev/null +++ b/app/models/concerns/blocks_unsafe_serialization.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Overrides `#serializable_hash` to raise an exception when called without the `only` option +# in order to prevent accidentally exposing attributes. +# +# An `unsafe: true` option can also be passed in to bypass this check. +# +# `#serializable_hash` is used by ActiveModel serializers like `ActiveModel::Serializers::JSON` +# which overrides `#as_json` and `#to_json`. +# +module BlocksUnsafeSerialization + extend ActiveSupport::Concern + extend ::Gitlab::Utils::Override + + UnsafeSerializationError = Class.new(StandardError) + + override :serializable_hash + def serializable_hash(options = nil) + return super if allow_serialization?(options) + + raise UnsafeSerializationError, + "Serialization has been disabled on #{self.class.name}" + end + + private + + def allow_serialization?(options = nil) + return false unless options + + !!(options[:only] || options[:unsafe]) + end +end diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb index 927d6ccb28f..efc65e55e40 100644 --- a/app/models/concerns/bulk_member_access_load.rb +++ b/app/models/concerns/bulk_member_access_load.rb @@ -1,61 +1,19 @@ # frozen_string_literal: true -# Returns and caches in thread max member access for a resource -# module BulkMemberAccessLoad extend ActiveSupport::Concern included do - # Determine the maximum access level for a group of resources in bulk. - # - # Returns a Hash mapping resource ID -> maximum access level. - def max_member_access_for_resource_ids(resource_klass, resource_ids, &block) - raise 'Block is mandatory' unless block_given? - - memoization_index = self.id - memoization_class = self.class - - resource_ids = resource_ids.uniq - memo_id = "#{memoization_class}:#{memoization_index}" - access = load_access_hash(resource_klass, memo_id) - - # Look up only the IDs we need - resource_ids -= access.keys - - return access if resource_ids.empty? - - resource_access = yield(resource_ids) - - access.merge!(resource_access) - - missing_resource_ids = resource_ids - resource_access.keys - - missing_resource_ids.each do |resource_id| - access[resource_id] = Gitlab::Access::NO_ACCESS - end - - access - end - def merge_value_to_request_store(resource_klass, resource_id, value) - max_member_access_for_resource_ids(resource_klass, [resource_id]) do + Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass), + resource_ids: [resource_id], + default_value: Gitlab::Access::NO_ACCESS) do { resource_id => value } end end - private - - def max_member_access_for_resource_key(klass, memoization_index) - "max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}" - end - - def load_access_hash(resource_klass, memo_id) - return {} unless Gitlab::SafeRequestStore.active? - - key = max_member_access_for_resource_key(resource_klass, memo_id) - Gitlab::SafeRequestStore[key] ||= {} - - Gitlab::SafeRequestStore[key] + def max_member_access_for_resource_key(klass) + "max_member_access_for_#{klass.name.underscore.pluralize}:#{self.class}:#{self.id}" end end end diff --git a/app/models/concerns/ci/has_deployment_name.rb b/app/models/concerns/ci/has_deployment_name.rb new file mode 100644 index 00000000000..fe288134872 --- /dev/null +++ b/app/models/concerns/ci/has_deployment_name.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + module HasDeploymentName + extend ActiveSupport::Concern + + def count_user_deployment? + Feature.enabled?(:job_deployment_count) && deployment_name? + end + + def deployment_name? + self.class::DEPLOYMENT_NAMES.any? { |n| name.downcase.include?(n) } + end + end +end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index ccaccec3b6b..313c767e59f 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -7,12 +7,16 @@ module Ci DEFAULT_STATUS = 'created' BLOCKED_STATUS = %w[manual scheduled].freeze AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze + # TODO: replace STARTED_STATUSES with data from BUILD_STARTED_RUNNING_STATUSES in https://gitlab.com/gitlab-org/gitlab/-/issues/273378 + # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82149#note_865508501 + BUILD_STARTED_RUNNING_STATUSES = %w[running success failed].freeze STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze + CANCELABLE_STATUSES = %w[running waiting_for_resource preparing pending created scheduled].freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze @@ -85,7 +89,7 @@ module Ci scope :waiting_for_resource_or_upcoming, -> { with_status(:created, :scheduled, :waiting_for_resource) } scope :cancelable, -> do - where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) + where(status: klass::CANCELABLE_STATUSES) end scope :without_statuses, -> (names) do diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 4bfeba338d2..b41b1ba6008 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -102,9 +102,7 @@ module CounterAttribute run_after_commit_or_now do if counter_attribute_enabled?(attribute) - redis_state do |redis| - redis.incrby(counter_key(attribute), increment) - end + increment_counter(attribute, increment) FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute) else @@ -115,6 +113,28 @@ module CounterAttribute true end + def increment_counter(attribute, increment) + if counter_attribute_enabled?(attribute) + redis_state do |redis| + redis.incrby(counter_key(attribute), increment) + end + end + end + + def clear_counter!(attribute) + if counter_attribute_enabled?(attribute) + redis_state { |redis| redis.del(counter_key(attribute)) } + end + end + + def get_counter_value(attribute) + if counter_attribute_enabled?(attribute) + redis_state do |redis| + redis.get(counter_key(attribute)).to_i + end + end + end + def counter_key(attribute) "project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}" end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index b6245e29746..d9c622f247a 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -3,6 +3,8 @@ module DeploymentPlatform # rubocop:disable Gitlab/ModuleWithInstanceVariables def deployment_platform(environment: nil) + return if Feature.disabled?(:certificate_based_clusters, default_enabled: :yaml, type: :ops) + @deployment_platform ||= {} @deployment_platform[environment] ||= find_deployment_platform(environment) diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 28ee54afaa9..ad070090dd5 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -46,4 +46,17 @@ module HasUserType def internal? ghost? || (bot? && !project_bot?) end + + def redacted_name(viewing_user) + return self.name unless self.project_bot? + + return self.name if self.groups.any? && viewing_user&.can?(:read_group, self.groups.first) + + return self.name if viewing_user&.can?(:read_project, self.projects.first) + + # If the requester does not have permission to read the project bot name, + # the API returns an arbitrary string. UI changes will be addressed in a follow up issue: + # https://gitlab.com/gitlab-org/gitlab/-/issues/346058 + '****' + end end diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb new file mode 100644 index 00000000000..b1def38d019 --- /dev/null +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Integrations + module HasIssueTrackerFields + extend ActiveSupport::Concern + + included do + field :project_url, + required: true, + storage: :data_fields, + title: -> { _('Project URL') }, + help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') } + + field :issues_url, + required: true, + storage: :data_fields, + title: -> { s_('IssueTracker|Issue URL') }, + help: -> do + format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'), + colon_id: '<code>:id</code>'.html_safe + end + + field :new_issue_url, + required: true, + storage: :data_fields, + title: -> { s_('IssueTracker|New issue URL') }, + help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') } + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 0138c0ad20f..1eb30e88f16 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -74,6 +74,7 @@ module Issuable end has_many :note_authors, -> { distinct }, through: :notes, source: :author + has_many :user_note_authors, -> { distinct.where("notes.system = false") }, through: :notes, source: :author has_many :label_links, as: :target, inverse_of: :target has_many :labels, through: :label_links @@ -464,37 +465,54 @@ module Issuable false end - def to_hook_data(user, old_associations: {}) - changes = previous_changes + def hook_association_changes(old_associations) + changes = {} - if old_associations - old_labels = old_associations.fetch(:labels, labels) - old_assignees = old_associations.fetch(:assignees, assignees) - old_severity = old_associations.fetch(:severity, severity) + old_labels = old_associations.fetch(:labels, labels) + old_assignees = old_associations.fetch(:assignees, assignees) + old_severity = old_associations.fetch(:severity, severity) - if old_labels != labels - changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] - end + if old_labels != labels + changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] + end - if old_assignees != assignees - changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] - end + if old_assignees != assignees + changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] + end + + if supports_severity? && old_severity != severity + changes[:severity] = [old_severity, severity] + end + + if supports_escalation? && escalation_status + current_escalation_status = escalation_status.status_name + old_escalation_status = old_associations.fetch(:escalation_status, current_escalation_status) - if supports_severity? && old_severity != severity - changes[:severity] = [old_severity, severity] + if old_escalation_status != current_escalation_status + changes[:escalation_status] = [old_escalation_status, current_escalation_status] end + end - if self.respond_to?(:total_time_spent) - old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent) - old_time_change = old_associations.fetch(:time_change, time_change) + if self.respond_to?(:total_time_spent) + old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent) + old_time_change = old_associations.fetch(:time_change, time_change) - if old_total_time_spent != total_time_spent - changes[:total_time_spent] = [old_total_time_spent, total_time_spent] - changes[:time_change] = [old_time_change, time_change] - end + if old_total_time_spent != total_time_spent + changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + changes[:time_change] = [old_time_change, time_change] end end + changes + end + + def to_hook_data(user, old_associations: {}) + changes = previous_changes + + if old_associations.present? + changes.merge!(hook_association_changes(old_associations)) + end + Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes) end diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb new file mode 100644 index 00000000000..3e14507bc70 --- /dev/null +++ b/app/models/concerns/issuable_link.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# == IssuableLink concern +# +# Contains common functionality shared between related Issues and related Epics +# +# Used by IssueLink, Epic::RelatedEpicLink +# +module IssuableLink + extend ActiveSupport::Concern + + TYPE_RELATES_TO = 'relates_to' + TYPE_BLOCKS = 'blocks' ## EE-only. Kept here to be used on link_type enum. + + class_methods do + def inverse_link_type(type) + type + end + + def issuable_type + raise NotImplementedError + end + end + + included do + validates :source, presence: true + validates :target, presence: true + validates :source, uniqueness: { scope: :target_id, message: 'is already related' } + validate :check_self_relation + validate :check_opposite_relation + + enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 } + + private + + def check_self_relation + return unless source && target + + if source == target + errors.add(:source, 'cannot be related to itself') + end + end + + def check_opposite_relation + return unless source && target + + if self.class.base_class.find_by(source: target, target: source) + errors.add(:source, "is already related to this #{self.class.issuable_type}") + end + end + end +end + +IssuableLink.prepend_mod_with('IssuableLink') +IssuableLink::ClassMethods.prepend_mod_with('IssuableLink::ClassMethods') diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb index 1c24032dbbb..5cbc937e465 100644 --- a/app/models/concerns/issue_resource_event.rb +++ b/app/models/concerns/issue_resource_event.rb @@ -8,6 +8,10 @@ module IssueResourceEvent scope :by_issue, ->(issue) { where(issue_id: issue.id) } - scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) } + scope :by_created_at_earlier_or_equal_to, ->(time) { where('created_at <= ?', time) } + scope :by_issue_ids, ->(issue_ids) do + table = self.klass.arel_table + where(table[:issue_id].in(issue_ids)) + end end end diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb index 5859f43a70c..893d06b4da8 100644 --- a/app/models/concerns/merge_request_reviewer_state.rb +++ b/app/models/concerns/merge_request_reviewer_state.rb @@ -14,6 +14,14 @@ module MergeRequestReviewerState presence: true, inclusion: { in: self.states.keys } + belongs_to :updated_state_by, class_name: 'User', foreign_key: :updated_state_by_user_id + after_initialize :set_state, unless: :persisted? + + def attention_requested_by + return unless attention_requested? + + updated_state_by + end end end diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb new file mode 100644 index 00000000000..68357c44300 --- /dev/null +++ b/app/models/concerns/pg_full_text_searchable.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# This module adds PG full-text search capabilities to a model. +# A `search_data` association with a `search_vector` column is required. +# +# Declare the fields that will be part of the search vector with their +# corresponding weights. Possible values for weight are A, B, C, or D. +# For example: +# +# include PgFullTextSearchable +# pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }] +# +# This module sets up an after_commit hook that updates the search data +# when the searchable columns are changed. You will need to implement the +# `#persist_pg_full_text_search_vector` method that does the actual insert or update. +# +# This also adds a `pg_full_text_search` scope so you can do: +# +# Model.pg_full_text_search("some search term") + +module PgFullTextSearchable + extend ActiveSupport::Concern + + LONG_WORDS_REGEX = %r([A-Za-z0-9+/@]{50,}).freeze + TSVECTOR_MAX_LENGTH = 1.megabyte.freeze + TEXT_SEARCH_DICTIONARY = 'english' + + def update_search_data! + tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight| + tsvector_arel_node(column, weight)&.to_sql + end + + persist_pg_full_text_search_vector(Arel.sql(tsvector_sql_nodes.compact.join(' || '))) + rescue ActiveRecord::StatementInvalid => e + raise unless e.cause.is_a?(PG::ProgramLimitExceeded) && e.message.include?('string is too long for tsvector') + + Gitlab::AppJsonLogger.error( + message: 'Error updating search data: string is too long for tsvector', + class: self.class.name, + model_id: self.id + ) + end + + private + + def persist_pg_full_text_search_vector(search_vector) + raise NotImplementedError + end + + def tsvector_arel_node(column, weight) + return if self[column].blank? + + column_text = self[column].gsub(LONG_WORDS_REGEX, ' ') + column_text = column_text[0..(TSVECTOR_MAX_LENGTH - 1)] + column_text = ActiveSupport::Inflector.transliterate(column_text) + + Arel::Nodes::NamedFunction.new( + 'setweight', + [ + Arel::Nodes::NamedFunction.new( + 'to_tsvector', + [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(column_text)] + ), + Arel::Nodes.build_quoted(weight) + ] + ) + end + + included do + cattr_reader :pg_full_text_searchable_columns do + {} + end + end + + class_methods do + def pg_full_text_searchable(columns:) + raise 'Full text search columns already defined!' if pg_full_text_searchable_columns.present? + + columns.each do |column| + pg_full_text_searchable_columns[column[:name]] = column[:weight] + end + + # We update this outside the transaction because this could raise an error if the resulting tsvector + # is too long. When that happens, we still persist the create / update but the model will not have a + # search data record. This is fine in most cases because this is a very rare occurrence and only happens + # with strings that are most likely unsearchable anyway. + # + # We also do not want to use a subtransaction here due to: https://gitlab.com/groups/gitlab-org/-/epics/6540 + after_save_commit do + next unless pg_full_text_searchable_columns.keys.any? { |f| saved_changes.has_key?(f) } + + update_search_data! + end + end + + def pg_full_text_search(search_term) + search_data_table = reflect_on_association(:search_data).klass.arel_table + + joins(:search_data).where( + Arel::Nodes::InfixOperation.new( + '@@', + search_data_table[:search_vector], + Arel::Nodes::NamedFunction.new( + 'websearch_to_tsquery', + [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)] + ) + ) + ) + end + end +end diff --git a/app/models/concerns/runners_token_prefixable.rb b/app/models/concerns/runners_token_prefixable.rb index 1aea874337e..99bbbece7c7 100644 --- a/app/models/concerns/runners_token_prefixable.rb +++ b/app/models/concerns/runners_token_prefixable.rb @@ -1,14 +1,8 @@ # frozen_string_literal: true module RunnersTokenPrefixable - extend ActiveSupport::Concern - # Prefix for runners_token which can be used to invalidate existing tokens. # The value chosen here is GR (for Gitlab Runner) combined with the rotation # date (20220225) decimal to hex encoded. RUNNERS_TOKEN_PREFIX = 'GR1348941' - - def runners_token_prefix - RUNNERS_TOKEN_PREFIX - end end diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb index 49342e30db6..5a7e16eb2c4 100644 --- a/app/models/concerns/select_for_project_authorization.rb +++ b/app/models/concerns/select_for_project_authorization.rb @@ -8,8 +8,10 @@ module SelectForProjectAuthorization select("projects.id AS project_id", "members.access_level") end - def select_as_maintainer_for_project_authorization - select(["projects.id AS project_id", "#{Gitlab::Access::MAINTAINER} AS access_level"]) + # workaround until we migrate Project#owners to have membership with + # OWNER access level + def select_project_owner_for_project_authorization + select(["projects.id AS project_id", "#{Gitlab::Access::OWNER} AS access_level"]) end end end diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb new file mode 100644 index 00000000000..725ec60e9b6 --- /dev/null +++ b/app/models/concerns/sensitive_serializable_hash.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module SensitiveSerializableHash + extend ActiveSupport::Concern + + included do + class_attribute :attributes_exempt_from_serializable_hash, default: [] + end + + class_methods do + def prevent_from_serialization(*keys) + self.attributes_exempt_from_serializable_hash ||= [] + self.attributes_exempt_from_serializable_hash.concat keys + end + end + + # Override serializable_hash to exclude sensitive attributes by default + # + # In general, prefer NOT to use serializable_hash / to_json / as_json in favor + # of serializers / entities instead which has an allowlist of attributes + def serializable_hash(options = nil) + return super unless prevent_sensitive_fields_from_serializable_hash? + return super if options && options[:unsafe_serialization_hash] + + options = options.try(:dup) || {} + options[:except] = Array(options[:except]).dup + + options[:except].concat self.class.attributes_exempt_from_serializable_hash + + if self.class.respond_to?(:encrypted_attributes) + options[:except].concat self.class.encrypted_attributes.keys + + # Per https://github.com/attr-encrypted/attr_encrypted/blob/a96693e9a2a25f4f910bf915e29b0f364f277032/lib/attr_encrypted.rb#L413 + options[:except].concat self.class.encrypted_attributes.values.map { |v| v[:attribute] } + options[:except].concat self.class.encrypted_attributes.values.map { |v| "#{v[:attribute]}_iv" } + end + + super(options) + end + + private + + def prevent_sensitive_fields_from_serializable_hash? + Feature.enabled?(:prevent_sensitive_fields_from_serializable_hash, default_enabled: :yaml) + end +end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 4901cd832ff..b475eb79aa3 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -12,7 +12,7 @@ module Spammable included do has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - attr_accessor :spam + attr_writer :spam attr_accessor :needs_recaptcha attr_accessor :spam_log @@ -29,6 +29,10 @@ module Spammable delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true end + def spam + !!@spam # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + def submittable_as_spam_by?(current_user) current_user && current_user.admin? && submittable_as_spam? end @@ -74,8 +78,9 @@ module Spammable end def recaptcha_error! - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\ - "Please, change the content or solve the reCAPTCHA to proceed.") + self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam. "\ + "Please, change the content or solve the reCAPTCHA to proceed.") \ + % { spammable_entity_type: spammable_entity_type }) end def unrecoverable_spam_error! diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 943ef3fa59f..d53594eb5af 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -44,7 +44,6 @@ module Timebox validates :group, presence: true, unless: :project validates :project, presence: true, unless: :group - validates :title, presence: true validate :timebox_type_check validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index f44ad8ebe90..d91ec161b84 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -8,6 +8,10 @@ module TokenAuthenticatable @encrypted_token_authenticatable_fields ||= [] end + def token_authenticatable_fields + @token_authenticatable_fields ||= [] + end + private def add_authentication_token_field(token_field, options = {}) @@ -23,6 +27,8 @@ module TokenAuthenticatable strategy = TokenAuthenticatableStrategies::Base .fabricate(self, token_field, options) + prevent_from_serialization(*strategy.token_fields) if respond_to?(:prevent_from_serialization) + if options.fetch(:unique, true) define_singleton_method("find_by_#{token_field}") do |token| strategy.find_token_authenticatable(token) @@ -82,9 +88,5 @@ module TokenAuthenticatable @token_authenticatable_module ||= const_set(:TokenAuthenticatable, Module.new).tap(&method(:include)) end - - def token_authenticatable_fields - @token_authenticatable_fields ||= [] - end end end diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index 2cec4ab460e..2b677f37c89 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -23,6 +23,14 @@ module TokenAuthenticatableStrategies raise NotImplementedError end + def token_fields + result = [token_field] + + result << @expires_at_field if expirable? + + result + end + # Default implementation returns the token as-is def format_token(instance, token) instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/concerns/token_authenticatable_strategies/digest.rb b/app/models/concerns/token_authenticatable_strategies/digest.rb index 9926662ed66..5c94f25949f 100644 --- a/app/models/concerns/token_authenticatable_strategies/digest.rb +++ b/app/models/concerns/token_authenticatable_strategies/digest.rb @@ -2,6 +2,10 @@ module TokenAuthenticatableStrategies class Digest < Base + def token_fields + super + [token_field_name] + end + def find_token_authenticatable(token, unscoped = false) return unless token diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb index e957d09fbc6..1db88c27181 100644 --- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -2,6 +2,10 @@ module TokenAuthenticatableStrategies class Encrypted < Base + def token_fields + super + [encrypted_field] + end + def find_token_authenticatable(token, unscoped = false) return if token.blank? diff --git a/app/models/concerns/update_namespace_statistics.rb b/app/models/concerns/update_namespace_statistics.rb new file mode 100644 index 00000000000..26d6fc10228 --- /dev/null +++ b/app/models/concerns/update_namespace_statistics.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# This module provides helpers for updating `NamespaceStatistics` with `after_save` and +# `after_destroy` hooks. +# +# Models including this module must respond to and return a `namespace` +# +# Example: +# +# class DependencyProxy::Manifest +# include UpdateNamespaceStatistics +# +# belongs_to :group +# alias_attribute :namespace, :group +# +# update_namespace_statistics namespace_statistics_name: :dependency_proxy_size +# end +module UpdateNamespaceStatistics + extend ActiveSupport::Concern + include AfterCommitQueue + + class_methods do + attr_reader :namespace_statistics_name, :statistic_attribute + + # Configure the model to update `namespace_statistics_name` on NamespaceStatistics, + # when `statistic_attribute` changes + # + # - namespace_statistics_name: A column of `NamespaceStatistics` to update + # - statistic_attribute: An attribute of the current model, default to `size` + def update_namespace_statistics(namespace_statistics_name:, statistic_attribute: :size) + @namespace_statistics_name = namespace_statistics_name + @statistic_attribute = statistic_attribute + + after_save(:schedule_namespace_statistics_refresh, if: :update_namespace_statistics?) + after_destroy(:schedule_namespace_statistics_refresh) + end + + private :update_namespace_statistics + end + + included do + private + + def update_namespace_statistics? + saved_change_to_attribute?(self.class.statistic_attribute) + end + + def schedule_namespace_statistics_refresh + run_after_commit do + Groups::UpdateStatisticsWorker.perform_async(namespace.id, [self.class.namespace_statistics_name]) + end + end + end +end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 1f123cb0244..fa03d73646d 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -14,6 +14,8 @@ class ContainerRepository < ApplicationRecord ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze MIGRATION_STATES = (IDLE_MIGRATION_STATES + ACTIVE_MIGRATION_STATES).freeze + MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze + TooManyImportsError = Class.new(StandardError) NativeImportError = Class.new(StandardError) @@ -64,7 +66,7 @@ class ContainerRepository < ApplicationRecord # feature flag since it is only accessed in this query. # https://gitlab.com/gitlab-org/gitlab/-/issues/350543 tracks the rollout and # removal of this feature flag. - joins(:project).where( + joins(project: [:namespace]).where( migration_state: [:default], created_at: ...ContainerRegistry::Migration.created_before ).with_target_import_tier @@ -74,7 +76,7 @@ class ContainerRepository < ApplicationRecord FROM feature_gates WHERE feature_gates.feature_key = 'container_registry_phase_2_deny_list' AND feature_gates.key = 'actors' - AND feature_gates.value = concat('Group:', projects.namespace_id) + AND feature_gates.value = concat('Group:', namespaces.traversal_ids[1]) )" ) end @@ -408,6 +410,16 @@ class ContainerRepository < ApplicationRecord update!(expiration_policy_started_at: Time.zone.now) end + def size + strong_memoize(:size) do + next unless Gitlab.com? + next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) + next unless gitlab_api_client.supports_gitlab_api? + + gitlab_api_client.repository_details(self.path, with_size: true)['size_bytes'] + end + end + def migration_in_active_state? migration_state.in?(ACTIVE_MIGRATION_STATES) end diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index a981351f4a0..4fa2c3fb8cf 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -23,8 +23,9 @@ class CustomerRelations::Contact < ApplicationRecord validates :last_name, presence: true, length: { maximum: 255 } validates :email, length: { maximum: 255 } validates :description, length: { maximum: 1024 } + validates :email, uniqueness: { scope: :group_id } validate :validate_email_format - validate :unique_email_for_group_hierarchy + validate :validate_root_group def self.reference_prefix '[contact:' @@ -41,14 +42,13 @@ class CustomerRelations::Contact < ApplicationRecord def self.find_ids_by_emails(group, emails) raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK - where(group_id: group.self_and_ancestor_ids, email: emails) - .pluck(:id) + where(group: group, email: emails).pluck(:id) end def self.exists_for_group?(group) return false unless group - exists?(group_id: group.self_and_ancestor_ids) + exists?(group: group) end private @@ -59,13 +59,9 @@ class CustomerRelations::Contact < ApplicationRecord self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) end - def unique_email_for_group_hierarchy - return unless group - return unless email + def validate_root_group + return if group&.root? - duplicate_email_exists = CustomerRelations::Contact - .where(group_id: group.self_and_hierarchy.pluck(:id), email: email) - .where.not(id: id).exists? - self.errors.add(:email, _('contact with same email already exists in group hierarchy')) if duplicate_email_exists + self.errors.add(:base, _('contacts can only be added to root groups')) end end diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb index 3e9d1e97c8c..dc7a3fd87bc 100644 --- a/app/models/customer_relations/issue_contact.rb +++ b/app/models/customer_relations/issue_contact.rb @@ -6,7 +6,7 @@ class CustomerRelations::IssueContact < ApplicationRecord belongs_to :issue, optional: false, inverse_of: :customer_relations_contacts belongs_to :contact, optional: false, inverse_of: :issue_contacts - validate :contact_belongs_to_issue_group_or_ancestor + validate :contact_belongs_to_root_group def self.find_contact_ids_by_emails(issue_id, emails) raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK @@ -24,11 +24,11 @@ class CustomerRelations::IssueContact < ApplicationRecord private - def contact_belongs_to_issue_group_or_ancestor + def contact_belongs_to_root_group return unless contact&.group_id return unless issue&.project&.namespace_id - return if issue.project.group&.self_and_ancestor_ids&.include?(contact.group_id) + return if issue.project.root_ancestor&.id == contact.group_id - errors.add(:base, _('The contact does not belong to the issue group or an ancestor')) + errors.add(:base, _("The contact does not belong to the issue group's root ancestor")) end end diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb index c206d1e05f5..a23b9d8fe28 100644 --- a/app/models/customer_relations/organization.rb +++ b/app/models/customer_relations/organization.rb @@ -19,9 +19,18 @@ class CustomerRelations::Organization < ApplicationRecord validates :name, uniqueness: { case_sensitive: false, scope: [:group_id] } validates :name, length: { maximum: 255 } validates :description, length: { maximum: 1024 } + validate :validate_root_group def self.find_by_name(group_id, name) where(group: group_id) .where('LOWER(name) = LOWER(?)', name) end + + private + + def validate_root_group + return if group&.root? + + self.errors.add(:base, _('organizations can only be added to root groups')) + end end diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb index f7b08f1d077..dc40ff62adb 100644 --- a/app/models/dependency_proxy/blob.rb +++ b/app/models/dependency_proxy/blob.rb @@ -5,8 +5,10 @@ class DependencyProxy::Blob < ApplicationRecord include TtlExpirable include Packages::Destructible include EachBatch + include UpdateNamespaceStatistics belongs_to :group + alias_attribute :namespace, :group MAX_FILE_SIZE = 5.gigabytes.freeze @@ -17,6 +19,7 @@ class DependencyProxy::Blob < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) } mount_file_store_uploader DependencyProxy::FileUploader + update_namespace_statistics namespace_statistics_name: :dependency_proxy_size def self.total_size sum(:size) diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb index c2587ffac9d..5ad746e4cd1 100644 --- a/app/models/dependency_proxy/manifest.rb +++ b/app/models/dependency_proxy/manifest.rb @@ -5,8 +5,10 @@ class DependencyProxy::Manifest < ApplicationRecord include TtlExpirable include Packages::Destructible include EachBatch + include UpdateNamespaceStatistics belongs_to :group + alias_attribute :namespace, :group MAX_FILE_SIZE = 10.megabytes.freeze DIGEST_HEADER = 'Docker-Content-Digest' @@ -20,6 +22,7 @@ class DependencyProxy::Manifest < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) } mount_file_store_uploader DependencyProxy::FileUploader + update_namespace_statistics namespace_statistics_name: :dependency_proxy_size def self.find_by_file_name_or_digest(file_name:, digest:) find_by(file_name: file_name) || find_by(digest: digest) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 46409465209..c06c809538a 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -8,7 +8,6 @@ class Deployment < ApplicationRecord include Importable include Gitlab::Utils::StrongMemoize include FastDestroyAll - include FromUnion StatusUpdateError = Class.new(StandardError) StatusSyncError = Class.new(StandardError) diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 6ebac6384bc..02979d5f804 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -145,7 +145,7 @@ class DiffNote < Note end def fetch_diff_file - return note_diff_file.raw_diff_file if note_diff_file + return note_diff_file.raw_diff_file if note_diff_file && !note_diff_file.raw_diff_file.has_renderable? if created_at_diff?(noteable.diff_refs) # We're able to use the already persisted diffs (Postgres) if we're diff --git a/app/models/environment.rb b/app/models/environment.rb index 51a9024721b..450ed6206d5 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -461,11 +461,16 @@ class Environment < ApplicationRecord # See https://en.wikipedia.org/wiki/Deployment_environment for industry standard deployment environments def guess_tier case name - when %r{dev|review|trunk}i then self.class.tiers[:development] - when %r{test|qc}i then self.class.tiers[:testing] - when %r{st(a|)g|mod(e|)l|pre|demo}i then self.class.tiers[:staging] - when %r{pr(o|)d|live}i then self.class.tiers[:production] - else self.class.tiers[:other] + when /(dev|review|trunk)/i + self.class.tiers[:development] + when /(test|tst|int|ac(ce|)pt|qa|qc|control|quality)/i + self.class.tiers[:testing] + when /(st(a|)g|mod(e|)l|pre|demo)/i + self.class.tiers[:staging] + when /(pr(o|)d|live)/i + self.class.tiers[:production] + else + self.class.tiers[:other] end end end diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 25f812645b1..0a429bb7afd 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -59,6 +59,10 @@ module ErrorTracking integrated end + def integrated_enabled? + enabled? && integrated_client? + end + def gitlab_dsn strong_memoize(:gitlab_dsn) do client_key&.sentry_dsn diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb index f799377a15f..fc093894847 100644 --- a/app/models/event_collection.rb +++ b/app/models/event_collection.rb @@ -44,31 +44,31 @@ class EventCollection private def project_events - relation_with_join_lateral('project_id', projects) + in_operator_optimized_relation('project_id', projects) end - def project_and_group_events - group_events = relation_with_join_lateral('group_id', groups) + def group_events + in_operator_optimized_relation('group_id', groups) + end + def project_and_group_events Event.from_union([project_events, group_events]).recent end - # This relation is built using JOIN LATERAL, producing faster queries than a - # regular LIMIT + OFFSET approach. - def relation_with_join_lateral(parent_column, parents) - parents_for_lateral = parents.select(:id).to_sql - - lateral = filtered_events - # Applying the limit here (before we filter (permissions) means we may get less than limit) - .limit(limit_for_join_lateral) - .where("events.#{parent_column} = parents_for_lateral.id") # rubocop:disable GitlabSecurity/SqlInjection - .to_sql - - # The outer query does not need to re-apply the filters since the JOIN - # LATERAL body already takes care of this. - base_relation - .from("(#{parents_for_lateral}) parents_for_lateral") - .joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true") + def in_operator_optimized_relation(parent_column, parents) + scope = filtered_events + array_scope = parents.select(:id) + array_mapping_scope = -> (parent_id_expression) { Event.where(Event.arel_table[parent_column].eq(parent_id_expression)).reorder(id: :desc) } + finder_query = -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) } + + Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder + .new( + scope: scope, + array_scope: array_scope, + array_mapping_scope: array_mapping_scope, + finder_query: finder_query + ) + .execute end def filtered_events @@ -85,16 +85,6 @@ class EventCollection Event.unscoped.recent end - def limit_for_join_lateral - # Applying the OFFSET on the inside of a JOIN LATERAL leads to incorrect - # results. To work around this we need to increase the inner limit for every - # page. - # - # This means that on page 1 we use LIMIT 20, and an outer OFFSET of 0. On - # page 2 we use LIMIT 40 and an outer OFFSET of 20. - @limit + @offset - end - def current_page (@offset / @limit) + 1 end diff --git a/app/models/group.rb b/app/models/group.rb index 1d6a3a14450..14d088dd38b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -19,7 +19,6 @@ class Group < Namespace include BulkMemberAccessLoad include ChronicDurationAttribute include RunnerTokenExpirationInterval - include RunnersTokenPrefixable extend ::Gitlab::Utils::Override @@ -120,7 +119,7 @@ class Group < Namespace add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required }, - prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX + prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX after_create :post_create_hook after_destroy :post_destroy_hook @@ -676,7 +675,7 @@ class Group < Namespace override :format_runners_token def format_runners_token(token) - "#{runners_token_prefix}#{token}" + "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}" end def project_creation_level @@ -817,7 +816,9 @@ class Group < Namespace private def max_member_access(user_ids) - max_member_access_for_resource_ids(User, user_ids) 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 @@ -892,6 +893,7 @@ class Group < Namespace .where(group_member_table[:requested_at].eq(nil)) .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) .where(group_member_table[:source_type].eq('Namespace')) + .where(group_member_table[:state].eq(::Member::STATE_ACTIVE)) .non_minimal_access end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 7e538238cbd..88941df691c 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -37,14 +37,14 @@ class WebHook < ApplicationRecord !temporarily_disabled? && !permanently_disabled? end - def temporarily_disabled? - return false unless web_hooks_disable_failed? + def temporarily_disabled?(ignore_flag: false) + return false unless ignore_flag || web_hooks_disable_failed? disabled_until.present? && disabled_until >= Time.current end - def permanently_disabled? - return false unless web_hooks_disable_failed? + def permanently_disabled?(ignore_flag: false) + return false unless ignore_flag || web_hooks_disable_failed? recent_failures > FAILURE_THRESHOLD end @@ -106,6 +106,13 @@ class WebHook < ApplicationRecord save(validate: false) end + def active_state(ignore_flag: false) + return :permanently_disabled if permanently_disabled?(ignore_flag: ignore_flag) + return :temporarily_disabled if temporarily_disabled?(ignore_flag: ignore_flag) + + :enabled + end + # @return [Boolean] Whether or not the WebHook is currently throttled. def rate_limited? return false unless rate_limit diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index 2016024b2f4..00e55d0fd89 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -118,7 +118,8 @@ class InstanceConfiguration group_export_download: application_setting_limit_per_minute(:group_download_export_limit), group_import: application_setting_limit_per_minute(:group_import_limit), raw_blob: application_setting_limit_per_minute(:raw_blob_request_limit), - user_email_lookup: application_setting_limit_per_minute(:user_email_lookup_limit), + search_rate_limit: application_setting_limit_per_minute(:search_rate_limit), + search_rate_limit_unauthenticated: application_setting_limit_per_minute(:search_rate_limit_unauthenticated), users_get_by_id: { enabled: application_settings[:users_get_by_id_limit] > 0, requests_per_period: application_settings[:users_get_by_id_limit], diff --git a/app/models/integration.rb b/app/models/integration.rb index e9cd90649ba..274c16507b7 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -9,10 +9,18 @@ class Integration < ApplicationRecord include Integrations::HasDataFields include FromUnion include EachBatch + include IgnorableColumns + + ignore_column :template, remove_with: '15.0', remove_after: '2022-04-22' + ignore_column :type, remove_with: '15.0', remove_after: '2022-04-22' + + UnknownType = Class.new(StandardError) + + self.inheritance_column = :type_new INTEGRATION_NAMES = %w[ asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord - drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira + drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat harbor irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao ].freeze @@ -37,9 +45,21 @@ class Integration < ApplicationRecord Integrations::BaseSlashCommands ].freeze + SECTION_TYPE_CONNECTION = 'connection' + serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize - attribute :type, Gitlab::Integrations::StiType.new + attr_encrypted :encrypted_properties_tmp, + attribute: :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 + + alias_attribute :type, :type_new default_value_for :active, false default_value_for :alert_events, true @@ -57,6 +77,8 @@ class Integration < ApplicationRecord default_value_for :wiki_page_events, true after_initialize :initialize_properties + after_initialize :copy_properties_to_encrypted_properties + before_save :copy_properties_to_encrypted_properties after_commit :reset_updated_properties @@ -74,9 +96,10 @@ class Integration < ApplicationRecord validate :validate_belongs_to_project_or_group scope :external_issue_trackers, -> { where(category: 'issue_tracker').active } - scope :external_wikis, -> { where(type: 'ExternalWikiService').active } + scope :by_name, ->(name) { by_type(integration_name_to_type(name)) } + scope :external_wikis, -> { by_name(:external_wiki).active } scope :active, -> { where(active: true) } - scope :by_type, -> (type) { where(type: type) } + scope :by_type, ->(type) { where(type: type) } # INTERNAL USE ONLY: use by_name instead scope :by_active_flag, -> (flag) { where(active: flag) } scope :inherit_from_id, -> (id) { where(inherit_from_id: id) } scope :with_default_settings, -> { where.not(inherit_from_id: nil) } @@ -99,6 +122,39 @@ class Integration < ApplicationRecord scope :alert_hooks, -> { where(alert_events: true, active: true) } scope :deployment, -> { where(category: 'deployment') } + class << self + private + + attr_writer :field_storage + + def field_storage + @field_storage || :properties + end + end + + # :nocov: Tested on subclasses. + def self.field(name, storage: field_storage, **attrs) + fields << ::Integrations::Field.new(name: name, **attrs) + + case storage + when :properties + prop_accessor(name) + when :data_fields + data_field(name) + else + raise ArgumentError, "Unknown field storage: #{storage}" + end + end + # :nocov: + + def self.fields + @fields ||= [] + end + + def fields + self.class.fields + end + # Provide convenient accessor methods for each serialized property. # Also keep track of updated properties in a similar way as ActiveModel::Dirty def self.prop_accessor(*args) @@ -112,8 +168,10 @@ class Integration < ApplicationRecord def #{arg}=(value) self.properties ||= {} + self.encrypted_properties_tmp = properties updated_properties['#{arg}'] = #{arg} unless #{arg}_changed? self.properties['#{arg}'] = value + self.encrypted_properties_tmp['#{arg}'] = value end def #{arg}_changed? @@ -158,10 +216,6 @@ class Integration < ApplicationRecord self.supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) } end - def self.supported_event_actions - %w[] - end - def self.supported_events %w[commit push tag_push issue confidential_issue merge_request wiki_page] end @@ -226,7 +280,7 @@ class Integration < ApplicationRecord end # Returns a list of available integration types. - # Example: ["AsanaService", ...] + # Example: ["Integrations::Asana", ...] def self.available_integration_types(include_project_specific: true, include_dev: true) available_integration_names(include_project_specific: include_project_specific, include_dev: include_dev).map do integration_name_to_type(_1) @@ -234,22 +288,27 @@ class Integration < ApplicationRecord end # Returns the model for the given integration name. - # Example: "asana" => Integrations::Asana + # Example: :asana => Integrations::Asana def self.integration_name_to_model(name) type = integration_name_to_type(name) integration_type_to_model(type) end # Returns the STI type for the given integration name. - # Example: "asana" => "AsanaService" + # Example: "asana" => "Integrations::Asana" def self.integration_name_to_type(name) - "#{name}_service".camelize + name = name.to_s + if available_integration_names.exclude?(name) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(UnknownType.new(name.inspect)) + else + "Integrations::#{name.camelize}" + end end # Returns the model for the given STI type. - # Example: "AsanaService" => Integrations::Asana + # Example: "Integrations::Asana" => Integrations::Asana def self.integration_type_to_model(type) - Gitlab::Integrations::StiType.new.cast(type).constantize + type.constantize end private_class_method :integration_type_to_model @@ -298,7 +357,7 @@ class Integration < ApplicationRecord from_union([ active.where(instance: true), active.where(group_id: group_ids, inherit_from_id: nil) - ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records| + ]).order(Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records| build_from_integration(records.first, association => scope.id).save end end @@ -330,6 +389,10 @@ class Integration < ApplicationRecord true end + def activate_disabled_reason + nil + end + def category read_attribute(:category).to_sym end @@ -338,6 +401,12 @@ class Integration < ApplicationRecord self.properties = {} if has_attribute?(:properties) && properties.nil? end + def copy_properties_to_encrypted_properties + self.encrypted_properties_tmp = properties + rescue ActiveModel::MissingAttributeError + # ignore - in a record built from using a restricted select list + end + def title # implement inside child end @@ -355,8 +424,7 @@ class Integration < ApplicationRecord self.class.to_param end - def fields - # implement inside child + def sections [] end @@ -371,8 +439,24 @@ class Integration < ApplicationRecord %w[active] end + # return a hash of columns => values suitable for passing to insert_all def to_integration_hash - as_json(methods: :type, except: %w[id instance project_id group_id]) + column = self.class.attribute_aliases.fetch('type', 'type') + copy_properties_to_encrypted_properties + + as_json(except: %w[id instance project_id group_id encrypted_properties_tmp]) + .merge(column => type) + .merge(reencrypt_properties) + end + + def reencrypt_properties + unless properties.nil? || properties.empty? + alg = self.class.encrypted_attributes[:encrypted_properties_tmp][:algorithm] + iv = generate_iv(alg) + ep = self.class.encrypt(:encrypted_properties_tmp, properties, { iv: iv }) + end + + { 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv } end def to_data_fields_hash @@ -392,7 +476,10 @@ class Integration < ApplicationRecord end def api_field_names - fields.pluck(:name).grep_v(/password|token|key|title|description/) + fields + .reject { _1[:type] == 'password' } + .pluck(:name) + .grep_v(/password|token|key/) end def global_fields @@ -410,10 +497,6 @@ class Integration < ApplicationRecord end end - def configurable_event_actions - self.class.supported_event_actions - end - def supported_events self.class.supported_events end diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index 7949563a1dc..054f0606dd2 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -4,8 +4,6 @@ require 'asana' module Integrations class Asana < Integration - include ActionView::Helpers::UrlHelper - prop_accessor :api_key, :restrict_to_branch validates :api_key, presence: true, if: :activated? @@ -18,7 +16,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 57767c63cf4..c614a9415ab 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -2,7 +2,6 @@ module Integrations class Bamboo < BaseCi - include ActionView::Helpers::UrlHelper include ReactivelyCached prepend EnableSslVerification @@ -36,7 +35,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index d0d54a92021..d5b6357cb66 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -241,7 +241,6 @@ module Integrations def notify_for_ref?(data) return true if data[:object_kind] == 'tag_push' - return true if data[:object_kind] == 'deployment' && !Feature.enabled?(:chat_notification_deployment_protected_branch_filter, project) ref = data[:ref] || data.dig(:object_attributes, :ref) return true if ref.blank? # No need to check protected branches when there is no ref diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index 42a6a3a19c8..458d0199e7a 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -4,10 +4,6 @@ module Integrations class BaseIssueTracker < Integration validate :one_issue_tracker, if: :activated?, on: :manual_change - # TODO: we can probably just delegate as part of - # https://gitlab.com/gitlab-org/gitlab/issues/29404 - data_field :project_url, :issues_url, :new_issue_url - default_value_for :category, 'issue_tracker' before_validation :handle_properties @@ -72,14 +68,6 @@ module Integrations issue_url(iid) end - def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true }, - { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }, - { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true } - ] - end - def initialize_properties {} end @@ -132,8 +120,18 @@ module Integrations # implement inside child end + def activate_disabled_reason + { trackers: other_external_issue_trackers } if other_external_issue_trackers.any? + end + private + def other_external_issue_trackers + return [] unless project_level? + + @other_external_issue_trackers ||= project.integrations.external_issue_trackers.where.not(id: id) + end + def enabled_in_gitlab_config Gitlab.config.issues_tracker && Gitlab.config.issues_tracker.values.any? && @@ -145,10 +143,10 @@ module Integrations end def one_issue_tracker - return if template? || instance? + return if instance? return if project.blank? - if project.integrations.external_issue_trackers.where.not(id: id).any? + if other_external_issue_trackers.any? errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time')) end end diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb index 9251015acb8..74e282f6848 100644 --- a/app/models/integrations/bugzilla.rb +++ b/app/models/integrations/bugzilla.rb @@ -2,7 +2,7 @@ module Integrations class Bugzilla < BaseIssueTracker - include ActionView::Helpers::UrlHelper + include Integrations::HasIssueTrackerFields validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? @@ -15,7 +15,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index c78fc6eff51..81e6c2411b8 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -2,8 +2,6 @@ module Integrations class Campfire < Integration - include ActionView::Helpers::UrlHelper - prop_accessor :token, :subdomain, :room validates :token, presence: true, if: :activated? @@ -16,7 +14,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer' s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index 7f111f482dd..65adce7a8d6 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -2,8 +2,6 @@ module Integrations class Confluence < Integration - include ActionView::Helpers::UrlHelper - VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze @@ -39,7 +37,7 @@ module Integrations s_( 'ConfluenceService|Your GitLab wiki is still available at %{wiki_link}. To re-enable the link to the GitLab wiki, disable this integration.' % - { wiki_link: link_to(wiki_url, wiki_url) } + { wiki_link: ActionController::Base.helpers.link_to(wiki_url, wiki_url) } ).html_safe else s_('ConfluenceService|Link to a Confluence Workspace from the sidebar. Enabling this integration replaces the "Wiki" sidebar link with a link to the Confluence Workspace. The GitLab wiki is still available at the original URL.').html_safe diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb index 635a9d093e9..3770e813eaa 100644 --- a/app/models/integrations/custom_issue_tracker.rb +++ b/app/models/integrations/custom_issue_tracker.rb @@ -2,7 +2,8 @@ module Integrations class CustomIssueTracker < BaseIssueTracker - include ActionView::Helpers::UrlHelper + include HasIssueTrackerFields + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def title @@ -14,7 +15,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 21993dd3c43..790e41e5a2a 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -4,8 +4,6 @@ require "discordrb/webhooks" module Integrations class Discord < BaseChatNotification - include ActionView::Helpers::UrlHelper - ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze def title @@ -21,7 +19,7 @@ module Integrations end def help - docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb index 24d343b7cb4..1b86ef73c85 100644 --- a/app/models/integrations/ewm.rb +++ b/app/models/integrations/ewm.rb @@ -2,7 +2,7 @@ module Integrations class Ewm < BaseIssueTracker - include ActionView::Helpers::UrlHelper + include HasIssueTrackerFields validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? @@ -19,7 +19,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index 2a8d598117b..18c48411e30 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -2,8 +2,6 @@ module Integrations class ExternalWiki < Integration - include ActionView::Helpers::UrlHelper - prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? @@ -33,7 +31,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb new file mode 100644 index 00000000000..49ab97677db --- /dev/null +++ b/app/models/integrations/field.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Integrations + class Field + SENSITIVE_NAME = %r/token|key|password|passphrase|secret/.freeze + + ATTRIBUTES = %i[ + section type placeholder required choices value checkbox_label + title help + non_empty_password_help + non_empty_password_title + api_only + ].freeze + + attr_reader :name + + def initialize(name:, type: 'text', api_only: false, **attributes) + @name = name.to_s.freeze + + attributes[:type] = SENSITIVE_NAME.match?(@name) ? 'password' : type + attributes[:api_only] = api_only + @attributes = attributes.freeze + end + + def [](key) + return name if key == :name + + value = @attributes[key] + return value.call if value.respond_to?(:call) + + value + end + + def sensitive? + @attributes[:type] == 'password' + end + + ATTRIBUTES.each do |name| + define_method(name) { self[name] } + end + end +end diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb index 443f61e65dd..476cdc35585 100644 --- a/app/models/integrations/flowdock.rb +++ b/app/models/integrations/flowdock.rb @@ -2,8 +2,6 @@ module Integrations class Flowdock < Integration - include ActionView::Helpers::UrlHelper - prop_accessor :token validates :token, presence: true, if: :activated? @@ -16,7 +14,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 0d6b9fb1019..8c68c9ff95a 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -2,8 +2,6 @@ module Integrations class HangoutsChat < BaseChatNotification - include ActionView::Helpers::UrlHelper - def title 'Google Chat' end @@ -17,7 +15,7 @@ module Integrations end def help - docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb new file mode 100644 index 00000000000..4c76e418886 --- /dev/null +++ b/app/models/integrations/harbor.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Integrations + class Harbor < Integration + prop_accessor :url, :project_name, :username, :password + + validates :url, public_url: true, presence: true, if: :activated? + validates :project_name, presence: true, if: :activated? + validates :username, presence: true, if: :activated? + validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated? + + before_validation :reset_username_and_password + + def title + 'Harbor' + end + + def description + s_("HarborIntegration|Use Harbor as this project's container registry.") + end + + def help + s_("HarborIntegration|After the Harbor integration is activated, global variables ‘$HARBOR_USERNAME’, ‘$HARBOR_PASSWORD’, ‘$HARBOR_URL’ and ‘$HARBOR_PROJECT’ will be created for CI/CD use.") + end + + class << self + def to_param + name.demodulize.downcase + end + + def supported_events + [] + end + + def supported_event_actions + [] + end + end + + def test(*_args) + client.ping + end + + def fields + [ + { + type: 'text', + name: 'url', + title: s_('HarborIntegration|Harbor URL'), + placeholder: 'https://demo.goharbor.io', + help: s_('HarborIntegration|Base URL of the Harbor instance.'), + required: true + }, + { + type: 'text', + name: 'project_name', + title: s_('HarborIntegration|Harbor project name'), + help: s_('HarborIntegration|The name of the project in Harbor.') + }, + { + type: 'text', + name: 'username', + title: s_('HarborIntegration|Harbor username'), + required: true + }, + { + type: 'text', + name: 'password', + title: s_('HarborIntegration|Harbor password'), + non_empty_password_title: s_('HarborIntegration|Enter Harbor password'), + non_empty_password_help: s_('HarborIntegration|Password for your Harbor username.'), + required: true + } + ] + end + + def ci_variables + return [] unless activated? + + [ + { key: 'HARBOR_URL', value: url }, + { key: 'HARBOR_PROJECT', value: project_name }, + { key: 'HARBOR_USERNAME', value: username }, + { key: 'HARBOR_PASSWORD', value: password, public: false, masked: true } + ] + end + + private + + def client + @client ||= ::Gitlab::Harbor::Client.new(self) + end + + def reset_username_and_password + if url_changed? && !password_touched? + self.password = nil + end + + if url_changed? && !username_touched? + self.username = nil + end + end + end +end diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index cea4aa2038d..116d1fb233d 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -4,8 +4,6 @@ require 'uri' module Integrations class Irker < Integration - include ActionView::Helpers::UrlHelper - prop_accessor :server_host, :server_port, :default_irc_uri prop_accessor :recipients, :channels boolean_accessor :colorize_messages @@ -44,7 +42,7 @@ module Integrations end def fields - recipients_docs_link = link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer' + recipients_docs_link = ActionController::Base.helpers.link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer' [ { type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'), help: s_('IrkerService|irker daemon hostname (defaults to localhost).') }, @@ -61,7 +59,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer' s_('IrkerService|Send update messages to an irker server. Before you can use this, you need to set up the irker daemon. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb index 5ea92170c26..32f11ee23eb 100644 --- a/app/models/integrations/jenkins.rb +++ b/app/models/integrations/jenkins.rb @@ -3,7 +3,7 @@ module Integrations class Jenkins < BaseCi include HasWebHook - include ActionView::Helpers::UrlHelper + prepend EnableSslVerification extend Gitlab::Utils::Override @@ -65,7 +65,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 966ad07afad..74ece57000f 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -15,6 +15,9 @@ module Integrations ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze + SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger' + SECTION_TYPE_JIRA_ISSUES = 'jira_issues' + validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? @@ -28,11 +31,6 @@ module Integrations # We should use username/password for Jira Server and email/api_token for Jira Cloud, # for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936. - # TODO: we can probably just delegate as part of - # https://gitlab.com/gitlab-org/gitlab/issues/29404 - data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled, - :vulnerabilities_enabled, :vulnerabilities_issuetype - before_validation :reset_password after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type? @@ -41,16 +39,50 @@ module Integrations all_details: 2 } + 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' + + field :api_url, + section: SECTION_TYPE_CONNECTION, + title: -> { s_('JiraService|Jira API URL') }, + help: -> { s_('JiraService|If different from Web URL.') } + + field :username, + section: SECTION_TYPE_CONNECTION, + required: true, + title: -> { s_('JiraService|Username or Email') }, + help: -> { s_('JiraService|Use a username for server version and an email for cloud version.') } + + field :password, + section: SECTION_TYPE_CONNECTION, + required: true, + title: -> { s_('JiraService|Password or API token') }, + non_empty_password_title: -> { s_('JiraService|Enter new password or API token') }, + non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, + help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') } + + # TODO: we can probably just delegate as part of + # https://gitlab.com/gitlab-org/gitlab/issues/29404 + # These fields are API only, so no field definition is required. + data_field :jira_issue_transition_automatic + data_field :jira_issue_transition_id + data_field :project_key + data_field :issues_enabled + data_field :vulnerabilities_enabled + data_field :vulnerabilities_issuetype + # When these are false GitLab does not create cross reference # comments on Jira except when an issue gets transitioned. def self.supported_events %w(commit merge_request) end - def self.supported_event_actions - %w(comment) - end - # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 def self.reference_pattern(only_long: true) @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ @@ -111,8 +143,8 @@ module Integrations end def help - jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } - s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } + jira_doc_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/index.html') } + s_("JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end}") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } end def title @@ -127,39 +159,32 @@ module Integrations 'jira' end - def fields - [ - { - type: 'text', - name: 'url', - title: s_('JiraService|Web URL'), - placeholder: 'https://jira.example.com', - help: s_('JiraService|Base URL of the Jira instance.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: s_('JiraService|Jira API URL'), - help: s_('JiraService|If different from Web URL.') - }, + def sections + jira_issues_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/issues.html') } + + sections = [ { - type: 'text', - name: 'username', - title: s_('JiraService|Username or Email'), - help: s_('JiraService|Use a username for server version and an email for cloud version.'), - required: true + type: SECTION_TYPE_CONNECTION, + title: s_('Integrations|Connection details'), + description: help }, { - type: 'password', - name: 'password', - title: s_('JiraService|Password or API token'), - non_empty_password_title: s_('JiraService|Enter new password or API token'), - non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'), - help: s_('JiraService|Use a password for server version and an API token for cloud version.'), - required: true + type: SECTION_TYPE_JIRA_TRIGGER, + title: _('Trigger'), + description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.') } ] + + # Jira issues is currently only configurable on the project level. + if project_level? + sections.push({ + type: SECTION_TYPE_JIRA_ISSUES, + title: _('Issues'), + description: s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe } + }) + end + + sections end def web_url(path = nil, **params) @@ -180,17 +205,12 @@ module Integrations url.to_s end - override :project_url - def project_url - web_url - end + alias_method :project_url, :web_url - override :issues_url def issues_url web_url('browse/:id') end - override :new_issue_url def new_issue_url web_url('secure/CreateIssue!default.jspa') end diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index 07a5086b8e9..d9ccbb7ea34 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -3,7 +3,6 @@ module Integrations class Mattermost < BaseChatNotification include SlackMattermostNotifier - include ActionView::Helpers::UrlHelper def title s_('Mattermost notifications') @@ -18,7 +17,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer' s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index 24cfd51eb55..5b9ac023b7e 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -2,7 +2,6 @@ module Integrations class Pivotaltracker < Integration - include ActionView::Helpers::UrlHelper API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' prop_accessor :token, :restrict_to_branch @@ -17,7 +16,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer' s_('Add commit messages as comments to Pivotal Tracker stories. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 5746343c31c..2e275dab91b 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -115,7 +115,6 @@ module Integrations end def prometheus_available? - return false if template? return false unless project project.all_clusters.enabled.eager_load(:integration_prometheus).any? do |cluster| diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb index 990b538f294..bc2a64b0848 100644 --- a/app/models/integrations/redmine.rb +++ b/app/models/integrations/redmine.rb @@ -2,7 +2,8 @@ module Integrations class Redmine < BaseIssueTracker - include ActionView::Helpers::UrlHelper + include Integrations::HasIssueTrackerFields + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def title @@ -14,7 +15,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer' s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 7660eda6f83..345dd98cbc1 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -2,8 +2,6 @@ module Integrations class WebexTeams < BaseChatNotification - include ActionView::Helpers::UrlHelper - def title s_("WebexTeamsService|Webex Teams") end @@ -17,7 +15,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index 10531717f11..ab6e1da27f8 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -2,7 +2,7 @@ module Integrations class Youtrack < BaseIssueTracker - include ActionView::Helpers::UrlHelper + include Integrations::HasIssueTrackerFields validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? @@ -24,7 +24,7 @@ module Integrations end def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index 493d42cc40b..c33df465fde 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -11,7 +11,6 @@ module Integrations validates :api_token, presence: true, if: :activated? validates :zentao_product_xid, presence: true, if: :activated? - # License Level: EEP_FEATURES def self.issues_license_available?(project) project&.licensed_feature_available?(:zentao_issues_integration) end @@ -48,10 +47,6 @@ module Integrations %w() end - def self.supported_event_actions - %w() - end - def fields [ { diff --git a/app/models/issue.rb b/app/models/issue.rb index 68ea6cb3abc..75727fff2cd 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -24,6 +24,7 @@ class Issue < ApplicationRecord include Todoable include FromUnion include EachBatch + include PgFullTextSearchable extend ::Gitlab::Utils::Override @@ -77,6 +78,7 @@ class Issue < ApplicationRecord end end + has_one :search_data, class_name: 'Issues::SearchData' has_one :issuable_severity has_one :sentry_issue has_one :alert_management_alert, class_name: 'AlertManagement::Alert' @@ -102,6 +104,8 @@ class Issue < ApplicationRecord alias_attribute :external_author, :service_desk_reply_to + pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }] + scope :in_projects, ->(project_ids) { where(project_id: project_ids) } scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) } @@ -233,6 +237,11 @@ class Issue < ApplicationRecord def order_upvotes_asc reorder(upvotes_count: :asc) end + + override :pg_full_text_search + def pg_full_text_search(search_term) + super.where('issue_search_data.project_id = issues.project_id') + end end def next_object_by_relative_position(ignoring: nil, order: :asc) @@ -611,6 +620,11 @@ class Issue < ApplicationRecord private + override :persist_pg_full_text_search_vector + def persist_pg_full_text_search_vector(search_vector) + Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id)) + end + def spammable_attribute_changed? title_changed? || description_changed? || diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb index 920586cc1ba..1bd34aa0083 100644 --- a/app/models/issue_link.rb +++ b/app/models/issue_link.rb @@ -2,46 +2,17 @@ class IssueLink < ApplicationRecord include FromUnion + include IssuableLink belongs_to :source, class_name: 'Issue' belongs_to :target, class_name: 'Issue' - validates :source, presence: true - validates :target, presence: true - validates :source, uniqueness: { scope: :target_id, message: 'is already related' } - validate :check_self_relation - validate :check_opposite_relation - scope :for_source_issue, ->(issue) { where(source_id: issue.id) } scope :for_target_issue, ->(issue) { where(target_id: issue.id) } - TYPE_RELATES_TO = 'relates_to' - TYPE_BLOCKS = 'blocks' - # we don't store is_blocked_by in the db but need it for displaying the relation - # from the target (used in IssueLink.inverse_link_type) - TYPE_IS_BLOCKED_BY = 'is_blocked_by' - - enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 } - - def self.inverse_link_type(type) - type - end - - private - - def check_self_relation - return unless source && target - - if source == target - errors.add(:source, 'cannot be related to itself') - end - end - - def check_opposite_relation - return unless source && target - - if IssueLink.find_by(source: target, target: source) - errors.add(:source, 'is already related to this issue') + class << self + def issuable_type + :issue end end end diff --git a/app/models/issues/search_data.rb b/app/models/issues/search_data.rb new file mode 100644 index 00000000000..0eda292796d --- /dev/null +++ b/app/models/issues/search_data.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Issues + class SearchData < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + + self.table_name = 'issue_search_data' + + belongs_to :issue + end +end diff --git a/app/models/label.rb b/app/models/label.rb index 0ebbb5b9bd3..4c9f071f43a 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -12,8 +12,9 @@ class Label < ApplicationRecord cache_markdown_field :description, pipeline: :single_line - DEFAULT_COLOR = '#6699cc' + DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc') + attribute :color, ::Gitlab::Database::Type::Color.new default_value_for :color, DEFAULT_COLOR has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -22,9 +23,9 @@ class Label < ApplicationRecord has_many :issues, through: :label_links, source: :target, source_type: 'Issue' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' - before_validation :strip_whitespace_from_title_and_color + before_validation :strip_whitespace_from_title - validates :color, color: true, allow_blank: false + validates :color, color: true, presence: true # Don't allow ',' for label titles validates :title, presence: true, format: { with: /\A[^,]+\z/ } @@ -212,15 +213,23 @@ class Label < ApplicationRecord end def text_color - LabelsHelper.text_color_for_bg(self.color) + color.contrast end def title=(value) - write_attribute(:title, sanitize_value(value)) if value.present? + if value.blank? + super + else + write_attribute(:title, sanitize_value(value)) + end end def description=(value) - write_attribute(:description, sanitize_value(value)) if value.present? + if value.blank? + super + else + write_attribute(:description, sanitize_value(value)) + end end ## @@ -285,8 +294,8 @@ class Label < ApplicationRecord CGI.unescapeHTML(Sanitize.clean(value.to_s)) end - def strip_whitespace_from_title_and_color - %w(color title).each { |attr| self[attr] = self[attr]&.strip } + def strip_whitespace_from_title + self[:title] = title&.strip end end diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb index 319499fd1b7..3df6742fbc9 100644 --- a/app/models/lfs_download_object.rb +++ b/app/models/lfs_download_object.rb @@ -4,6 +4,7 @@ class LfsDownloadObject include ActiveModel::Validations attr_accessor :oid, :size, :link, :headers + delegate :sanitized_url, :credentials, to: :sanitized_uri validates :oid, format: { with: /\A\h{64}\z/ } diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 3a449055bc1..3e19f294253 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -94,9 +94,9 @@ class ProjectMember < Member override :access_level_inclusion def access_level_inclusion - return if access_level.in?(Gitlab::Access.values) - - errors.add(:access_level, "is not included in the list") + unless access_level.in?(Gitlab::Access.all_values) + errors.add(:access_level, "is not included in the list") + end end override :refresh_member_authorized_projects diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 29540cbde2f..854325e1fcd 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1016,8 +1016,24 @@ class MergeRequest < ApplicationRecord merge_request_diff.persisted? || create_merge_request_diff end - def create_merge_request_diff + def eager_fetch_ref! + return unless valid? + + # has_internal_id normally attempts to allocate the iid in the + # before_create hook, but we need the iid to be available before + # that to fetch the ref into the target project. + track_target_project_iid! + ensure_target_project_iid! + fetch_ref! + # Prevent the after_create hook from fetching the source branch again. + @skip_fetch_ref = true + end + + def create_merge_request_diff + # Callers such as MergeRequests::BuildService may not call eager_fetch_ref!. Just + # in case they haven't, we fetch the ref. + fetch_ref! unless skip_fetch_ref # n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377 Gitlab::GitalyClient.allow_n_plus_1_calls do @@ -1136,15 +1152,20 @@ class MergeRequest < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def mergeable_state?(skip_ci_check: false, skip_discussions_check: false) - return false unless open? - return false if work_in_progress? - return false if broken? - return false unless skip_discussions_check || mergeable_discussions_state? - if Feature.enabled?(:improved_mergeability_checks, self.project, default_enabled: :yaml) - additional_checks = MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: { skip_ci_check: skip_ci_check }) + additional_checks = MergeRequests::Mergeability::RunChecksService.new( + merge_request: self, + params: { + skip_ci_check: skip_ci_check, + skip_discussions_check: skip_discussions_check + } + ) additional_checks.execute.all?(&:success?) else + return false unless open? + return false if draft? + return false if broken? + return false unless skip_discussions_check || mergeable_discussions_state? return false unless skip_ci_check || mergeable_ci_state? true @@ -1921,10 +1942,18 @@ class MergeRequest < ApplicationRecord merge_request_assignees.find_by(user_id: user.id) end + def merge_request_assignees_with(user_ids) + merge_request_assignees.where(user_id: user_ids) + end + def find_reviewer(user) merge_request_reviewers.find_by(user_id: user.id) end + def merge_request_reviewers_with(user_ids) + merge_request_reviewers.where(user_id: user_ids) + end + def enabled_reports { sast: report_type_enabled?(:sast), @@ -1950,6 +1979,8 @@ class MergeRequest < ApplicationRecord private + attr_accessor :skip_fetch_ref + def set_draft_status self.draft = draft? end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 2c95cc2672c..86da29dd27a 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -35,6 +35,7 @@ class Milestone < ApplicationRecord scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) } scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) } + validates :title, presence: true validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validate :uniqueness_of_title, if: :title_changed? diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5c55f4d3def..ffaeb2071f6 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -117,6 +117,7 @@ class Namespace < ApplicationRecord before_create :sync_share_with_group_lock_with_parent before_update :sync_share_with_group_lock_with_parent, if: :parent_changed? after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? } + after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? } # Legacy Storage specific hooks @@ -401,7 +402,11 @@ class Namespace < ApplicationRecord return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil? strong_memoize(:first_auto_devops_config) do - if has_parent? + if has_parent? && cache_first_auto_devops_config? + Rails.cache.fetch(first_auto_devops_config_cache_key_for(id), expires_in: 1.day) do + parent.first_auto_devops_config + end + elsif has_parent? parent.first_auto_devops_config else { scope: :instance, status: Gitlab::CurrentSettings.auto_devops_enabled? } @@ -509,10 +514,6 @@ class Namespace < ApplicationRecord Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml) end - def project_namespace_creation_enabled? - Feature.enabled?(:create_project_namespace_on_project_create, self, default_enabled: :yaml) - end - def storage_enforcement_date # should return something like Date.new(2022, 02, 03) # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632 @@ -621,6 +622,20 @@ class Namespace < ApplicationRecord .update_all(share_with_group_lock: true) end + def expire_first_auto_devops_config_cache + return unless cache_first_auto_devops_config? + + descendants_to_expire = self_and_descendants.as_ids + return if descendants_to_expire.load.empty? + + keys = descendants_to_expire.map { |group| first_auto_devops_config_cache_key_for(group.id) } + Rails.cache.delete_multi(keys) + end + + def cache_first_auto_devops_config? + ::Feature.enabled?(:namespaces_cache_first_auto_devops_config, default_enabled: :yaml) + end + def write_projects_repository_config all_projects.find_each do |project| project.set_full_path @@ -638,6 +653,13 @@ class Namespace < ApplicationRecord Namespaces::SyncEvent.enqueue_worker end end + + def first_auto_devops_config_cache_key_for(group_id) + return "namespaces:{first_auto_devops_config}:#{group_id}" unless sync_traversal_ids? + + # Use SHA2 of `traversal_ids` to account for moving a namespace within the same root ancestor hierarchy. + "namespaces:{#{traversal_ids.first}}:first_auto_devops_config:#{group_id}:#{Digest::SHA2.hexdigest(traversal_ids.join(' '))}" + end end Namespace.prepend_mod_with('Namespace') diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb index 34086a8af5d..d2de85b5dd4 100644 --- a/app/models/namespace/traversal_hierarchy.rb +++ b/app/models/namespace/traversal_hierarchy.rb @@ -31,15 +31,16 @@ class Namespace # ActiveRecord. https://github.com/rails/rails/issues/13496 # Ideally it would be: # `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')` - sql = """ - UPDATE namespaces - SET traversal_ids = cte.traversal_ids - FROM (#{recursive_traversal_ids}) as cte - WHERE namespaces.id = cte.id - AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids - """ + sql = <<-SQL + UPDATE namespaces + SET traversal_ids = cte.traversal_ids + FROM (#{recursive_traversal_ids}) as cte + WHERE namespaces.id = cte.id + AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids + SQL + Namespace.transaction do - @root.lock! + @root.lock!("FOR NO KEY UPDATE") Namespace.connection.exec_query(sql) end rescue ActiveRecord::Deadlocked diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 99a5b8cb063..1963745cf4d 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -44,22 +44,15 @@ module Namespaces included do before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? } after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? } - # sync traversal_ids on namespace create, which can happen quite early within a transaction, thus keeping the lock on root namespace record - # for a relatively long time, e.g. creating the project namespace when a project is being created. - after_create :sync_traversal_ids, if: -> { sync_traversal_ids? && !sync_traversal_ids_before_commit? } # This uses rails internal before_commit API to sync traversal_ids on namespace create, right before transaction is committed. # This helps reduce the time during which the root namespace record is locked to ensure updated traversal_ids are valid - before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? && sync_traversal_ids_before_commit? } + before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? } end def sync_traversal_ids? Feature.enabled?(:sync_traversal_ids, root_ancestor, default_enabled: :yaml) end - def sync_traversal_ids_before_commit? - Feature.enabled?(:sync_traversal_ids_before_commit, root_ancestor, default_enabled: :yaml) - end - def use_traversal_ids? return false unless Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 09d69a5f77a..0cac4c9143a 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -126,36 +126,26 @@ module Namespaces end def self_and_descendants_with_comparison_operators(include_self: true) - base = all.select( - :traversal_ids, - 'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids' - ) + base = all.select(:traversal_ids) base_cte = Gitlab::SQL::CTE.new(:descendants_base_cte, base) namespaces = Arel::Table.new(:namespaces) # Bound the search space to ourselves (optional) and descendants. # - # WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids) - # AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids + # WHERE next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids records = unscoped + .distinct + .with(base_cte.to_arel) .from([base_cte.table, namespaces]) - .where(base_cte.table[:next_traversal_ids].eq(nil).or(base_cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids]))) .where(next_sibling_func(base_cte.table[:traversal_ids]).gt(namespaces[:traversal_ids])) # AND base_cte.traversal_ids <= namespaces.traversal_ids - records = if include_self - records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) - else - records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) - end - - records_cte = Gitlab::SQL::CTE.new(:descendants_cte, records) - - unscoped - .unscope(where: [:type]) - .with(base_cte.to_arel, records_cte.to_arel) - .from(records_cte.alias_to(namespaces)) + if include_self + records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) + else + records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) + end end def next_sibling_func(*args) diff --git a/app/models/note.rb b/app/models/note.rb index a84da066968..4f2e7ebe2c5 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -609,7 +609,6 @@ class Note < ApplicationRecord def show_outdated_changes? return false unless for_merge_request? - return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml) return false unless system? return false unless change_position&.line_range diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index fc7c348dfdb..ad8140ac684 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -49,6 +49,7 @@ class Packages::PackageFile < ApplicationRecord scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) } scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) } scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) } + scope :order_id_asc, -> { order(id: :asc) } scope :for_rubygem_with_file_name, ->(project, file_name) do joins(:package).merge(project.packages.rubygems).with_file_name(file_name) diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb index 2e4d61eaf53..ff247fedb59 100644 --- a/app/models/packages/pypi/metadatum.rb +++ b/app/models/packages/pypi/metadatum.rb @@ -6,7 +6,7 @@ class Packages::Pypi::Metadatum < ApplicationRecord belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum validates :package, presence: true - validates :required_python, length: { maximum: 255 }, allow_blank: true + validates :required_python, length: { maximum: 255 }, allow_nil: false validate :pypi_package_type diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 2f515f3443d..021ff789b13 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -34,6 +34,7 @@ class PersonalAccessToken < ApplicationRecord scope :order_expires_at_asc, -> { reorder(expires_at: :asc) } scope :order_expires_at_desc, -> { reorder(expires_at: :desc) } scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) } + scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) } validates :scopes, presence: true validate :validate_scopes diff --git a/app/models/preloaders/environments/deployment_preloader.rb b/app/models/preloaders/environments/deployment_preloader.rb index fcf892698bb..251d1837f19 100644 --- a/app/models/preloaders/environments/deployment_preloader.rb +++ b/app/models/preloaders/environments/deployment_preloader.rb @@ -21,11 +21,13 @@ module Preloaders def load_deployment_association(association_name, association_attributes) return unless environments.present? - union_arg = environments.inject([]) do |result, environment| - result << environment.association(association_name).scope - end - - union_sql = Deployment.from_union(union_arg).to_sql + # Not using Gitlab::SQL::Union as `order_by` in the SQL constructed is ignored. + # See: + # 1) https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/sql/union.rb#L7 + # 2) https://gitlab.com/gitlab-org/gitlab/-/issues/353966#note_860928647 + union_sql = environments.map do |environment| + "(#{environment.association(association_name).scope.to_sql})" + end.join(' UNION ') deployments = Deployment .from("(#{union_sql}) #{::Deployment.table_name}") @@ -34,8 +36,16 @@ module Preloaders deployments_by_environment_id = deployments.index_by(&:environment_id) environments.each do |environment| - environment.association(association_name).target = deployments_by_environment_id[environment.id] + associated_deployment = deployments_by_environment_id[environment.id] + + environment.association(association_name).target = associated_deployment environment.association(association_name).loaded! + + if associated_deployment + # `last?` in DeploymentEntity requires this environment to be loaded + associated_deployment.association(:environment).target = environment + associated_deployment.association(:environment).loaded! + end end end end diff --git a/app/models/project.rb b/app/models/project.rb index f89e616a5ca..155ebe88d33 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -38,7 +38,7 @@ class Project < ApplicationRecord include GitlabRoutingHelper include BulkMemberAccessLoad include RunnerTokenExpirationInterval - include RunnersTokenPrefixable + include BlocksUnsafeSerialization extend Gitlab::Cache::RequestCache extend Gitlab::Utils::Override @@ -196,6 +196,7 @@ class Project < ApplicationRecord has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki' has_one :flowdock_integration, class_name: 'Integrations::Flowdock' has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat' + has_one :harbor_integration, class_name: 'Integrations::Harbor' has_one :irker_integration, class_name: 'Integrations::Irker' has_one :jenkins_integration, class_name: 'Integrations::Jenkins' has_one :jira_integration, class_name: 'Integrations::Jira' @@ -344,22 +345,18 @@ class Project < ApplicationRecord has_many :stages, class_name: 'Ci::Stage', inverse_of: :project has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project - # Ci::Build objects store data on the file system such as artifact files and - # build traces. Currently there's no efficient way of removing this data in - # bulk that doesn't involve loading the rows into memory. As a result we're - # still using `dependent: :destroy` here. has_many :pending_builds, class_name: 'Ci::PendingBuild' - has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :builds, class_name: 'Ci::Build', inverse_of: :project has_many :processables, class_name: 'Ci::Processable', inverse_of: :project - has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks + has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks, dependent: :restrict_with_error has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project - has_many :job_artifacts, class_name: 'Ci::JobArtifact' - has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project + has_many :job_artifacts, class_name: 'Ci::JobArtifact', dependent: :restrict_with_error + has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project, dependent: :restrict_with_error has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' - has_many :secure_files, class_name: 'Ci::SecureFile' + has_many :secure_files, class_name: 'Ci::SecureFile', dependent: :restrict_with_error has_many :environments has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment' has_many :deployments @@ -462,7 +459,7 @@ class Project < ApplicationRecord delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true delegate :add_user, :add_users, to: :team - delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, 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 :last_pipeline, to: :commit, allow_nil: true @@ -501,11 +498,15 @@ class Project < ApplicationRecord presence: true, project_path: true, length: { maximum: 255 } + validates :path, + format: { with: Gitlab::Regex.oci_repository_path_regex, + message: Gitlab::Regex.oci_repository_path_regex_message }, + if: :path_changed? validates :project_feature, presence: true validates :namespace, presence: true - validates :project_namespace, presence: true, on: :create, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? } + validates :project_namespace, presence: true, on: :create, if: -> { self.namespace } validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) } validates :name, uniqueness: { scope: :namespace_id } validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, @@ -529,6 +530,7 @@ class Project < ApplicationRecord # Scopes scope :pending_delete, -> { where(pending_delete: true) } scope :without_deleted, -> { where(pending_delete: false) } + scope :not_hidden, -> { where(hidden: false) } scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted } scope :with_storage_feature, ->(feature) do @@ -1006,10 +1008,6 @@ class Project < ApplicationRecord Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true) end - def context_commits_enabled? - Feature.enabled?(:context_commits, self.group, default_enabled: :yaml) - end - # LFS and hashed repository storage are required for using Design Management. def design_management_enabled? lfs_enabled? && hashed_storage?(:repository) @@ -1565,14 +1563,17 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def execute_hooks(data, hooks_scope = :push_hooks) run_after_commit_or_now do - hooks.hooks_for(hooks_scope).select_active(hooks_scope, data).each do |hook| - hook.async_execute(data, hooks_scope.to_s) - end + triggered_hooks(hooks_scope, data).execute SystemHooksService.new.execute_hooks(data, hooks_scope) end end # rubocop: enable CodeReuse/ServiceClass + def triggered_hooks(hooks_scope, data) + triggered = ::Projects::TriggeredHooks.new(hooks_scope, data) + triggered.add_hooks(hooks) + end + def execute_integrations(data, hooks_scope = :push_hooks) # Call only service hooks that are active for this scope run_after_commit_or_now do @@ -1876,13 +1877,9 @@ class Project < ApplicationRecord ensure_runners_token! end - def runners_token_prefix - RUNNERS_TOKEN_PREFIX - end - override :format_runners_token def format_runners_token(token) - "#{runners_token_prefix}#{token}" + "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}" end def pages_deployed? @@ -1938,12 +1935,12 @@ class Project < ApplicationRecord .delete_all end - def mark_pages_as_deployed(artifacts_archive: nil) - ensure_pages_metadatum.update!(deployed: true, artifacts_archive: artifacts_archive) + def mark_pages_as_deployed + ensure_pages_metadatum.update!(deployed: true) end def mark_pages_as_not_deployed - ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil) + ensure_pages_metadatum.update!(deployed: false) end def update_pages_deployment!(deployment) @@ -2521,7 +2518,18 @@ class Project < ApplicationRecord end def access_request_approvers_to_be_notified - members.maintainers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + # For a personal project: + # The creator is added as a member with `Owner` access level, starting from GitLab 14.8 + # The creator was added as a member with `Maintainer` access level, before GitLab 14.8 + # So, to make sure access requests for all personal projects work as expected, + # we need to filter members with the scope `owners_and_maintainers`. + access_request_approvers = if personal? + members.owners_and_maintainers + else + members.maintainers + end + + access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def pages_lookup_path(trim_prefix: nil, domain: nil) @@ -2817,6 +2825,10 @@ class Project < ApplicationRecord end end + def pending_delete_or_hidden? + pending_delete? || hidden? + end + private # overridden in EE @@ -2838,7 +2850,9 @@ class Project < ApplicationRecord if @topic_list != self.topic_list self.topics.delete_all - self.topics = @topic_list.map { |topic| Projects::Topic.find_or_create_by(name: topic) } + self.topics = @topic_list.map do |topic| + Projects::Topic.where('lower(name) = ?', topic.downcase).order(total_projects_count: :desc).first_or_create(name: topic) + end end @topic_list = nil @@ -3010,16 +3024,15 @@ class Project < ApplicationRecord end def ensure_project_namespace_in_sync - # create project_namespace when project is created if create_project_namespace_on_project_create FF is enabled + # create project_namespace when project is created build_project_namespace if project_namespace_creation_enabled? - # regardless of create_project_namespace_on_project_create FF we need - # to keep project and project namespace in sync if there is one + # we need to keep project and project namespace in sync if there is one sync_attributes(project_namespace) if sync_project_namespace? end def project_namespace_creation_enabled? - new_record? && !project_namespace && self.namespace && self.root_namespace.project_namespace_creation_enabled? + new_record? && !project_namespace && self.namespace end def sync_project_namespace? diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index c76332b21cd..5c6fdec16ca 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -9,7 +9,7 @@ class ProjectAuthorization < ApplicationRecord validates :project, presence: true validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true - validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true + validates :user, uniqueness: { scope: :project }, presence: true def self.select_from_union(relations) from_union(relations) diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index d374ee120d1..3b514d5c5ff 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -14,7 +14,12 @@ class ProjectImportData < ApplicationRecord insecure_mode: true, algorithm: 'aes-256-cbc' - serialize :data, JSON # rubocop:disable Cop/ActiveRecordSerialize + # NOTE + # We are serializing a project as `data` in an "unsafe" way here + # because the credentials are necessary for a successful import. + # This is safe because the serialization is only going between rails + # and the database, never to any end users. + serialize :data, Serializers::UnsafeJson # rubocop:disable Cop/ActiveRecordSerialize validates :project, presence: true diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb index 58dbac9057f..dc1e9319340 100644 --- a/app/models/project_pages_metadatum.rb +++ b/app/models/project_pages_metadatum.rb @@ -4,11 +4,13 @@ class ProjectPagesMetadatum < ApplicationRecord extend SuppressCompositePrimaryKeyWarning include EachBatch + include IgnorableColumns self.primary_key = :project_id + ignore_columns :artifacts_archive_id, remove_with: '15.0', remove_after: '2022-04-22' + belongs_to :project, inverse_of: :pages_metadatum - belongs_to :artifacts_archive, class_name: 'Ci::JobArtifact' belongs_to :pages_deployment scope :deployed, -> { where(deployed: true) } diff --git a/app/models/project_team.rb b/app/models/project_team.rb index c3c7508df9f..4b89d95c1a3 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -23,6 +23,10 @@ class ProjectTeam add_user(user, :maintainer, current_user: current_user) end + def add_owner(user, current_user: nil) + add_user(user, :owner, current_user: current_user) + end + def add_role(user, role, current_user: nil) public_send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend end @@ -103,7 +107,9 @@ class ProjectTeam if group group.owners else - [project.owner] + # workaround until we migrate Project#owners to have membership with + # OWNER access level + Array.wrap(fetch_members(Gitlab::Access::OWNER)) | Array.wrap(project.owner) end end @@ -173,7 +179,9 @@ class ProjectTeam # # Returns a Hash mapping user ID -> maximum access level. def max_member_access_for_user_ids(user_ids) - project.max_member_access_for_resource_ids(User, user_ids) 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) @@ -190,31 +198,15 @@ class ProjectTeam end def contribution_check_for_user_ids(user_ids) - user_ids = user_ids.uniq - key = "contribution_check_for_users:#{project.id}" - - Gitlab::SafeRequestStore[key] ||= {} - contributors = Gitlab::SafeRequestStore[key] || {} - - user_ids -= contributors.keys - - return contributors if user_ids.empty? - - resource_contributors = project.merge_requests - .merged - .where(author_id: user_ids, target_branch: project.default_branch.to_s) - .pluck(:author_id) - .product([true]).to_h - - contributors.merge!(resource_contributors) - - missing_resource_ids = user_ids - resource_contributors.keys - - missing_resource_ids.each do |resource_id| - contributors[resource_id] = false + 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) + .pluck(:author_id) + .product([true]).to_h end - - contributors end def contributor?(user_id) diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb new file mode 100644 index 00000000000..afb67b79f0d --- /dev/null +++ b/app/models/projects/build_artifacts_size_refresh.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Projects + class BuildArtifactsSizeRefresh < ApplicationRecord + include BulkInsertSafe + + STALE_WINDOW = 3.days + + self.table_name = 'project_build_artifacts_size_refreshes' + + belongs_to :project + + validates :project, presence: true + + STATES = { + created: 1, + running: 2, + pending: 3 + }.freeze + + state_machine :state, initial: :created do + # created -> running <-> pending + state :created, value: STATES[:created] + state :running, value: STATES[:running] + state :pending, value: STATES[:pending] + + event :process do + transition [:created, :pending, :running] => :running + end + + event :requeue do + transition running: :pending + end + + # set it only the first time we execute the refresh + before_transition created: :running do |refresh| + refresh.reset_project_statistics! + refresh.refresh_started_at = Time.zone.now + end + + before_transition running: any do |refresh, transition| + refresh.updated_at = Time.zone.now + end + + before_transition running: :pending do |refresh, transition| + refresh.last_job_artifact_id = transition.args.first + end + end + + scope :stale, -> { with_state(:running).where('updated_at < ?', STALE_WINDOW.ago) } + scope :remaining, -> { with_state(:created, :pending).or(stale) } + + def self.enqueue_refresh(projects) + now = Time.zone.now + + records = Array(projects).map do |project| + new(project: project, state: STATES[:created], created_at: now, updated_at: now) + end + + bulk_insert!(records, skip_duplicates: true) + end + + def self.process_next_refresh! + next_refresh = nil + + transaction do + next_refresh = remaining + .order(:state, :updated_at) + .lock('FOR UPDATE SKIP LOCKED') + .take + + next_refresh&.process! + end + + next_refresh + end + + def reset_project_statistics! + statistics = project.statistics + statistics.update!(build_artifacts_size: 0) + statistics.clear_counter!(:build_artifacts_size) + end + + def next_batch(limit:) + project.job_artifacts.select(:id, :size) + .where('created_at <= ? AND id > ?', refresh_started_at, last_job_artifact_id.to_i) + .order(:created_at) + .limit(limit) + end + end +end diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index 78bc2df2e1e..b42b03f0618 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -7,18 +7,19 @@ module Projects include Avatarable include Gitlab::SQL::Pattern - validates :name, presence: true, uniqueness: true, length: { maximum: 255 } + validates :name, presence: true, length: { maximum: 255 } + validates :name, uniqueness: { case_sensitive: false }, if: :name_changed? validates :description, length: { maximum: 1024 } has_many :project_topics, class_name: 'Projects::ProjectTopic' has_many :projects, through: :project_topics - scope :order_by_total_projects_count, -> { order(total_projects_count: :desc).order(id: :asc) } + scope :order_by_non_private_projects_count, -> { order(non_private_projects_count: :desc).order(id: :asc) } scope :reorder_by_similarity, -> (search) do order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ { column: arel_table['name'] } ]) - reorder(order_expression.desc, arel_table['total_projects_count'].desc, arel_table['id']) + reorder(order_expression.desc, arel_table['non_private_projects_count'].desc, arel_table['id']) end class << self diff --git a/app/models/projects/triggered_hooks.rb b/app/models/projects/triggered_hooks.rb new file mode 100644 index 00000000000..e3aa3d106b7 --- /dev/null +++ b/app/models/projects/triggered_hooks.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Projects + class TriggeredHooks + def initialize(scope, data) + @scope = scope + @data = data + @relations = [] + end + + def add_hooks(relation) + @relations << relation + self + end + + def execute + # Assumes that the relations implement TriggerableHooks + @relations.each do |hooks| + hooks.hooks_for(@scope).select_active(@scope, @data).each do |hook| + hook.async_execute(@data, @scope.to_s) + end + end + end + end +end diff --git a/app/models/release.rb b/app/models/release.rb index 0fda6940249..c6c0920c4d0 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -5,6 +5,8 @@ class Release < ApplicationRecord include CacheMarkdownField include Importable include Gitlab::Utils::StrongMemoize + include EachBatch + include FromUnion cache_markdown_field :description @@ -24,6 +26,8 @@ class Release < ApplicationRecord before_create :set_released_at validates :project, :tag, presence: true + validates :tag, uniqueness: { scope: :project_id } + validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, if: :description_changed? validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] } diff --git a/app/models/repository.rb b/app/models/repository.rb index be8e530c650..346478b6689 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -15,6 +15,7 @@ class Repository heads tags replace + #{REF_MERGE_REQUEST} #{REF_ENVIRONMENTS} #{REF_KEEP_AROUND} #{REF_PIPELINES} @@ -1084,10 +1085,10 @@ class Repository blob.data end - def create_if_not_exists + def create_if_not_exists(default_branch = nil) return if exists? - raw.create_repository + raw.create_repository(default_branch) after_create true diff --git a/app/models/snippet.rb b/app/models/snippet.rb index b04fca64c87..38aaeff5c9a 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -350,24 +350,10 @@ class Snippet < ApplicationRecord snippet_repository&.shard_name || Repository.pick_storage_shard end - # Repositories are created with a default branch. This branch - # can be different from the default branch set in the platform. - # This method changes the `HEAD` file to point to the existing - # default branch in case it's different. - def change_head_to_default_branch - return unless repository.exists? - # All snippets must have at least 1 file. Therefore, if - # `HEAD` is empty is because it's pointing to the wrong - # default branch - return unless repository.empty? || list_files('HEAD').empty? - - repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}") - end - def create_repository return if repository_exists? && snippet_repository - repository.create_if_not_exists + repository.create_if_not_exists(default_branch) track_snippet_repository(repository.storage) end diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb index c61cd3b6b30..05e93f00912 100644 --- a/app/models/storage/hashed.rb +++ b/app/models/storage/hashed.rb @@ -3,6 +3,7 @@ module Storage class Hashed attr_accessor :container + delegate :gitlab_shell, :repository_storage, to: :container REPOSITORY_PATH_PREFIX = '@hashed' diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index 092e5249a3e..0d12a629b8e 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -3,6 +3,7 @@ module Storage class LegacyProject attr_accessor :project + delegate :namespace, :gitlab_shell, :repository_storage, to: :project def initialize(project) diff --git a/app/models/todo.rb b/app/models/todo.rb index dc436570f52..eb5d9965955 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -34,6 +34,8 @@ class Todo < ApplicationRecord ATTENTION_REQUESTED => :attention_requested }.freeze + ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED].freeze + belongs_to :author, class_name: "User" belongs_to :note belongs_to :project diff --git a/app/models/user.rb b/app/models/user.rb index 9cd238904ff..b3bdc2c1c42 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,7 +16,7 @@ class User < ApplicationRecord include FeatureGate include CreatedAtFilterable include BulkMemberAccessLoad - include BlocksJsonSerialization + include BlocksUnsafeSerialization include WithUploads include OptionallySearch include FromUnion @@ -135,6 +135,7 @@ class User < ApplicationRecord has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :webauthn_registrations has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :saved_replies, class_name: '::Users::SavedReply' has_one :user_synced_attributes_metadata, autosave: true has_one :aws_role, class_name: 'Aws::Role' @@ -276,24 +277,22 @@ class User < ApplicationRecord after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook after_destroy :remove_key_cache - after_create :add_primary_email_to_emails!, if: :confirmed? - after_commit(on: :update) do - if previous_changes.key?('email') - # Add the old primary email to Emails if not added already - this should be removed - # after the background migration for MR https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70872/ has completed, - # as the primary email is now added to Emails upon confirmation - # Issue to remove that: https://gitlab.com/gitlab-org/gitlab/-/issues/344134 - previous_confirmed_at = previous_changes.key?('confirmed_at') ? previous_changes['confirmed_at'][0] : confirmed_at - previous_email = previous_changes[:email][0] - if previous_confirmed_at && !emails.exists?(email: previous_email) - # rubocop: disable CodeReuse/ServiceClass - Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: previous_confirmed_at) - # rubocop: enable CodeReuse/ServiceClass - end + after_save if: -> { saved_change_to_email? && confirmed? } do + email_to_confirm = self.emails.find_by(email: self.email) - update_invalid_gpg_signatures + if email_to_confirm.present? + if skip_confirmation_period_expiry_check + email_to_confirm.force_confirm + else + email_to_confirm.confirm + end + else + add_primary_email_to_emails! end end + after_commit(on: :update) do + update_invalid_gpg_signatures if previous_changes.key?('email') + end after_initialize :set_projects_limit @@ -1692,6 +1691,12 @@ class User < ApplicationRecord end end + def attention_requested_open_merge_requests_count(force: false) + Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do + MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count + end + end + def assigned_open_issues_count(force: false) Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count @@ -1735,6 +1740,11 @@ class User < ApplicationRecord def invalidate_merge_request_cache_counts Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count']) + invalidate_attention_requested_count + end + + def invalidate_attention_requested_count + Rails.cache.delete(attention_request_cache_key) end def invalidate_todos_cache_counts @@ -1746,6 +1756,10 @@ class User < ApplicationRecord Rails.cache.delete(['users', id, 'personal_projects_count']) end + def attention_request_cache_key + ['users', id, 'attention_requested_open_merge_requests_count'] + end + # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth # flow means we don't call that automatically (and can't conveniently do so). # @@ -1846,7 +1860,9 @@ class User < ApplicationRecord # # Returns a Hash mapping project ID -> maximum access level. def max_member_access_for_project_ids(project_ids) - max_member_access_for_resource_ids(Project, project_ids) do |project_ids| + Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Project), + resource_ids: project_ids, + default_value: Gitlab::Access::NO_ACCESS) do |project_ids| project_authorizations.where(project: project_ids) .group(:project_id) .maximum(:access_level) @@ -1861,7 +1877,9 @@ class User < ApplicationRecord # # Returns a Hash mapping project ID -> maximum access level. def max_member_access_for_group_ids(group_ids) - max_member_access_for_resource_ids(Group, group_ids) do |group_ids| + Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Group), + resource_ids: group_ids, + default_value: Gitlab::Access::NO_ACCESS) do |group_ids| group_members.where(source: group_ids).group(:source_id).maximum(:access_level) end end @@ -1993,29 +2011,6 @@ class User < ApplicationRecord ci_job_token_scope.present? end - # override from Devise::Models::Confirmable - # - # Add the primary email to user.emails (or confirm it if it was already - # present) when the primary email is confirmed. - def confirm(args = {}) - saved = super(args) - return false unless saved - - email_to_confirm = self.emails.find_by(email: self.email) - - if email_to_confirm.present? - if skip_confirmation_period_expiry_check - email_to_confirm.force_confirm(args) - else - email_to_confirm.confirm(args) - end - else - add_primary_email_to_emails! - end - - saved - end - def user_project strong_memoize(:user_project) do personal_projects.find_by(path: username, visibility_level: Gitlab::VisibilityLevel::PUBLIC) @@ -2166,7 +2161,7 @@ class User < ApplicationRecord end def signup_email_invalid_message - self.new_record? ? _('is not allowed for sign-up.') : _('is not allowed.') + self.new_record? ? _('is not allowed for sign-up. Please use your regular email address.') : _('is not allowed. Please use your regular email address.') end def check_username_format diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 5c39e29a128..0922323e12b 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -42,7 +42,13 @@ module Users security_newsletter_callout: 39, verification_reminder: 40, # EE-only ci_deprecation_warning_for_types_keyword: 41, - security_training_feature_promotion: 42 # EE-only + security_training_feature_promotion: 42, # EE-only + storage_enforcement_banner_first_enforcement_threshold: 43, + storage_enforcement_banner_second_enforcement_threshold: 44, + storage_enforcement_banner_third_enforcement_threshold: 45, + storage_enforcement_banner_fourth_enforcement_threshold: 46, + attention_requests_top_nav: 47, + attention_requests_side_nav: 48 } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 556ee03605d..998a5deb0fd 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -8,7 +8,7 @@ module Users belongs_to :user - validates :holder_name, length: { maximum: 26 } + validates :holder_name, length: { maximum: 50 } validates :network, length: { maximum: 32 } validates :last_digits, allow_nil: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 9999 diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 0dc449719ab..839be8d2a48 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -11,10 +11,10 @@ module Users enum feature_name: { invite_members_banner: 1, approaching_seat_count_threshold: 2, # EE-only - storage_enforcement_banner_first_enforcement_threshold: 43, - storage_enforcement_banner_second_enforcement_threshold: 44, - storage_enforcement_banner_third_enforcement_threshold: 45, - storage_enforcement_banner_fourth_enforcement_threshold: 46 + storage_enforcement_banner_first_enforcement_threshold: 3, + storage_enforcement_banner_second_enforcement_threshold: 4, + storage_enforcement_banner_third_enforcement_threshold: 5, + storage_enforcement_banner_fourth_enforcement_threshold: 6 } validates :group, presence: true diff --git a/app/models/users/saved_reply.rb b/app/models/users/saved_reply.rb new file mode 100644 index 00000000000..7737d826b05 --- /dev/null +++ b/app/models/users/saved_reply.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Users + class SavedReply < ApplicationRecord + self.table_name = 'saved_replies' + + belongs_to :user + + validates :user_id, :name, :content, presence: true + validates :name, + length: { maximum: 255 }, + uniqueness: { scope: [:user_id] }, + format: { + with: Gitlab::Regex.saved_reply_name_regex, + message: Gitlab::Regex.saved_reply_name_regex_message + } + validates :content, length: { maximum: 10000 } + end +end diff --git a/app/models/wiki.rb b/app/models/wiki.rb index e114e30d589..622070abd88 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -87,8 +87,7 @@ class Wiki end def create_wiki_repository - repository.create_if_not_exists - change_head_to_default_branch + repository.create_if_not_exists(default_branch) raise CouldNotCreateWikiError unless repository_exists? rescue StandardError => err @@ -150,10 +149,10 @@ class Wiki # the page. # # Returns an initialized WikiPage instance or nil - def find_page(title, version = nil) + def find_page(title, version = nil, load_content: true) page_title, page_dir = page_title_and_dir(title) - if page = wiki.page(title: page_title, version: version, dir: page_dir) + if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content) WikiPage.new(self, page) end end @@ -322,16 +321,6 @@ class Wiki def default_message(action, title) "#{user.username} #{action} page: #{title}" end - - def change_head_to_default_branch - # If the wiki has commits in the 'HEAD' branch means that the current - # HEAD is pointing to the right branch. If not, it could mean that either - # the repo has just been created or that 'HEAD' is pointing - # to the wrong branch and we need to rewrite it - return if repository.raw_repository.commit_count('HEAD') != 0 - - repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}") - end end Wiki.prepend_mod_with('Wiki') diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 3dbbbcdfe23..803b9781ac4 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -45,6 +45,7 @@ class WikiPage # The GitLab Wiki instance. attr_reader :wiki + delegate :container, to: :wiki # The raw Gitlab::Git::WikiPage instance. @@ -315,7 +316,6 @@ class WikiPage end def update_front_matter(attrs) - return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container) return unless attrs.has_key?(:front_matter) fm_yaml = serialize_front_matter(attrs[:front_matter]) @@ -326,7 +326,7 @@ class WikiPage def parsed_content strong_memoize(:parsed_content) do - Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse + Gitlab::WikiPages::FrontMatterParser.new(raw_content).parse end end @@ -404,3 +404,5 @@ class WikiPage }) end end + +WikiPage.prepend_mod diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 99f05e4a181..557694da35a 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -7,4 +7,12 @@ class WorkItem < Issue def noteable_target_type_name 'issue' end + + private + + def record_create_action + super + + Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_created_action(author: author) + end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 494c4f5abe4..080513b28e9 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -38,6 +38,7 @@ module WorkItems scope :default, -> { where(namespace: nil) } scope :order_by_name_asc, -> { order('LOWER(name)') } + scope :by_type, ->(base_type) { where(base_type: base_type) } def self.default_by_type(type) find_by(namespace_id: nil, base_type: type) diff --git a/app/policies/alert_management/alert_policy.rb b/app/policies/alert_management/alert_policy.rb index 85fafcde2cc..e2383921c82 100644 --- a/app/policies/alert_management/alert_policy.rb +++ b/app/policies/alert_management/alert_policy.rb @@ -5,3 +5,5 @@ module AlertManagement delegate { @subject.project } end end + +AlertManagement::AlertPolicy.prepend_mod diff --git a/app/policies/application_setting_policy.rb b/app/policies/application_setting_policy.rb index 114c71fd99d..6d0b5f36fa4 100644 --- a/app/policies/application_setting_policy.rb +++ b/app/policies/application_setting_policy.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true class ApplicationSettingPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass - rule { admin }.enable :read_application_setting + rule { admin }.policy do + enable :read_application_setting + enable :update_runners_registration_token + end end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 77897c5807f..f8e7a912896 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -67,7 +67,7 @@ class BasePolicy < DeclarativePolicy::Base rule { default }.enable :read_cross_project - condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.dev_env_or_com? } + condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.com? } end BasePolicy.prepend_mod_with('BasePolicy') diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index bdbe7021276..6dfe9cc496b 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -9,6 +9,10 @@ module Ci @user.owns_runner?(@subject) end + condition(:belongs_to_multiple_projects) do + @subject.belongs_to_more_than_one_project? + end + rule { anonymous }.prevent_all rule { admin }.policy do @@ -22,6 +26,8 @@ module Ci enable :delete_runner end + rule { ~admin & belongs_to_multiple_projects }.prevent :delete_runner + rule { ~admin & locked }.prevent :assign_runner end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 2a2ddf29899..fa7b117f3cd 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -115,7 +115,6 @@ class GlobalPolicy < BasePolicy enable :approve_user enable :reject_user enable :read_usage_trends_measurement - enable :update_runners_registration_token end # We can't use `read_statistics` because the user may have different permissions for different projects diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 76e5b3ece53..7a49ad3d4aa 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -80,9 +80,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) && @subject.crm_enabled? } - with_scope :subject - condition(:group_runner_registration_allowed, score: 0, scope: :subject) do - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') + condition(:group_runner_registration_allowed) do + Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') end rule { can?(:read_group) & design_management_enabled }.policy do @@ -280,7 +279,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy prevent :admin_crm_organization end - rule { ~group_runner_registration_allowed }.policy do + rule { ~admin & ~group_runner_registration_allowed }.policy do prevent :register_group_runners end diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index c9c13b29643..a667c843bc6 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -13,7 +13,7 @@ class IssuePolicy < IssuablePolicy end desc "User can read contacts belonging to the issue group" - condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.group) } + condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.root_ancestor) } desc "Issue is confidential" condition(:confidential, scope: :subject) { @subject.confidential? } diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 4cc5ed06d61..09085bef9f0 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -194,6 +194,10 @@ class ProjectPolicy < BasePolicy condition(:"#{f}_disabled", score: 32) { !access_allowed_to?(f.to_sym) } end + condition(:project_runner_registration_allowed) do + Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?('project') + end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -230,6 +234,8 @@ class ProjectPolicy < BasePolicy enable :set_emails_disabled enable :set_show_default_award_emojis enable :set_warn_about_potentially_unwanted_characters + + enable :register_project_runners end rule { can?(:guest_access) }.policy do @@ -264,8 +270,6 @@ class ProjectPolicy < BasePolicy enable :create_work_item end - rule { can?(:update_issue) }.enable :update_work_item - # These abilities are not allowed to admins that are not members of the project, # that's why they are defined separately. rule { guest & can?(:download_code) }.enable :build_download_code @@ -409,6 +413,7 @@ class ProjectPolicy < BasePolicy enable :admin_feature_flag enable :admin_feature_flags_user_lists enable :update_escalation_status + enable :read_secure_files end rule { can?(:developer_access) & user_confirmed? }.policy do @@ -455,8 +460,10 @@ class ProjectPolicy < BasePolicy enable :update_freeze_period enable :destroy_freeze_period enable :admin_feature_flags_client + enable :register_project_runners enable :update_runners_registration_token enable :admin_project_google_cloud + enable :admin_secure_files end rule { public_project & metrics_dashboard_allowed }.policy do @@ -729,6 +736,10 @@ class ProjectPolicy < BasePolicy enable :access_security_and_compliance end + rule { ~admin & ~project_runner_registration_allowed }.policy do + prevent :register_project_runners + end + private def user_is_user? diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 018c061af9f..de99cbffb6f 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -23,9 +23,12 @@ class UserPolicy < BasePolicy enable :destroy_user enable :update_user enable :update_user_status + enable :create_saved_replies + enable :update_saved_replies enable :read_user_personal_access_tokens enable :read_group_count enable :read_user_groups + enable :read_saved_replies end rule { default }.enable :read_user_profile diff --git a/app/policies/users/saved_reply_policy.rb b/app/policies/users/saved_reply_policy.rb new file mode 100644 index 00000000000..be76c526012 --- /dev/null +++ b/app/policies/users/saved_reply_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Users + class SavedReplyPolicy < BasePolicy + delegate { @subject.user } + end +end diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb index 7ba5102a406..b4723bc7ed8 100644 --- a/app/policies/work_item_policy.rb +++ b/app/policies/work_item_policy.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true -class WorkItemPolicy < BasePolicy - delegate { @subject.project } +class WorkItemPolicy < IssuePolicy + rule { can?(:owner_access) | is_author }.enable :delete_work_item - desc 'User is author of the work item' - condition(:author) do - @user && @user == @subject.author - end + rule { can?(:update_issue) }.enable :update_work_item - rule { can?(:owner_access) | author }.enable :delete_work_item + rule { can?(:read_issue) }.enable :read_work_item end diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index b692935d229..659e991e9d8 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -3,7 +3,6 @@ module AlertManagement class AlertPresenter < Gitlab::View::Presenter::Delegated include IncidentManagement::Settings - include ActionView::Helpers::UrlHelper presents ::AlertManagement::Alert delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884. diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 47b72df32a2..aeab914dc9e 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -require 'ipynbdiff' class BlobPresenter < Gitlab::View::Presenter::Delegated include ApplicationHelper @@ -56,13 +55,19 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def replace_path - url_helpers.project_create_blob_path(project, ref_qualified_path) + url_helpers.project_update_blob_path(project, ref_qualified_path) end def pipeline_editor_path project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default end + def gitpod_blob_url + return unless Gitlab::CurrentSettings.gitpod_enabled && !current_user.nil? && current_user.gitpod_enabled + + "#{Gitlab::CurrentSettings.gitpod_url}##{url_helpers.project_tree_url(project, tree_join(blob.commit_id, blob.path || ''))}" + end + def find_file_path url_helpers.project_find_file_path(project, ref_qualified_path) end @@ -104,6 +109,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated fork_path_for_current_user(project, ide_edit_path) end + def fork_and_view_path + fork_path_for_current_user(project, web_path) + end + def can_modify_blob? super(blob, project, blob.commit_id) end @@ -128,6 +137,14 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated external_storage_url_or_path(url_helpers.project_raw_url(project, ref_qualified_path), project) end + def code_navigation_path + Gitlab::CodeNavigationPath.new(project, blob.commit_id).full_json_path_for(blob.path) + end + + def project_blob_path_root + project_blob_path(project, blob.commit_id) + end + private def url_helpers diff --git a/app/presenters/blobs/notebook_presenter.rb b/app/presenters/blobs/notebook_presenter.rb new file mode 100644 index 00000000000..16ae1e71191 --- /dev/null +++ b/app/presenters/blobs/notebook_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Blobs + class NotebookPresenter < ::BlobPresenter + def gitattr_language + 'md' + end + end +end diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 8e1b675d051..082993130a1 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -105,10 +105,7 @@ module Ci end def refspec_for_persistent_ref - # Use persistent_ref.sha because it sometimes causes 'git fetch' to do - # less work. See - # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/746. - "+#{pipeline.persistent_ref.sha}:#{pipeline.persistent_ref.path}" + "+#{pipeline.persistent_ref.path}:#{pipeline.persistent_ref.path}" end def persistent_ref_exist? diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 2818e6da036..410b633df50 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -5,7 +5,6 @@ module Ci include Gitlab::Utils::StrongMemoize delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884. - delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag` # We use a class method here instead of a constant, allowing EE to redefine # the returned `Hash` more easily. diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index cc466e0ff81..82152ce42ae 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -32,6 +32,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated new_polymorphic_path([clusterable, :cluster], options) end + def connect_path + polymorphic_path([clusterable, :clusters], action: :connect) + end + def authorize_aws_role_path polymorphic_path([clusterable, :clusters], action: :authorize_aws_role) end diff --git a/app/presenters/environment_presenter.rb b/app/presenters/environment_presenter.rb index 6c8da86187c..fe828fb9fd8 100644 --- a/app/presenters/environment_presenter.rb +++ b/app/presenters/environment_presenter.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class EnvironmentPresenter < Gitlab::View::Presenter::Delegated - include ActionView::Helpers::UrlHelper - presents ::Environment, as: :environment def path diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb index e9340a42e51..5dd2f3adda5 100644 --- a/app/presenters/gitlab/blame_presenter.rb +++ b/app/presenters/gitlab/blame_presenter.rb @@ -2,7 +2,6 @@ module Gitlab class BlamePresenter < Gitlab::View::Presenter::Simple - include ActionView::Helpers::UrlHelper include ActionView::Helpers::TranslationHelper include ActionView::Context include AvatarsHelper @@ -75,5 +74,13 @@ module Gitlab def project_duration @project_duration ||= age_map_duration(groups, project) end + + def link_to(*args, &block) + ActionController::Base.helpers.link_to(*args, &block) + end + + def mail_to(*args, &block) + ActionController::Base.helpers.mail_to(*args, &block) + end end end diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index f2550eb17e3..9e4a3b403ea 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -38,6 +38,11 @@ class InstanceClusterablePresenter < ClusterablePresenter admin_cluster_path(cluster, params) end + override :connect_path + def connect_path + connect_admin_clusters_path + end + override :create_user_clusters_path def create_user_clusters_path create_user_admin_clusters_path diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb index 8d604f9a0f6..6929bf79fdf 100644 --- a/app/presenters/label_presenter.rb +++ b/app/presenters/label_presenter.rb @@ -14,6 +14,10 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated end end + def text_color_class + "gl-label-text-#{label.color.contrast.luminosity}" + end + def destroy_path case label when GroupLabel then group_label_path(label.group, label) diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 8450679dd79..6dd3908b21d 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class MergeRequestPresenter < Gitlab::View::Presenter::Delegated - include ActionView::Helpers::UrlHelper include GitlabRoutingHelper include MarkupHelper include TreeHelper @@ -150,7 +149,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated ) end - def assign_to_closing_issues_link + def assign_to_closing_issues_path + assign_related_issues_project_merge_request_path(project, merge_request) + end + + def assign_to_closing_issues_count # rubocop: disable CodeReuse/ServiceClass issues = MergeRequests::AssignIssuesService.new(project: project, current_user: current_user, @@ -158,14 +161,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated merge_request: merge_request, closes_issues: closing_issues }).assignable_issues - path = assign_related_issues_project_merge_request_path(project, merge_request) - if issues.present? - if issues.count > 1 - link_to _('Assign yourself to these issues'), path, method: :post - else - link_to _('Assign yourself to this issue'), path, method: :post - end - end + issues.count # rubocop: enable CodeReuse/ServiceClass end @@ -290,6 +286,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def user_can_fork_project? can?(current_user, :fork_project, project) end + + # Avoid including ActionView::Helpers::UrlHelper + def link_to(*args) + ApplicationController.helpers.link_to(*args) + end end MergeRequestPresenter.prepend_mod_with('MergeRequestPresenter') diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb index 6c4d1143c0f..624fa1e0cb0 100644 --- a/app/presenters/project_clusterable_presenter.rb +++ b/app/presenters/project_clusterable_presenter.rb @@ -22,12 +22,12 @@ class ProjectClusterablePresenter < ClusterablePresenter override :sidebar_text def sidebar_text - s_('ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.') + s_('ClusterIntegration|Use GitLab to deploy to your cluster, run jobs, use review apps, and more.') end override :learn_more_link def learn_more_link - ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about Kubernetes.'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end def metrics_dashboard_path(cluster) diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 9e64d2d43a2..098519cdffe 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -2,7 +2,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated include ActionView::Helpers::NumberHelper - include ActionView::Helpers::UrlHelper include GitlabRoutingHelper include StorageHelper include TreeHelper @@ -138,17 +137,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated ide_edit_path(project, default_branch_or_main, 'README.md') end - def add_code_quality_ci_yml_path - add_special_file_path( - file_name: ci_config_path_or_default, - commit_message: s_("CommitMessage|Add %{file_name} and create a code quality job") % { file_name: ci_config_path_or_default }, - additional_params: { - template: 'Code-Quality', - code_quality_walkthrough: true - } - ) - end - def license_short_name license = repository.license license&.nickname || license&.name || 'LICENSE' @@ -473,6 +461,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated project.topics.map(&:name) end end + + # Avoid including ActionView::Helpers::UrlHelper + def content_tag(*args) + ActionController::Base.helpers.content_tag(*args) + end end ProjectPresenter.prepend_mod_with('ProjectPresenter') diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index dac42af38bf..fc47ece6199 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -1,14 +1,8 @@ # frozen_string_literal: true class ReleasePresenter < Gitlab::View::Presenter::Delegated - include ActionView::Helpers::UrlHelper - presents ::Release, as: :release - # TODO: Remove `delegate` as it's redundant due to SimpleDelegator. - delegator_override :tag, :project - delegate :project, :tag, to: :release - def commit_path return unless release.commit && can_download_code? diff --git a/app/presenters/releases/evidence_presenter.rb b/app/presenters/releases/evidence_presenter.rb index bdc053a303b..f7da6ceb8fe 100644 --- a/app/presenters/releases/evidence_presenter.rb +++ b/app/presenters/releases/evidence_presenter.rb @@ -2,8 +2,6 @@ module Releases class EvidencePresenter < Gitlab::View::Presenter::Delegated - include ActionView::Helpers::UrlHelper - presents ::Releases::Evidence, as: :evidence def filepath diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb index 72f967b8beb..4755b88cbea 100644 --- a/app/presenters/search_service_presenter.rb +++ b/app/presenters/search_service_presenter.rb @@ -25,7 +25,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated case scope when 'users' - objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord + objects.eager_load(:status) if objects.respond_to?(:eager_load) # rubocop:disable CodeReuse/ActiveRecord when 'commits' prepare_commits_for_rendering(objects) else diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb index 5a99f10b6e7..dc775fb4160 100644 --- a/app/presenters/user_presenter.rb +++ b/app/presenters/user_presenter.rb @@ -11,6 +11,22 @@ class UserPresenter < Gitlab::View::Presenter::Delegated should_be_private? ? ProjectMember.none : user.project_members end + def preferences_gitpod_path + profile_preferences_path(anchor: 'user_gitpod_enabled') if application_gitpod_enabled? + end + + def profile_enable_gitpod_path + profile_path(user: { gitpod_enabled: true }) if application_gitpod_enabled? + end + + delegator_override :saved_replies + def saved_replies + return ::Users::SavedReply.none unless Feature.enabled?(:saved_replies, current_user, default_enabled: :yaml) + return ::Users::SavedReply.none unless current_user.can?(:read_saved_replies, user) + + user.saved_replies + end + private def can?(*args) @@ -20,4 +36,8 @@ class UserPresenter < Gitlab::View::Presenter::Delegated def should_be_private? !Ability.allowed?(current_user, :read_user_profile, user) end + + def application_gitpod_enabled? + Gitlab::CurrentSettings.gitpod_enabled + end end diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb index cfbf6f60e38..c1d415dfb40 100644 --- a/app/serializers/analytics/cycle_analytics/stage_entity.rb +++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb @@ -57,7 +57,8 @@ module Analytics def html_description(event) options = {} if event.label_based? - options[:label_html] = render_label(event.label, link: '', small: true, tooltip: true) + label = event.label.present(issuable_subject: event.label.subject) + options[:label_html] = render_label(label, link: '', small: true, tooltip: true) end content_tag(:p) { event.html_description(options).html_safe } diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index ba42e14be22..e2d24e74b29 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -24,7 +24,7 @@ class ClusterEntity < Grape::Entity end expose :kubernetes_errors do |cluster| - ClusterErrorEntity.new(cluster) + Clusters::KubernetesErrorEntity.new(cluster) end expose :enable_advanced_logs_querying do |cluster| diff --git a/app/serializers/cluster_error_entity.rb b/app/serializers/cluster_error_entity.rb deleted file mode 100644 index c749537cb94..00000000000 --- a/app/serializers/cluster_error_entity.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class ClusterErrorEntity < Grape::Entity - expose :connection_error - expose :metrics_connection_error - expose :node_connection_error -end diff --git a/app/serializers/clusters/kubernetes_error_entity.rb b/app/serializers/clusters/kubernetes_error_entity.rb new file mode 100644 index 00000000000..ceab10f232e --- /dev/null +++ b/app/serializers/clusters/kubernetes_error_entity.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clusters + class KubernetesErrorEntity < Grape::Entity + expose :connection_error + expose :metrics_connection_error + expose :node_connection_error + end +end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 7a2fba73f3a..020c66af777 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -73,3 +73,5 @@ class DeploymentEntity < Grape::Entity request.try(:project) || options[:project] end end + +DeploymentEntity.prepend_mod diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index e0565a1e506..c818fcd6215 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -23,7 +23,7 @@ class DiffsEntity < Grape::Entity CommitEntity.represent(options[:commit], commit_options(options)) end - expose :context_commits, using: API::Entities::Commit, if: -> (diffs, options) { merge_request&.project&.context_commits_enabled? } do |diffs| + expose :context_commits, using: API::Entities::Commit do |diffs| options[:context_commits] end @@ -89,7 +89,7 @@ class DiffsEntity < Grape::Entity project_blob_path(merge_request.project, merge_request.diff_head_sha) end - expose :context_commits_diff, if: -> (_) { merge_request&.project&.context_commits_enabled? } do |diffs, options| + expose :context_commits_diff do |diffs, options| next unless merge_request.context_commits_diff.commits_count > 0 ContextCommitsDiffEntity.represent( diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 6105b52fbda..d484f60ed8f 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -20,6 +20,7 @@ class EnvironmentEntity < Grape::Entity expose :last_deployment, using: DeploymentEntity expose :stop_action_available?, as: :has_stop_action expose :rollout_status, if: -> (*) { can_read_deploy_board? }, using: RolloutStatusEntity + expose :tier expose :upcoming_deployment, if: -> (environment) { environment.upcoming_deployment } do |environment, ops| DeploymentEntity.represent(environment.upcoming_deployment, diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 8d9b73b2290..a8645c8670d 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -60,7 +60,7 @@ class EnvironmentSerializer < BaseSerializer Preloaders::Environments::DeploymentPreloader.new(resource) .execute_with_union(:upcoming_deployment, deployment_associations) - resource.all.to_a.tap do |environments| + resource.to_a.tap do |environments| environments.each do |environment| # Batch loading the commits of the deployments environment.last_deployment&.commit&.try(:lazy_author) diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb index 2be37d23a05..997abb0f148 100644 --- a/app/serializers/fork_namespace_entity.rb +++ b/app/serializers/fork_namespace_entity.rb @@ -30,14 +30,6 @@ class ForkNamespaceEntity < Grape::Entity markdown_description(namespace) end - expose :can_create_project do |namespace, options| - if Feature.enabled?(:fork_project_form, options[:project], default_enabled: :yaml) - true - else - options[:current_user].can?(:create_projects, namespace) - end - end - private # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb index 9c6601afd5e..7222b5df425 100644 --- a/app/serializers/issue_sidebar_basic_entity.rb +++ b/app/serializers/issue_sidebar_basic_entity.rb @@ -10,6 +10,11 @@ class IssueSidebarBasicEntity < IssuableSidebarBasicEntity can?(current_user, :update_escalation_status, issue.project) end end + + expose :show_crm_contacts do |issuable| + current_user&.can?(:read_crm_contact, issuable.project.root_ancestor) && + CustomerRelations::Contact.exists_for_group?(issuable.project.root_ancestor) + end end IssueSidebarBasicEntity.prepend_mod_with('IssueSidebarBasicEntity') diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index e586d7f8407..5785715390f 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -4,7 +4,9 @@ class LabelEntity < Grape::Entity expose :id expose :title - expose :color + expose :color do |label| + label.color.to_s + end expose :description expose :group_id expose :project_id diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index f2f97f560e0..bfb5b3eeae6 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -41,7 +41,7 @@ class MemberEntity < Grape::Entity expose :valid_level_roles, as: :valid_roles expose :user, if: -> (member) { member.user.present? } do |member, options| - MemberUserEntity.represent(member.user, source: options[:source]) + MemberUserEntity.represent(member.user, options) end expose :state diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index b9c71e6d97b..21ab20747d0 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -104,7 +104,11 @@ class MergeRequestWidgetEntity < Grape::Entity # include them if they are explicitly requested on first load. expose :issues_links, if: -> (_, opts) { opts[:issues_links] } do expose :assign_to_closing do |merge_request| - presenter(merge_request).assign_to_closing_issues_link + presenter(merge_request).assign_to_closing_issues_path + end + + expose :assign_to_closing_count do |merge_request| + presenter(merge_request).assign_to_closing_issues_count end expose :closing do |merge_request| diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index f459e700c03..76797a773b5 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -10,11 +10,6 @@ class PipelineDetailsEntity < Ci::PipelineEntity expose :details do expose :manual_actions, using: BuildActionEntity expose :scheduled_actions, using: BuildActionEntity - expose :code_quality_build_path, if: -> (_, options) { options[:code_quality_walkthrough] } do |pipeline| - next unless code_quality_build = pipeline.builds.finished.find_by_name('code_quality') - - project_job_path(pipeline.project, code_quality_build, code_quality_walkthrough: true) - end end expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb index a1fbfa1d4c4..49a4944b2b0 100644 --- a/app/serializers/service_event_entity.rb +++ b/app/serializers/service_event_entity.rb @@ -4,7 +4,7 @@ class ServiceEventEntity < Grape::Entity include RequestAwareEntity expose :title do |event| - event + IntegrationsHelper.integration_event_title(event) end expose :event_field_name, as: :name diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb index aad9db5ffea..b13f2c0e217 100644 --- a/app/serializers/service_field_entity.rb +++ b/app/serializers/service_field_entity.rb @@ -4,7 +4,7 @@ class ServiceFieldEntity < Grape::Entity include RequestAwareEntity include Gitlab::Utils::StrongMemoize - expose :type, :name, :placeholder, :required, :choices, :checkbox_label + expose :section, :type, :name, :placeholder, :required, :choices, :checkbox_label expose :title do |field| non_empty_password?(field) ? field[:non_empty_password_title] : field[:title] diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb index ab8d1176b9e..34c2003bd01 100644 --- a/app/services/alert_management/create_alert_issue_service.rb +++ b/app/services/alert_management/create_alert_issue_service.rb @@ -22,9 +22,7 @@ module AlertManagement return result unless result.success? issue = result.payload[:issue] - update_title_for(issue) - - SystemNoteService.new_alert_issue(alert, issue, user) + perform_after_create_tasks(issue) result end @@ -56,6 +54,12 @@ module AlertManagement issue.update!(title: "#{DEFAULT_INCIDENT_TITLE} #{issue.iid}") end + def perform_after_create_tasks(issue) + update_title_for(issue) + + SystemNoteService.new_alert_issue(alert, issue, user) + end + def error(message, issue = nil) ServiceResponse.error(payload: { issue: issue }, message: message) end @@ -75,3 +79,5 @@ module AlertManagement end end end + +AlertManagement::CreateAlertIssueService.prepend_mod diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 84518fd6b0e..bb6a52eb2f4 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -59,12 +59,7 @@ module Auth token.expire_time = token_expire_at token[:access] = names.map do |name| - { - type: type, - name: name, - actions: actions, - migration_eligible: type == 'repository' ? migration_eligible(repository_path: name) : nil - }.compact + { type: type, name: name, actions: actions } end token.encoded @@ -136,12 +131,7 @@ module Auth # ensure_container_repository!(path, authorized_actions) - { - type: type, - name: path.to_s, - actions: authorized_actions, - migration_eligible: self.class.migration_eligible(project: requested_project) - }.compact + { type: type, name: path.to_s, actions: authorized_actions } end def actively_importing?(actions, path) @@ -153,28 +143,6 @@ module Auth container_repository.migration_importing? end - def self.migration_eligible(project: nil, repository_path: nil) - return unless Feature.enabled?(:container_registry_migration_phase1) - - # project has precedence over repository_path. If only the latter is provided, we find the corresponding Project. - unless project - return unless repository_path - - project = ContainerRegistry::Path.new(repository_path).repository_project - end - - # The migration process will start by allowing only specific test and gitlab-org projects using the - # `container_registry_migration_phase1_allow` FF. We'll then move on to a percentage rollout using this same FF. - # To remove the risk of impacting enterprise customers that rely heavily on the registry during the percentage - # rollout, we'll add their top-level group/namespace to the `container_registry_migration_phase1_deny` FF. Later, - # we'll remove them manually from this deny list, and their new repositories will become eligible. - Feature.disabled?(:container_registry_migration_phase1_deny, project.root_ancestor) && - Feature.enabled?(:container_registry_migration_phase1_allow, project) - rescue ContainerRegistry::Path::InvalidRegistryPathError => ex - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ex, **Gitlab::ApplicationContext.current) - false - end - ## # Because we do not have two way communication with registry yet, # we create a container repository image resource when push to the diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb index a3e24844587..01fad14d036 100644 --- a/app/services/boards/base_items_list_service.rb +++ b/app/services/boards/base_items_list_service.rb @@ -12,21 +12,32 @@ module Boards end # rubocop: disable CodeReuse/ActiveRecord - def metadata - issuables = item_model.arel_table - keys = metadata_fields.keys + def metadata(required_fields = [:issue_count, :total_issue_weight]) + fields = metadata_fields(required_fields) + keys = fields.keys # TODO: eliminate need for SQL literal fragment - columns = Arel.sql(metadata_fields.values_at(*keys).join(', ')) - results = item_model.where(id: init_collection.select(issuables[:id])).pluck(columns) + columns = Arel.sql(fields.values_at(*keys).join(', ')) + results = item_model.where(id: collection_ids) + results = query_additions(results, required_fields) + results = results.select(columns) - Hash[keys.zip(results.flatten)] + Hash[keys.zip(results.pluck(columns).flatten)] end # rubocop: enable CodeReuse/ActiveRecord private - def metadata_fields - { size: 'COUNT(*)' } + # override if needed + def query_additions(items, required_fields) + items + end + + def collection_ids + @collection_ids ||= init_collection.select(item_model.arel_table[:id]) + end + + def metadata_fields(required_fields) + required_fields&.include?(:issue_count) ? { size: 'COUNT(*)' } : {} end def order(items) diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb index a7fe4c776b7..3a214122ed3 100644 --- a/app/services/bulk_create_integration_service.rb +++ b/app/services/bulk_create_integration_service.rb @@ -32,11 +32,7 @@ class BulkCreateIntegrationService end def integration_hash - if integration.template? - integration.to_integration_hash - else - integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id } - end + integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id } end def data_fields_hash diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb index 097b29cf143..bc70dd3bea4 100644 --- a/app/services/ci/after_requeue_job_service.rb +++ b/app/services/ci/after_requeue_job_service.rb @@ -22,9 +22,15 @@ module Ci end def dependent_jobs - stage_dependent_jobs - .or(needs_dependent_jobs.except(:preload)) + dependent_jobs = stage_dependent_jobs + .or(needs_dependent_jobs) .ordered_by_stage + + if ::Feature.enabled?(:ci_fix_order_of_subsequent_jobs, @processable.pipeline.project, default_enabled: :yaml) + dependent_jobs = ordered_by_dag(dependent_jobs) + end + + dependent_jobs end def process(job) @@ -44,5 +50,23 @@ module Ci def skipped_jobs @skipped_jobs ||= @processable.pipeline.processables.skipped end + + # rubocop: disable CodeReuse/ActiveRecord + def ordered_by_dag(jobs) + sorted_job_names = sort_jobs(jobs).each_with_index.to_h + + jobs.preload(:needs).group_by(&:stage_idx).flat_map do |_, stage_jobs| + stage_jobs.sort_by { |job| sorted_job_names.fetch(job.name) } + end + end + + def sort_jobs(jobs) + Gitlab::Ci::YamlProcessor::Dag.order( + jobs.to_h do |job| + [job.name, job.needs.map(&:name)] + end + ) + end + # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index a2e53cbf9b8..034bab93108 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -120,15 +120,13 @@ module Ci def has_cyclic_dependency? return false if @bridge.triggers_child_pipeline? - if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml) - pipeline_checksums = @bridge.pipeline.self_and_upstreams.filter_map do |pipeline| - config_checksum(pipeline) unless pipeline.child? - end - - # To avoid false positives we allow 1 cycle in the ancestry and - # fail when 2 cycles are detected: A -> B -> A -> B -> A - pipeline_checksums.tally.any? { |_checksum, occurrences| occurrences > 2 } + pipeline_checksums = @bridge.pipeline.self_and_upstreams.filter_map do |pipeline| + config_checksum(pipeline) unless pipeline.child? end + + # To avoid false positives we allow 1 cycle in the ancestry and + # fail when 2 cycles are detected: A -> B -> A -> B -> A + pipeline_checksums.tally.any? { |_checksum, occurrences| occurrences > 2 } end def has_max_descendants_depth? diff --git a/app/services/ci/destroy_secure_file_service.rb b/app/services/ci/destroy_secure_file_service.rb new file mode 100644 index 00000000000..7145ace7f31 --- /dev/null +++ b/app/services/ci/destroy_secure_file_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ci + class DestroySecureFileService < BaseService + def execute(secure_file) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_secure_files, secure_file.project) + + secure_file.destroy! + 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 883a70c9795..4d1b2e07d7f 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 @@ -91,17 +91,13 @@ module Ci def all_statuses_by_id strong_memoize(:all_statuses_by_id) do - all_statuses.to_h do |row| - [row[:id], row] - end + all_statuses.index_by { |row| row[:id] } end end def all_statuses_by_name strong_memoize(:statuses_by_name) do - all_statuses.to_h do |row| - [row[:name], row] - end + all_statuses.index_by { |row| row[:name] } end end diff --git a/app/services/ci/register_runner_service.rb b/app/services/ci/register_runner_service.rb deleted file mode 100644 index 7c6cd82565d..00000000000 --- a/app/services/ci/register_runner_service.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module Ci - class RegisterRunnerService - def execute(registration_token, attributes) - runner_type_attrs = extract_runner_type_attrs(registration_token) - - return unless runner_type_attrs - - ::Ci::Runner.create(attributes.merge(runner_type_attrs)) - end - - private - - def extract_runner_type_attrs(registration_token) - @attrs_from_token ||= check_token(registration_token) - - return unless @attrs_from_token - - attrs = @attrs_from_token.clone - case attrs[:runner_type] - when :project_type - attrs[:projects] = [attrs.delete(:scope)] - when :group_type - attrs[:groups] = [attrs.delete(:scope)] - end - - attrs - end - - def check_token(registration_token) - if runner_registration_token_valid?(registration_token) - # Create shared runner. Requires admin access - { runner_type: :instance_type } - elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token) - # Create a specific runner for the project - { runner_type: :project_type, scope: project } - elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token) - # Create a specific runner for the group - { runner_type: :group_type, scope: group } - end - end - - def runner_registration_token_valid?(registration_token) - ActiveSupport::SecurityUtils.secure_compare(registration_token, Gitlab::CurrentSettings.runners_registration_token) - end - - def runner_registrar_valid?(type) - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type) - end - - def token_scope - @attrs_from_token[:scope] - end - end -end - -Ci::RegisterRunnerService.prepend_mod diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 73c5d0163da..906e5cec4f3 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -65,7 +65,7 @@ module Ci def check_access!(build) unless can?(current_user, :update_build, build) - raise Gitlab::Access::AccessDeniedError + raise Gitlab::Access::AccessDeniedError, '403 Forbidden' end end diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 9ad46ca7585..d40643e1513 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -5,9 +5,8 @@ module Ci include Gitlab::OptimisticLocking def execute(pipeline) - unless can?(current_user, :update_pipeline, pipeline) - raise Gitlab::Access::AccessDeniedError - end + access_response = check_access(pipeline) + return access_response if access_response.error? pipeline.ensure_scheduling_type! @@ -30,6 +29,18 @@ module Ci Ci::ProcessPipelineService .new(pipeline) .execute + + ServiceResponse.success + rescue Gitlab::Access::AccessDeniedError => e + ServiceResponse.error(message: e.message, http_status: :forbidden) + end + + def check_access(pipeline) + if can?(current_user, :update_pipeline, pipeline) + ServiceResponse.success + else + ServiceResponse.error(message: '403 Forbidden', http_status: :forbidden) + end end private diff --git a/app/services/ci/runners/assign_runner_service.rb b/app/services/ci/runners/assign_runner_service.rb new file mode 100644 index 00000000000..886cd3a4e44 --- /dev/null +++ b/app/services/ci/runners/assign_runner_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ci + module Runners + class AssignRunnerService + # @param [Ci::Runner] runner: the runner to assign to a project + # @param [Project] project: the new project to assign the runner to + # @param [User] user: the user performing the operation + def initialize(runner, project, user) + @runner = runner + @project = project + @user = user + end + + def execute + return false unless @user.present? && @user.can?(:assign_runner, @runner) + + @runner.assign_to(@project, @user) + end + + private + + attr_reader :runner, :project, :user + end + end +end + +Ci::Runners::AssignRunnerService.prepend_mod diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb new file mode 100644 index 00000000000..7978d094d9b --- /dev/null +++ b/app/services/ci/runners/register_runner_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Ci + module Runners + class RegisterRunnerService + def execute(registration_token, attributes) + runner_type_attrs = extract_runner_type_attrs(registration_token) + + return unless runner_type_attrs + + ::Ci::Runner.create(attributes.merge(runner_type_attrs)) + end + + private + + def extract_runner_type_attrs(registration_token) + @attrs_from_token ||= check_token(registration_token) + + return unless @attrs_from_token + + attrs = @attrs_from_token.clone + case attrs[:runner_type] + when :project_type + attrs[:projects] = [attrs.delete(:scope)] + when :group_type + attrs[:groups] = [attrs.delete(:scope)] + end + + attrs + end + + def check_token(registration_token) + if runner_registration_token_valid?(registration_token) + # Create shared runner. Requires admin access + { runner_type: :instance_type } + elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token) + # Create a specific runner for the project + { runner_type: :project_type, scope: project } + elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token) + # Create a specific runner for the group + { runner_type: :group_type, scope: group } + end + end + + def runner_registration_token_valid?(registration_token) + ActiveSupport::SecurityUtils.secure_compare(registration_token, Gitlab::CurrentSettings.runners_registration_token) + end + + def runner_registrar_valid?(type) + Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type) + end + + def token_scope + @attrs_from_token[:scope] + end + end + end +end + +Ci::Runners::RegisterRunnerService.prepend_mod diff --git a/app/services/ci/runners/reset_registration_token_service.rb b/app/services/ci/runners/reset_registration_token_service.rb new file mode 100644 index 00000000000..bbe49c04644 --- /dev/null +++ b/app/services/ci/runners/reset_registration_token_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + module Runners + class ResetRegistrationTokenService + # @param [ApplicationSetting, Project, Group] scope: the scope of the reset operation + # @param [User] user: the user performing the operation + def initialize(scope, user) + @scope = scope + @user = user + end + + def execute + return unless @user.present? && @user.can?(:update_runners_registration_token, scope) + + case scope + when ::ApplicationSetting + scope.reset_runners_registration_token! + ApplicationSetting.current_without_cache.runners_registration_token + when ::Group, ::Project + scope.reset_runners_token! + scope.runners_token + end + end + + private + + attr_reader :scope, :user + end + end +end diff --git a/app/services/ci/runners/unassign_runner_service.rb b/app/services/ci/runners/unassign_runner_service.rb new file mode 100644 index 00000000000..1e46cf6add8 --- /dev/null +++ b/app/services/ci/runners/unassign_runner_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ci + module Runners + class UnassignRunnerService + # @param [Ci::RunnerProject] runner_project the runner/project association to destroy + # @param [User] user the user performing the operation + def initialize(runner_project, user) + @runner_project = runner_project + @runner = runner_project.runner + @project = runner_project.project + @user = user + end + + def execute + return false unless @user.present? && @user.can?(:assign_runner, @runner) + + @runner_project.destroy + end + + private + + attr_reader :runner, :project, :user + end + end +end + +Ci::Runners::UnassignRunnerService.prepend_mod diff --git a/app/services/ci/runners/unregister_runner_service.rb b/app/services/ci/runners/unregister_runner_service.rb new file mode 100644 index 00000000000..4ee1e73c458 --- /dev/null +++ b/app/services/ci/runners/unregister_runner_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ci + module Runners + class UnregisterRunnerService + attr_reader :runner, :author + + # @param [Ci::Runner] runner the runner to unregister/destroy + # @param [User, authentication token String] author the user or the authentication token that authorizes the removal + def initialize(runner, author) + @runner = runner + @author = author + end + + def execute + @runner&.destroy + end + end + end +end + +Ci::Runners::UnregisterRunnerService.prepend_mod diff --git a/app/services/ci/runners/update_runner_service.rb b/app/services/ci/runners/update_runner_service.rb new file mode 100644 index 00000000000..6cc080f81c2 --- /dev/null +++ b/app/services/ci/runners/update_runner_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ci + module Runners + class UpdateRunnerService + attr_reader :runner + + def initialize(runner) + @runner = runner + end + + def update(params) + params[:active] = !params.delete(:paused) if params.include?(:paused) + + runner.update(params).tap do |updated| + runner.tick_runner_queue if updated + end + end + end + end +end diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index a3f45c1b9cd..7323ad417ea 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -17,6 +17,7 @@ module Ci MAX_TRACKABLE_FAILURES = 200 attr_reader :pipeline + delegate :project, to: :pipeline def initialize(pipeline) diff --git a/app/services/ci/unregister_runner_service.rb b/app/services/ci/unregister_runner_service.rb deleted file mode 100644 index 97d9852b7ed..00000000000 --- a/app/services/ci/unregister_runner_service.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Ci - class UnregisterRunnerService - attr_reader :runner - - # @param [Ci::Runner] runner the runner to unregister/destroy - def initialize(runner) - @runner = runner - end - - def execute - @runner&.destroy - end - end -end diff --git a/app/services/ci/update_runner_service.rb b/app/services/ci/update_runner_service.rb deleted file mode 100644 index 4a17e25c0cc..00000000000 --- a/app/services/ci/update_runner_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Ci - class UpdateRunnerService - attr_reader :runner - - def initialize(runner) - @runner = runner - end - - def update(params) - params[:active] = !params.delete(:paused) if params.include?(:paused) - - runner.update(params).tap do |updated| - runner.tick_runner_queue if updated - end - end - end -end diff --git a/app/services/concerns/members/bulk_create_users.rb b/app/services/concerns/members/bulk_create_users.rb index 9cfef96311e..3f8971dde74 100644 --- a/app/services/concerns/members/bulk_create_users.rb +++ b/app/services/concerns/members/bulk_create_users.rb @@ -47,16 +47,15 @@ module Members end end - if user_ids.present? - # we should handle the idea of existing members where users are passed as users - https://gitlab.com/gitlab-org/gitlab/-/issues/352617 - # the below will automatically discard invalid user_ids - users.concat(User.id_in(user_ids)) + # the below will automatically discard invalid user_ids + users.concat(User.id_in(user_ids)) if user_ids.present? + users.uniq! # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times + + if users.present? # helps not have to perform another query per user id to see if the member exists later on when fetching - existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id) # rubocop:disable CodeReuse/ActiveRecord + existing_members = source.members_and_requesters.where(user_id: users).index_by(&:user_id) # rubocop:disable CodeReuse/ActiveRecord end - users.uniq! # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times - [emails, users, existing_members] end end diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb index c8dc60355cf..5d7247a5b99 100644 --- a/app/services/concerns/rate_limited_service.rb +++ b/app/services/concerns/rate_limited_service.rb @@ -36,7 +36,6 @@ module RateLimitedService def rate_limit!(service) evaluated_scope = evaluated_scope_for(service) - return if feature_flag_disabled?(evaluated_scope[:project]) if rate_limiter.throttled?(key, **opts.merge(scope: evaluated_scope.values, users_allowlist: users_allowlist)) raise RateLimitedError.new(key: key, rate_limiter: rate_limiter), _('This endpoint has been requested too many times. Try again later.') @@ -54,14 +53,11 @@ module RateLimitedService all[var] = service.public_send(var) # rubocop: disable GitlabSecurity/PublicSend end end - - def feature_flag_disabled?(project) - Feature.disabled?("rate_limited_service_#{key}", project, default_enabled: :yaml) - end end prepended do attr_accessor :rate_limiter_bypassed + cattr_accessor :rate_limiter_scoped_and_keyed def self.rate_limit(key:, opts:, rate_limiter: ::Gitlab::ApplicationRateLimiter) diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index cbcd0b7f56b..b21d05f4178 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -6,6 +6,7 @@ module UpdateRepositoryStorageMethods Error = Class.new(StandardError) attr_reader :repository_storage_move + delegate :container, :source_storage_name, :destination_storage_name, to: :repository_storage_move def initialize(repository_storage_move) diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb index 289c125b9d1..598621f70e1 100644 --- a/app/services/error_tracking/base_service.rb +++ b/app/services/error_tracking/base_service.rb @@ -1,7 +1,13 @@ # frozen_string_literal: true module ErrorTracking - class BaseService < ::BaseService + class BaseService < ::BaseProjectService + include Gitlab::Utils::UsageData + + def initialize(project, user = nil, params = {}) + super(project: project, current_user: user, params: params.dup) + end + def execute return unauthorized if unauthorized @@ -21,6 +27,8 @@ module ErrorTracking yield if block_given? + track_usage_event(params[:tracking_event], current_user.id) if params[:tracking_event] + success(parse_response(response)) end diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb index 50508c9810a..6376b743255 100644 --- a/app/services/error_tracking/collect_error_service.rb +++ b/app/services/error_tracking/collect_error_service.rb @@ -60,7 +60,7 @@ module ErrorTracking end def actor - return event['transaction'] if event['transaction'] + 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 diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb index e360b3a8e4e..51d08cc5b55 100644 --- a/app/services/google_cloud/create_service_accounts_service.rb +++ b/app/services/google_cloud/create_service_accounts_service.rb @@ -12,7 +12,7 @@ module GoogleCloud service_account.project_id, service_account.to_json, service_account_key.to_json, - environment_protected? + ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name) ) ServiceResponse.success(message: _('Service account generated successfully'), payload: { @@ -50,11 +50,6 @@ module GoogleCloud def service_account_desc "GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'" end - - # Overridden in EE - def environment_protected? - false - end end end diff --git a/app/services/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/google_cloud/gcp_region_add_or_replace_service.rb new file mode 100644 index 00000000000..467f818bcc7 --- /dev/null +++ b/app/services/google_cloud/gcp_region_add_or_replace_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module GoogleCloud + class GcpRegionAddOrReplaceService < ::BaseService + def execute(environment, region) + gcp_region_key = Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY + + change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } } + filter_params = { key: gcp_region_key, filter: { environment_scope: environment } } + + existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first + + if existing_variable + change_params[:action] = :update + change_params[:variable] = existing_variable + else + change_params[:action] = :create + end + + ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute + end + end +end diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb index 3014daf08e2..b791f07cd65 100644 --- a/app/services/google_cloud/service_accounts_service.rb +++ b/app/services/google_cloud/service_accounts_service.rb @@ -14,12 +14,12 @@ module GoogleCloud # # This method looks up GitLab project's CI vars # and returns Google Cloud Service Accounts combinations - # aligning GitLab project and environment to GCP projects + # aligning GitLab project and ref to GCP projects def find_for_project - group_vars_by_environment.map do |environment_scope, value| + group_vars_by_ref.map do |environment_scope, value| { - environment: environment_scope, + ref: environment_scope, gcp_project: value['GCP_PROJECT_ID'], service_account_exists: value['GCP_SERVICE_ACCOUNT'].present?, service_account_key_exists: value['GCP_SERVICE_ACCOUNT_KEY'].present? @@ -27,21 +27,21 @@ module GoogleCloud end end - def add_for_project(environment, gcp_project_id, service_account, service_account_key, is_protected) + def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected) project_var_create_or_replace( - environment, + ref, 'GCP_PROJECT_ID', gcp_project_id, is_protected ) project_var_create_or_replace( - environment, + ref, 'GCP_SERVICE_ACCOUNT', service_account, is_protected ) project_var_create_or_replace( - environment, + ref, 'GCP_SERVICE_ACCOUNT_KEY', service_account_key, is_protected @@ -50,7 +50,7 @@ module GoogleCloud private - def group_vars_by_environment + def group_vars_by_ref filtered_vars = project.variables.filter { |variable| GCP_KEYS.include? variable.key } filtered_vars.each_with_object({}) do |variable, grouped| grouped[variable.environment_scope] ||= {} @@ -59,10 +59,19 @@ module GoogleCloud end def project_var_create_or_replace(environment_scope, key, value, is_protected) - params = { key: key, filter: { environment_scope: environment_scope } } - existing_variable = ::Ci::VariablesFinder.new(project, params).execute.first - existing_variable.destroy if existing_variable - project.variables.create!(key: key, value: value, environment_scope: environment_scope, protected: is_protected) + change_params = { variable_params: { key: key, value: value, environment_scope: environment_scope, protected: is_protected } } + filter_params = { key: key, filter: { environment_scope: environment_scope } } + + existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first + + if existing_variable + change_params[:action] = :update + change_params[:variable] = existing_variable + else + change_params[:action] = :create + end + + ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute end end end diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb index aee423659ef..4b0541e78a1 100644 --- a/app/services/groups/deploy_tokens/create_service.rb +++ b/app/services/groups/deploy_tokens/create_service.rb @@ -13,3 +13,5 @@ module Groups end end end + +Groups::DeployTokens::CreateService.prepend_mod diff --git a/app/services/groups/deploy_tokens/destroy_service.rb b/app/services/groups/deploy_tokens/destroy_service.rb index 6dae22f29d2..4745d00ed7f 100644 --- a/app/services/groups/deploy_tokens/destroy_service.rb +++ b/app/services/groups/deploy_tokens/destroy_service.rb @@ -11,3 +11,5 @@ module Groups end end end + +Groups::DeployTokens::DestroyService.prepend_mod diff --git a/app/services/groups/deploy_tokens/revoke_service.rb b/app/services/groups/deploy_tokens/revoke_service.rb new file mode 100644 index 00000000000..cf91d3b27fa --- /dev/null +++ b/app/services/groups/deploy_tokens/revoke_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Groups + module DeployTokens + class RevokeService < BaseService + attr_accessor :token + + def execute + @token = group.deploy_tokens.find(params[:id]) + @token.revoke! + end + end + end +end + +Groups::DeployTokens::RevokeService.prepend_mod diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index 5ffa746e109..c88c139a22e 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -11,11 +11,15 @@ module Groups # rubocop: disable CodeReuse/ActiveRecord def execute + # TODO - add a policy check here https://gitlab.com/gitlab-org/gitlab/-/issues/353082 + raise DestroyError, "You can't delete this group because you're blocked." if current_user.blocked? + group.prepare_for_destroy group.projects.includes(:project_feature).each do |project| # Execute the destruction of the models immediately to ensure atomic cleanup. success = ::Projects::DestroyService.new(project, current_user).execute + raise DestroyError, "Project #{project.id} can't be deleted" unless success end diff --git a/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb b/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb deleted file mode 100644 index edb9dc8ad91..00000000000 --- a/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module Import - module GitlabProjects - class CreateProjectFromRemoteFileService < CreateProjectFromUploadedFileService - FILE_SIZE_LIMIT = 10.gigabytes - ALLOWED_CONTENT_TYPES = [ - 'application/gzip', # most common content-type when fetching a tar.gz - 'application/x-tar' # aws-s3 uses x-tar for tar.gz files - ].freeze - - validate :valid_remote_import_url? - validate :validate_file_size - validate :validate_content_type - - private - - def required_params - [:path, :namespace, :remote_import_url] - end - - def project_params - super - .except(:file) - .merge(import_export_upload: ::ImportExportUpload.new( - remote_import_url: params[:remote_import_url] - )) - end - - def valid_remote_import_url? - ::Gitlab::UrlBlocker.validate!( - params[:remote_import_url], - allow_localhost: allow_local_requests?, - allow_local_network: allow_local_requests?, - schemes: %w(http https) - ) - - true - rescue ::Gitlab::UrlBlocker::BlockedUrlError => e - errors.add(:base, e.message) - - false - end - - def allow_local_requests? - ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? - end - - def validate_content_type - # AWS-S3 presigned URLs don't respond to HTTP HEAD requests, - # so file type cannot be validated - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75170#note_748059103 - return if amazon_s3? - - if headers['content-type'].blank? - errors.add(:base, "Missing 'ContentType' header") - elsif !ALLOWED_CONTENT_TYPES.include?(headers['content-type']) - errors.add(:base, "Remote file content type '%{content_type}' not allowed. (Allowed content types: %{allowed})" % { - content_type: headers['content-type'], - allowed: ALLOWED_CONTENT_TYPES.join(', ') - }) - end - end - - def validate_file_size - # AWS-S3 presigned URLs don't respond to HTTP HEAD requests, - # so file size cannot be validated - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75170#note_748059103 - return if amazon_s3? - - if headers['content-length'].to_i == 0 - errors.add(:base, "Missing 'ContentLength' header") - elsif headers['content-length'].to_i > FILE_SIZE_LIMIT - errors.add(:base, 'Remote file larger than limit. (limit %{limit})' % { - limit: ActiveSupport::NumberHelper.number_to_human_size(FILE_SIZE_LIMIT) - }) - end - end - - def amazon_s3? - headers['Server'] == 'AmazonS3' && headers['x-amz-request-id'].present? - end - - def headers - return {} if params[:remote_import_url].blank? || !valid_remote_import_url? - - @headers ||= Gitlab::HTTP.head(params[:remote_import_url]).headers - end - end - end -end diff --git a/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb b/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb deleted file mode 100644 index 35d52a11288..00000000000 --- a/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Import - module GitlabProjects - class CreateProjectFromUploadedFileService - include ActiveModel::Validations - include ::Services::ReturnServiceResponses - - validate :required_params_presence - - def initialize(current_user, params = {}) - @current_user = current_user - @params = params.dup - end - - def execute - return error(errors.full_messages.first) unless valid? - return error(project.errors.full_messages&.first) unless project.saved? - - success(project) - rescue StandardError => e - error(e.message) - end - - private - - attr_reader :current_user, :params - - def error(message) - super(message, :bad_request) - end - - def project - @project ||= ::Projects::GitlabProjectsImportService.new( - current_user, - project_params, - params[:override] - ).execute - end - - def project_params - { - name: params[:name], - path: params[:path], - namespace_id: params[:namespace].id, - file: params[:file], - overwrite: params[:overwrite], - import_type: 'gitlab_project' - } - end - - def required_params - [:path, :namespace, :file] - end - - def required_params_presence - required_params - .select { |key| params[key].blank? } - .each do |missing_parameter| - errors.add(:base, "Parameter '#{missing_parameter}' is required") - end - end - end - end -end diff --git a/app/services/import/gitlab_projects/create_project_service.rb b/app/services/import/gitlab_projects/create_project_service.rb new file mode 100644 index 00000000000..1613c4dde25 --- /dev/null +++ b/app/services/import/gitlab_projects/create_project_service.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Creates a new project with an associated project export file to be imported +# The associated project export file might be associated with different strategies +# to acquire the file to be imported, the default file_acquisition_strategy +# is uploading a file (Import::GitlabProjects::FileAcquisitionStrategies::FileUpload) +module Import + module GitlabProjects + class CreateProjectService + include ActiveModel::Validations + include ::Services::ReturnServiceResponses + + validates_presence_of :path, :namespace + + # Creates a new CreateProjectService. + # + # @param [User] current_user + # @param [Hash] :params + # @param [Import::GitlabProjects::FileAcquisitionStrategies::*] :file_acquisition_strategy + def initialize(current_user, params:, file_acquisition_strategy: FileAcquisitionStrategies::FileUpload) + @current_user = current_user + @params = params.dup + @strategy = file_acquisition_strategy.new(current_user: current_user, params: params) + end + + # Creates a project with the strategy parameters + # + # @return [Services::ServiceReponse] + def execute + return error(errors.full_messages) unless valid? + return error(project.errors.full_messages) unless project.saved? + + success(project) + rescue StandardError => e + error(e.message) + end + + # Cascade the validation to strategy + def valid? + super && strategy.valid? + end + + # Merge with strategy's errors + def errors + super.tap { _1.merge!(strategy.errors) } + end + + def read_attribute_for_validation(key) + params[key] + end + + private + + attr_reader :current_user, :params, :strategy + + def error(messages) + messages = Array.wrap(messages) + message = messages.shift + super(message, :bad_request, pass_back: { other_errors: messages }) + end + + def project + @project ||= ::Projects::GitlabProjectsImportService.new( + current_user, + project_params, + params[:override] + ).execute + end + + def project_params + { + name: params[:name], + path: params[:path], + namespace_id: params[:namespace].id, + overwrite: params[:overwrite], + import_type: 'gitlab_project' + }.merge(strategy.project_params) + end + end + end +end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb new file mode 100644 index 00000000000..8bee3067d6c --- /dev/null +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Import + module GitlabProjects + module FileAcquisitionStrategies + class FileUpload + include ActiveModel::Validations + + validate :uploaded_file + + def initialize(current_user: nil, params:) + @params = params + end + + def project_params + @project_params ||= @params.slice(:file) + end + + def file + @file ||= @params[:file] + end + + private + + def uploaded_file + return if file.present? && file.is_a?(UploadedFile) + + errors.add(:file, 'must be uploaded') + end + end + end + end +end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb new file mode 100644 index 00000000000..ae9a450660c --- /dev/null +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Import + module GitlabProjects + module FileAcquisitionStrategies + class RemoteFile + include ActiveModel::Validations + + def self.allow_local_requests? + ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end + + validates :file_url, addressable_url: { + schemes: %w(https), + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + dns_rebind_protection: true + } + validate :aws_s3, if: :validate_aws_s3? + # When removing the import_project_from_remote_file_s3 remove the + # whole condition of this validation: + validates_with RemoteFileValidator, if: -> { validate_aws_s3? || !s3_request? } + + def initialize(current_user: nil, params:) + @params = params + end + + def project_params + @project_parms ||= { + import_export_upload: ::ImportExportUpload.new(remote_import_url: file_url) + } + end + + def file_url + @file_url ||= params[:remote_import_url] + end + + def content_type + @content_type ||= headers['content-type'] + end + + def content_length + @content_length ||= headers['content-length'].to_i + end + + private + + attr_reader :params + + def aws_s3 + if s3_request? + errors.add(:base, 'To import from AWS S3 use `projects/remote-import-s3`') + end + end + + def s3_request? + headers['Server'] == 'AmazonS3' && headers['x-amz-request-id'].present? + end + + def validate_aws_s3? + ::Feature.enabled?(:import_project_from_remote_file_s3, default_enabled: :yaml) + end + + def headers + return {} if file_url.blank? + + @headers ||= Gitlab::HTTP.head(file_url, timeout: 1.second).headers + rescue StandardError => e + errors.add(:base, "Failed to retrive headers: #{e.message}") + + {} + end + end + end + end +end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb new file mode 100644 index 00000000000..5cbca53582d --- /dev/null +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Import + module GitlabProjects + module FileAcquisitionStrategies + class RemoteFileS3 + include ActiveModel::Validations + include Gitlab::Utils::StrongMemoize + + def self.allow_local_requests? + ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end + + validates_presence_of :region, :bucket_name, :file_key, :access_key_id, :secret_access_key + validates :file_url, addressable_url: { + schemes: %w(https), + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + dns_rebind_protection: true + } + + validates_with RemoteFileValidator + + # The import itself has a limit of 24h, since the URL is created before the import starts + # we add an expiration a bit longer to ensure it won't expire during the import. + URL_EXPIRATION = 28.hours.seconds + + def initialize(current_user: nil, params:) + @params = params + end + + def project_params + @project_parms ||= { + import_export_upload: ::ImportExportUpload.new(remote_import_url: file_url) + } + end + + def file_url + @file_url ||= s3_object&.presigned_url(:get, expires_in: URL_EXPIRATION.to_i) + end + + def content_type + @content_type ||= s3_object&.content_type + end + + def content_length + @content_length ||= s3_object&.content_length.to_i + end + + # Make the validated params/methods accessible + def read_attribute_for_validation(key) + return file_url if key == :file_url + + params[key] + end + + private + + attr_reader :params + + def s3_object + strong_memoize(:s3_object) do + build_s3_options + end + end + + def build_s3_options + object = Aws::S3::Object.new( + params[:bucket_name], + params[:file_key], + client: Aws::S3::Client.new( + region: params[:region], + access_key_id: params[:access_key_id], + secret_access_key: params[:secret_access_key] + ) + ) + + # Force validate if the object exists and is accessible + # Some exceptions are only raised when trying to access the object data + unless object.exists? + errors.add(:base, "File not found '#{params[:file_key]}' in '#{params[:bucket_name]}'") + return + end + + object + rescue StandardError => e + errors.add(:base, "Failed to open '#{params[:file_key]}' in '#{params[:bucket_name]}': #{e.message}") + nil + end + end + end + end +end diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb index ccbca671b37..a49e639ea62 100644 --- a/app/services/incident_management/pager_duty/process_webhook_service.rb +++ b/app/services/incident_management/pager_duty/process_webhook_service.rb @@ -2,7 +2,7 @@ module IncidentManagement module PagerDuty - class ProcessWebhookService + class ProcessWebhookService < ::BaseProjectService include Gitlab::Utils::StrongMemoize include IncidentManagement::Settings @@ -13,7 +13,8 @@ module IncidentManagement PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.trigger).freeze def initialize(project, payload) - @project = project + super(project: project) + @payload = payload end @@ -29,7 +30,7 @@ module IncidentManagement private - attr_reader :project, :payload + attr_reader :payload def process_incidents pager_duty_processable_events.each do |event| diff --git a/app/services/integrations/propagate_template_service.rb b/app/services/integrations/propagate_template_service.rb deleted file mode 100644 index 85a82ba4c8e..00000000000 --- a/app/services/integrations/propagate_template_service.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Integrations - # TODO: Remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/335178 - class PropagateTemplateService - def self.propagate(_integration) - # no-op - end - end -end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 95093b88155..a63c54df4a6 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -160,7 +160,7 @@ class IssuableBaseService < ::BaseProjectService params.delete(:escalation_status) ).execute - return unless result.success? && result.payload.present? + return unless result.success? && result[:escalation_status].present? @escalation_status_change_reason = result[:escalation_status].delete(:status_change_reason) @@ -486,7 +486,10 @@ class IssuableBaseService < ::BaseProjectService associations[:description] = issuable.description associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers? associations[:severity] = issuable.severity if issuable.supports_severity? - associations[:escalation_status] = issuable.escalation_status&.slice(:status, :policy_id) if issuable.supports_escalation? + + if issuable.supports_escalation? && issuable.escalation_status + associations[:escalation_status] = issuable.escalation_status.status_name + end associations end diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index 81685f81afa..802260c8fae 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -17,7 +17,7 @@ module IssuableLinks # otherwise create issue links for the issues which # are still not assigned and return success message. if render_conflict_error? - return error(issuables_assigned_message, 409) + return error(issuables_already_assigned_message, 409) end if render_not_found_error? @@ -36,6 +36,20 @@ module IssuableLinks success end + # rubocop: disable CodeReuse/ActiveRecord + def relate_issuables(referenced_issuable) + link = link_class.find_or_initialize_by(source: issuable, target: referenced_issuable) + + set_link_type(link) + + if link.changed? && link.save + create_notes(referenced_issuable) + end + + link + end + # rubocop: enable CodeReuse/ActiveRecord + private def render_conflict_error? @@ -96,6 +110,23 @@ module IssuableLinks {} end + def issuables_already_assigned_message + _('%{issuable}(s) already assigned' % { issuable: target_issuable_type.capitalize }) + end + + def issuables_not_found_message + _('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} URL.' % { issuable: target_issuable_type }) + end + + def target_issuable_type + :issue + end + + def create_notes(referenced_issuable) + SystemNoteService.relate_issuable(issuable, referenced_issuable, current_user) + SystemNoteService.relate_issuable(referenced_issuable, issuable, current_user) + end + def linkable_issuables(objects) raise NotImplementedError end @@ -104,16 +135,12 @@ module IssuableLinks raise NotImplementedError end - def relate_issuables(referenced_object) + def link_class raise NotImplementedError end - def issuables_assigned_message - _("Issue(s) already assigned") - end - - def issuables_not_found_message - _("No matching issue found. Make sure that you are adding a valid issue URL.") + def set_link_type(_link) + # no-op end end end diff --git a/app/services/issuable_links/destroy_service.rb b/app/services/issuable_links/destroy_service.rb index 28035bbb291..19edd008b0a 100644 --- a/app/services/issuable_links/destroy_service.rb +++ b/app/services/issuable_links/destroy_service.rb @@ -4,11 +4,13 @@ module IssuableLinks class DestroyService < BaseService include IncidentManagement::UsageData - attr_reader :link, :current_user + attr_reader :link, :current_user, :source, :target def initialize(link, user) @link = link @current_user = user + @source = link.source + @target = link.target end def execute @@ -22,6 +24,11 @@ module IssuableLinks private + def create_notes + SystemNoteService.unrelate_issuable(source, target, current_user) + SystemNoteService.unrelate_issuable(target, source, current_user) + end + def after_destroy create_notes track_event diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb index a022d3e0bcf..1c6621ce0a1 100644 --- a/app/services/issue_links/create_service.rb +++ b/app/services/issue_links/create_service.rb @@ -2,44 +2,25 @@ module IssueLinks class CreateService < IssuableLinks::CreateService - # rubocop: disable CodeReuse/ActiveRecord - def relate_issuables(referenced_issue) - link = IssueLink.find_or_initialize_by(source: issuable, target: referenced_issue) - - set_link_type(link) - - if link.changed? && link.save - create_notes(referenced_issue) - end - - link - end - # rubocop: enable CodeReuse/ActiveRecord - def linkable_issuables(issues) @linkable_issuables ||= begin issues.select { |issue| can?(current_user, :admin_issue_link, issue) } end end - def create_notes(referenced_issue) - SystemNoteService.relate_issue(issuable, referenced_issue, current_user) - SystemNoteService.relate_issue(referenced_issue, issuable, current_user) - end - def previous_related_issuables @related_issues ||= issuable.related_issues(current_user).to_a end private - def set_link_type(_link) - # EE only - end - def track_event track_incident_action(current_user, issuable, :incident_relate) end + + def link_class + IssueLink + end end end diff --git a/app/services/issue_links/destroy_service.rb b/app/services/issue_links/destroy_service.rb index 25a45fc697b..e2422ecaca9 100644 --- a/app/services/issue_links/destroy_service.rb +++ b/app/services/issue_links/destroy_service.rb @@ -4,23 +4,10 @@ module IssueLinks class DestroyService < IssuableLinks::DestroyService private - def source - @source ||= link.source - end - - def target - @target ||= link.target - end - def permission_to_remove_relation? can?(current_user, :admin_issue_link, source) && can?(current_user, :admin_issue_link, target) end - def create_notes - SystemNoteService.unrelate_issue(source, target, current_user) - SystemNoteService.unrelate_issue(target, source, current_user) - end - def track_event track_incident_action(current_user, target, :incident_unrelate) end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 7fbf7c6af58..7ab663718db 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -23,6 +23,7 @@ module Issues handle_move_between_ids(@issue) + @add_related_issue ||= params.delete(:add_related_issue) filter_resolve_discussion_params create(@issue, skip_system_notes: skip_system_notes) @@ -52,6 +53,7 @@ module Issues # Add new items to Issues::AfterCreateService if they can be performed in Sidekiq def after_create(issue) user_agent_detail_service.create + handle_add_related_issue(issue) resolve_discussions_with_issue(issue) create_escalation_status(issue) @@ -91,6 +93,12 @@ module Issues def user_agent_detail_service UserAgentDetailService.new(spammable: @issue, spam_params: spam_params) end + + def handle_add_related_issue(issue) + return unless @add_related_issue + + IssueLinks::CreateService.new(issue, issue.author, { target_issuable: @add_related_issue }).execute + end end end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index 3809d8bc347..7076e858155 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -23,11 +23,11 @@ module Issues def header_to_value_hash { + 'Title' => 'title', + 'Description' => 'description', 'Issue ID' => 'iid', 'URL' => -> (issue) { issue_url(issue) }, - 'Title' => 'title', 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, - 'Description' => 'description', 'Author' => 'author_name', 'Author Username' => -> (issue) { issue.author&.username }, 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, @@ -52,7 +52,7 @@ module Issues # rubocop: disable CodeReuse/ActiveRecord def issue_time_spent(issue) - issue.timelogs.map(&:time_spent).sum + issue.timelogs.sum(&:time_spent) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb index 2edc944435b..5836097f1fd 100644 --- a/app/services/issues/set_crm_contacts_service.rb +++ b/app/services/issues/set_crm_contacts_service.rb @@ -52,7 +52,7 @@ module Issues end def add_by_email - contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, emails(:add_emails)) + contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group.root_ancestor, emails(:add_emails)) add_by_id(contact_ids) end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 8372cd919e5..88c4ff1a8bb 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -51,7 +51,6 @@ module Issues old_mentioned_users = old_associations.fetch(:mentioned_users, []) old_assignees = old_associations.fetch(:assignees, []) old_severity = old_associations[:severity] - old_escalation_status = old_associations[:escalation_status] if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees) todo_service.resolve_todos_for_target(issue, current_user) @@ -68,7 +67,7 @@ module Issues handle_milestone_change(issue) handle_added_mentions(issue, old_mentioned_users) handle_severity_change(issue, old_severity) - handle_escalation_status_change(issue, old_escalation_status) + handle_escalation_status_change(issue) handle_issue_type_change(issue) end @@ -80,9 +79,7 @@ module Issues todo_service.reassigned_assignable(issue, current_user, old_assignees) track_incident_action(current_user, issue, :incident_assigned) - if Feature.enabled?(:broadcast_issue_updates, issue.project, default_enabled: :yaml) - GraphqlTriggers.issuable_assignees_updated(issue) - end + GraphqlTriggers.issuable_assignees_updated(issue) end def handle_task_changes(issuable) @@ -196,9 +193,8 @@ module Issues ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issue.id, current_user.id) end - def handle_escalation_status_change(issue, old_escalation_status) - return unless old_escalation_status.present? - return if issue.escalation_status&.slice(:status, :policy_id) == old_escalation_status + def handle_escalation_status_change(issue) + return unless issue.supports_escalation? && issue.escalation_status ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new( issue, diff --git a/app/services/labels/base_service.rb b/app/services/labels/base_service.rb index ead7f2ea607..f694e6d47a0 100644 --- a/app/services/labels/base_service.rb +++ b/app/services/labels/base_service.rb @@ -2,162 +2,8 @@ module Labels class BaseService < ::BaseService - COLOR_NAME_TO_HEX = { - black: '#000000', - silver: '#C0C0C0', - gray: '#808080', - white: '#FFFFFF', - maroon: '#800000', - red: '#FF0000', - purple: '#800080', - fuchsia: '#FF00FF', - green: '#008000', - lime: '#00FF00', - olive: '#808000', - yellow: '#FFFF00', - navy: '#000080', - blue: '#0000FF', - teal: '#008080', - aqua: '#00FFFF', - orange: '#FFA500', - aliceblue: '#F0F8FF', - antiquewhite: '#FAEBD7', - aquamarine: '#7FFFD4', - azure: '#F0FFFF', - beige: '#F5F5DC', - bisque: '#FFE4C4', - blanchedalmond: '#FFEBCD', - blueviolet: '#8A2BE2', - brown: '#A52A2A', - burlywood: '#DEB887', - cadetblue: '#5F9EA0', - chartreuse: '#7FFF00', - chocolate: '#D2691E', - coral: '#FF7F50', - cornflowerblue: '#6495ED', - cornsilk: '#FFF8DC', - crimson: '#DC143C', - darkblue: '#00008B', - darkcyan: '#008B8B', - darkgoldenrod: '#B8860B', - darkgray: '#A9A9A9', - darkgreen: '#006400', - darkgrey: '#A9A9A9', - darkkhaki: '#BDB76B', - darkmagenta: '#8B008B', - darkolivegreen: '#556B2F', - darkorange: '#FF8C00', - darkorchid: '#9932CC', - darkred: '#8B0000', - darksalmon: '#E9967A', - darkseagreen: '#8FBC8F', - darkslateblue: '#483D8B', - darkslategray: '#2F4F4F', - darkslategrey: '#2F4F4F', - darkturquoise: '#00CED1', - darkviolet: '#9400D3', - deeppink: '#FF1493', - deepskyblue: '#00BFFF', - dimgray: '#696969', - dimgrey: '#696969', - dodgerblue: '#1E90FF', - firebrick: '#B22222', - floralwhite: '#FFFAF0', - forestgreen: '#228B22', - gainsboro: '#DCDCDC', - ghostwhite: '#F8F8FF', - gold: '#FFD700', - goldenrod: '#DAA520', - greenyellow: '#ADFF2F', - grey: '#808080', - honeydew: '#F0FFF0', - hotpink: '#FF69B4', - indianred: '#CD5C5C', - indigo: '#4B0082', - ivory: '#FFFFF0', - khaki: '#F0E68C', - lavender: '#E6E6FA', - lavenderblush: '#FFF0F5', - lawngreen: '#7CFC00', - lemonchiffon: '#FFFACD', - lightblue: '#ADD8E6', - lightcoral: '#F08080', - lightcyan: '#E0FFFF', - lightgoldenrodyellow: '#FAFAD2', - lightgray: '#D3D3D3', - lightgreen: '#90EE90', - lightgrey: '#D3D3D3', - lightpink: '#FFB6C1', - lightsalmon: '#FFA07A', - lightseagreen: '#20B2AA', - lightskyblue: '#87CEFA', - lightslategray: '#778899', - lightslategrey: '#778899', - lightsteelblue: '#B0C4DE', - lightyellow: '#FFFFE0', - limegreen: '#32CD32', - linen: '#FAF0E6', - mediumaquamarine: '#66CDAA', - mediumblue: '#0000CD', - mediumorchid: '#BA55D3', - mediumpurple: '#9370DB', - mediumseagreen: '#3CB371', - mediumslateblue: '#7B68EE', - mediumspringgreen: '#00FA9A', - mediumturquoise: '#48D1CC', - mediumvioletred: '#C71585', - midnightblue: '#191970', - mintcream: '#F5FFFA', - mistyrose: '#FFE4E1', - moccasin: '#FFE4B5', - navajowhite: '#FFDEAD', - oldlace: '#FDF5E6', - olivedrab: '#6B8E23', - orangered: '#FF4500', - orchid: '#DA70D6', - palegoldenrod: '#EEE8AA', - palegreen: '#98FB98', - paleturquoise: '#AFEEEE', - palevioletred: '#DB7093', - papayawhip: '#FFEFD5', - peachpuff: '#FFDAB9', - peru: '#CD853F', - pink: '#FFC0CB', - plum: '#DDA0DD', - powderblue: '#B0E0E6', - rosybrown: '#BC8F8F', - royalblue: '#4169E1', - saddlebrown: '#8B4513', - salmon: '#FA8072', - sandybrown: '#F4A460', - seagreen: '#2E8B57', - seashell: '#FFF5EE', - sienna: '#A0522D', - skyblue: '#87CEEB', - slateblue: '#6A5ACD', - slategray: '#708090', - slategrey: '#708090', - snow: '#FFFAFA', - springgreen: '#00FF7F', - steelblue: '#4682B4', - tan: '#D2B48C', - thistle: '#D8BFD8', - tomato: '#FF6347', - turquoise: '#40E0D0', - violet: '#EE82EE', - wheat: '#F5DEB3', - whitesmoke: '#F5F5F5', - yellowgreen: '#9ACD32', - rebeccapurple: '#663399' - }.freeze - def convert_color_name_to_hex - color = params[:color] - color_name = color.strip.downcase - - return color if color_name.start_with?('#') - - COLOR_NAME_TO_HEX[color_name.to_sym] || color + ::Gitlab::Color.of(params[:color]) end end end diff --git a/app/services/loose_foreign_keys/batch_cleaner_service.rb b/app/services/loose_foreign_keys/batch_cleaner_service.rb index f3db2037911..b89de15a568 100644 --- a/app/services/loose_foreign_keys/batch_cleaner_service.rb +++ b/app/services/loose_foreign_keys/batch_cleaner_service.rb @@ -54,7 +54,7 @@ module LooseForeignKeys attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter, :deleted_records_rescheduled_count, :deleted_records_incremented_count def handle_over_limit - return if Feature.disabled?(:lfk_fair_queueing) + return if Feature.disabled?(:lfk_fair_queueing, default_enabled: :yaml) records_to_reschedule = [] records_to_increment = [] diff --git a/app/services/members/projects/creator_service.rb b/app/services/members/projects/creator_service.rb index 2e974177075..4dba81acf73 100644 --- a/app/services/members/projects/creator_service.rb +++ b/app/services/members/projects/creator_service.rb @@ -4,7 +4,7 @@ module Members module Projects class CreatorService < Members::CreatorService def self.access_levels - Gitlab::Access.sym_options + Gitlab::Access.sym_options_with_owner end private diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 3f39b2742c6..37c2676e51c 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -11,10 +11,11 @@ module MergeRequests reset_approvals_cache(merge_request) create_event(merge_request) + stream_audit_event(merge_request) create_approval_note(merge_request) mark_pending_todos_as_done(merge_request) execute_approval_hooks(merge_request, current_user) - remove_attention_requested(merge_request, current_user) + remove_attention_requested(merge_request) merge_request_activity_counter.track_approve_mr_action(user: current_user) success @@ -52,6 +53,10 @@ module MergeRequests def create_event(merge_request) event_service.approve_mr(merge_request, current_user) end + + def stream_audit_event(merge_request) + # Defined in EE + end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 3363fc90997..2ab623bacf8 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -60,7 +60,9 @@ module MergeRequests merge_request_activity_counter.track_reviewers_changed_action(user: current_user) unless new_reviewers.include?(current_user) - remove_attention_requested(merge_request, current_user) + remove_attention_requested(merge_request) + + merge_request.merge_request_reviewers_with(new_reviewers).update_all(updated_state_by_user_id: current_user.id) end end @@ -251,10 +253,10 @@ module MergeRequests ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, users: users.uniq).execute end - def remove_attention_requested(merge_request, user) + def remove_attention_requested(merge_request) return unless merge_request.attention_requested_enabled? - ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute + ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request).execute end end end diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb index 6573b623779..774f2c2ee35 100644 --- a/app/services/merge_requests/bulk_remove_attention_requested_service.rb +++ b/app/services/merge_requests/bulk_remove_attention_requested_service.rb @@ -19,6 +19,8 @@ module MergeRequests merge_request.merge_request_assignees.where(user_id: users).update_all(state: :reviewed) merge_request.merge_request_reviewers.where(user_id: users).update_all(state: :reviewed) + users.each { |user| user.invalidate_attention_requested_count } + success end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index c1292d924b2..9c525ae8489 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -31,6 +31,14 @@ module MergeRequests private + def before_create(merge_request) + # If the fetching of the source branch occurs in an ActiveRecord + # callback (e.g. after_create), a database transaction will be + # open while the Gitaly RPC waits. To avoid an idle in transaction + # timeout, we do this before we attempt to save the merge request. + merge_request.eager_fetch_ref! + end + def set_projects! # @project is used to determine whether the user can set the merge request's # assignee, milestone and labels. Whether they can depends on their diff --git a/app/services/merge_requests/export_csv_service.rb b/app/services/merge_requests/export_csv_service.rb index 8f2a70575e5..1f8dec69ef0 100644 --- a/app/services/merge_requests/export_csv_service.rb +++ b/app/services/merge_requests/export_csv_service.rb @@ -13,11 +13,11 @@ module MergeRequests def header_to_value_hash { + 'Title' => 'title', + 'Description' => 'description', 'MR IID' => 'iid', 'URL' => -> (merge_request) { merge_request_url(merge_request) }, - 'Title' => 'title', 'State' => 'state', - 'Description' => 'description', 'Source Branch' => 'source_branch', 'Target Branch' => 'target_branch', 'Source Project ID' => 'source_project_id', diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 97be9fe8d9f..a169a6dc0b6 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -21,10 +21,12 @@ module MergeRequests merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees) merge_request_activity_counter.track_assignees_changed_action(user: current_user) + merge_request.merge_request_assignees_with(new_assignees).update_all(updated_state_by_user_id: current_user.id) + execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks] unless new_assignees.include?(current_user) - remove_attention_requested(merge_request, current_user) + remove_attention_requested(merge_request) end end diff --git a/app/services/merge_requests/merge_orchestration_service.rb b/app/services/merge_requests/merge_orchestration_service.rb index 24341ef1145..5f3d2174840 100644 --- a/app/services/merge_requests/merge_orchestration_service.rb +++ b/app/services/merge_requests/merge_orchestration_service.rb @@ -26,7 +26,7 @@ module MergeRequests def can_merge_immediately?(merge_request) merge_request.can_be_merged_by?(current_user) && - merge_request.mergeable_state? + merge_request.mergeable? end def can_merge_automatically?(merge_request) diff --git a/app/services/merge_requests/mergeability/check_broken_status_service.rb b/app/services/merge_requests/mergeability/check_broken_status_service.rb new file mode 100644 index 00000000000..9a54a4292c8 --- /dev/null +++ b/app/services/merge_requests/mergeability/check_broken_status_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module MergeRequests + module Mergeability + class CheckBrokenStatusService < CheckBaseService + def execute + if merge_request.broken? + failure + else + success + end + end + + def skip? + false + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb new file mode 100644 index 00000000000..9b4eab9d399 --- /dev/null +++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module MergeRequests + module Mergeability + class CheckDiscussionsStatusService < CheckBaseService + def execute + if merge_request.mergeable_discussions_state? + success + else + failure + end + end + + def skip? + params[:skip_discussions_check].present? + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/check_draft_status_service.rb b/app/services/merge_requests/mergeability/check_draft_status_service.rb new file mode 100644 index 00000000000..bc940e2116d --- /dev/null +++ b/app/services/merge_requests/mergeability/check_draft_status_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MergeRequests + module Mergeability + class CheckDraftStatusService < CheckBaseService + def execute + if merge_request.draft? + failure + else + success + end + end + + def skip? + false + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/check_open_status_service.rb b/app/services/merge_requests/mergeability/check_open_status_service.rb new file mode 100644 index 00000000000..361af946e3f --- /dev/null +++ b/app/services/merge_requests/mergeability/check_open_status_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MergeRequests + module Mergeability + class CheckOpenStatusService < CheckBaseService + def execute + if merge_request.open? + success + else + failure + end + end + + def skip? + false + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb index c1d65fb65cc..03c6d985c23 100644 --- a/app/services/merge_requests/mergeability/run_checks_service.rb +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -7,6 +7,10 @@ module MergeRequests # We want to have the cheapest checks first in the list, # that way we can fail fast before running the more expensive ones CHECKS = [ + CheckOpenStatusService, + CheckDraftStatusService, + CheckBrokenStatusService, + CheckDiscussionsStatusService, CheckCiStatusService ].freeze diff --git a/app/services/merge_requests/reload_merge_head_diff_service.rb b/app/services/merge_requests/reload_merge_head_diff_service.rb index f02a9bd3139..4724dd1c068 100644 --- a/app/services/merge_requests/reload_merge_head_diff_service.rb +++ b/app/services/merge_requests/reload_merge_head_diff_service.rb @@ -9,7 +9,6 @@ module MergeRequests end def execute - return error("default_merge_ref_for_diffs feature flag is disabled") unless enabled? return error("Merge request has no merge ref head.") unless merge_request.merge_ref_head.present? error_msg = recreate_merge_head_diff @@ -23,10 +22,6 @@ module MergeRequests attr_reader :merge_request - def enabled? - Feature.enabled?(:default_merge_ref_for_diffs, merge_request.project, default_enabled: :yaml) - end - def recreate_merge_head_diff merge_request.merge_head_diff&.destroy! diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb index 198a21884b8..c7bc3532264 100644 --- a/app/services/merge_requests/remove_approval_service.rb +++ b/app/services/merge_requests/remove_approval_service.rb @@ -17,7 +17,7 @@ module MergeRequests reset_approvals_cache(merge_request) create_note(merge_request) merge_request_activity_counter.track_unapprove_mr_action(user: current_user) - remove_attention_requested(merge_request, current_user) + remove_attention_requested(merge_request) end success diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb index b727c24415e..a32a8071471 100644 --- a/app/services/merge_requests/remove_attention_requested_service.rb +++ b/app/services/merge_requests/remove_attention_requested_service.rb @@ -2,13 +2,12 @@ module MergeRequests class RemoveAttentionRequestedService < MergeRequests::BaseService - attr_accessor :merge_request, :user + attr_accessor :merge_request - def initialize(project:, current_user:, merge_request:, user:) + def initialize(project:, current_user:, merge_request:) super(project: project, current_user: current_user) @merge_request = merge_request - @user = user end def execute @@ -18,6 +17,8 @@ module MergeRequests update_state(reviewer) update_state(assignee) + current_user.invalidate_attention_requested_count + success else error("User is not a reviewer or assignee of the merge request") @@ -27,11 +28,11 @@ module MergeRequests private def assignee - merge_request.find_assignee(user) + merge_request.find_assignee(current_user) end def reviewer - merge_request.find_reviewer(user) + merge_request.find_reviewer(current_user) end def update_state(reviewer_or_assignee) diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index 35c50d63da0..4612688f78b 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -6,6 +6,8 @@ module MergeRequests return merge_request unless can?(current_user, :reopen_merge_request, merge_request) if merge_request.reopen + users = merge_request.assignees | merge_request.reviewers + create_event(merge_request) create_note(merge_request, 'reopened') merge_request_activity_counter.track_reopen_mr_action(user: current_user) @@ -13,11 +15,13 @@ module MergeRequests execute_hooks(merge_request, 'reopen') merge_request.reload_diff(current_user) merge_request.mark_as_unchecked - invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) + invalidate_cache_counts(merge_request, users: users) merge_request.update_project_counter_caches merge_request.cache_merge_request_closes_issues!(current_user) merge_request.cleanup_schedule&.destroy merge_request.update_column(:merge_ref_sha, nil) + + users.each { |user| user.invalidate_attention_requested_count } end merge_request diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb index d9f81ac310f..64cdcd725a2 100644 --- a/app/services/merge_requests/toggle_attention_requested_service.rb +++ b/app/services/merge_requests/toggle_attention_requested_service.rb @@ -18,12 +18,14 @@ module MergeRequests update_state(reviewer) update_state(assignee) + user.invalidate_attention_requested_count + if reviewer&.attention_requested? || assignee&.attention_requested? create_attention_request_note notity_user if current_user.id != user.id - remove_attention_requested(merge_request, current_user) + remove_attention_requested(merge_request) end else create_remove_attention_request_note @@ -59,7 +61,8 @@ module MergeRequests end def update_state(reviewer_or_assignee) - reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested) + reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested, + updated_state_by: current_user) end end end diff --git a/app/services/notification_recipients/builder/merge_request_unmergeable.rb b/app/services/notification_recipients/builder/merge_request_unmergeable.rb index 24d96b98002..b9facf07a3a 100644 --- a/app/services/notification_recipients/builder/merge_request_unmergeable.rb +++ b/app/services/notification_recipients/builder/merge_request_unmergeable.rb @@ -4,6 +4,7 @@ module NotificationRecipients module Builder class MergeRequestUnmergeable < Base attr_reader :target + def initialize(merge_request) @target = merge_request end diff --git a/app/services/notification_recipients/builder/new_note.rb b/app/services/notification_recipients/builder/new_note.rb index 17e4728d352..dcf6d23298a 100644 --- a/app/services/notification_recipients/builder/new_note.rb +++ b/app/services/notification_recipients/builder/new_note.rb @@ -4,6 +4,7 @@ module NotificationRecipients module Builder class NewNote < Base attr_reader :note + def initialize(note) @note = note end diff --git a/app/services/notification_recipients/builder/new_review.rb b/app/services/notification_recipients/builder/new_review.rb index 3b1296f6967..84598c3d4ad 100644 --- a/app/services/notification_recipients/builder/new_review.rb +++ b/app/services/notification_recipients/builder/new_review.rb @@ -4,6 +4,7 @@ module NotificationRecipients module Builder class NewReview < Base attr_reader :review + def initialize(review) @review = review end diff --git a/app/services/notification_recipients/builder/project_maintainers.rb b/app/services/notification_recipients/builder/project_maintainers.rb index e8f22c00a83..a295929a1a9 100644 --- a/app/services/notification_recipients/builder/project_maintainers.rb +++ b/app/services/notification_recipients/builder/project_maintainers.rb @@ -14,6 +14,7 @@ module NotificationRecipients return [] unless project add_recipients(project.team.maintainers, :mention, nil) + add_recipients(project.team.owners, :mention, nil) end def acting_user diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5b1733422d0..aa7e636b8a4 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -18,6 +18,7 @@ class NotificationService class Async attr_reader :parent + delegate :respond_to_missing, to: :parent def initialize(parent) @@ -64,6 +65,13 @@ class NotificationService end end + # Notify the owner of the account when a new personal access token is created + def access_token_created(user, token_name) + return unless user.can?(:receive_notifications) + + mailer.access_token_created_email(user, token_name).deliver_later + end + # Notify the owner of the personal access token, when it is about to expire # And mark the token with about_to_expire_delivered def access_token_about_to_expire(user, token_names) diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb index b988c191734..5d7e967ceb0 100644 --- a/app/services/packages/pypi/create_package_service.rb +++ b/app/services/packages/pypi/create_package_service.rb @@ -9,7 +9,7 @@ module Packages ::Packages::Package.transaction do meta = Packages::Pypi::Metadatum.new( package: created_package, - required_python: params[:requires_python] + required_python: params[:requires_python] || '' ) unless meta.valid? diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb index 7555ba26768..e2f2e220750 100644 --- a/app/services/personal_access_tokens/create_service.rb +++ b/app/services/personal_access_tokens/create_service.rb @@ -16,6 +16,7 @@ module PersonalAccessTokens if token.persisted? log_event(token) + notification_service.access_token_created(target_user, token.name) ServiceResponse.success(payload: { personal_access_token: token }) else ServiceResponse.error(message: token.errors.full_messages.to_sentence, payload: { personal_access_token: token }) diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index f5638b0aa40..15c978e6763 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -86,7 +86,7 @@ class PostReceiveService banner = nil if project - scoped_messages = BroadcastMessage.current_banner_messages(project.full_path).select do |message| + 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 diff --git a/app/services/projects/base_move_relations_service.rb b/app/services/projects/base_move_relations_service.rb index 3a159cef58b..bd5a39d3b59 100644 --- a/app/services/projects/base_move_relations_service.rb +++ b/app/services/projects/base_move_relations_service.rb @@ -3,6 +3,7 @@ module Projects class BaseMoveRelationsService < BaseService attr_reader :source_project + def execute(source_project, remove_remaining_elements: true) return if source_project.blank? diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 1a788abac12..72f3fddb4c3 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -145,12 +145,14 @@ module Projects end def caching_enabled? - container_expiration_policy && - older_than.present? + result = ::Gitlab::CurrentSettings.current_application_settings.container_registry_expiration_policies_caching && + container_expiration_policy && + older_than.present? + !!result end def throttling_enabled? - Feature.enabled?(:container_registry_expiration_policies_throttling) + Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml) end def max_list_size diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb index 589aac5c3ac..f109cb0ca20 100644 --- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb @@ -54,7 +54,7 @@ module Projects def throttling_enabled? strong_memoize(:feature_flag) do - Feature.enabled?(:container_registry_expiration_policies_throttling) + Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml) end end diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb index 404642acf72..4184c676fc3 100644 --- a/app/services/projects/container_repository/third_party/delete_tags_service.rb +++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb @@ -41,14 +41,12 @@ module Projects # update the manifests of the tags with the new dummy image def replace_tag_manifests(dummy_manifest) - deleted_tags = {} - - @tag_names.each do |name| + deleted_tags = @tag_names.map do |name| digest = @container_repository.client.put_tag(@container_repository.path, name, dummy_manifest) next unless digest - deleted_tags[name] = digest - end + [name, digest] + end.compact.to_h # make sure the digests are the same (it should always be) digests = deleted_tags.values.uniq diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index c885369dfec..252e1d76bef 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -147,7 +147,7 @@ module Projects priority: UserProjectAccessChangedService::LOW_PRIORITY ) else - @project.add_maintainer(@project.namespace.owner, current_user: current_user) + @project.add_owner(@project.namespace.owner, current_user: current_user) end end diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb index 592198ef241..2486544b150 100644 --- a/app/services/projects/deploy_tokens/create_service.rb +++ b/app/services/projects/deploy_tokens/create_service.rb @@ -13,3 +13,5 @@ module Projects end end end + +Projects::DeployTokens::CreateService.prepend_mod diff --git a/app/services/projects/deploy_tokens/destroy_service.rb b/app/services/projects/deploy_tokens/destroy_service.rb index e063f86a65c..7ac1b52b0af 100644 --- a/app/services/projects/deploy_tokens/destroy_service.rb +++ b/app/services/projects/deploy_tokens/destroy_service.rb @@ -11,3 +11,5 @@ module Projects end end end + +Projects::DeployTokens::DestroyService.prepend_mod diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 95af5a6863f..a73244c6971 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -37,7 +37,7 @@ module Projects system_hook_service.execute_hooks_for(project, :destroy) log_info("Project \"#{project.full_path}\" was deleted") - publish_project_deleted_event_for(project) if Feature.enabled?(:publish_project_deleted_event, default_enabled: :yaml) + publish_project_deleted_event_for(project) current_user.invalidate_personal_projects_count @@ -72,7 +72,13 @@ module Projects end def remove_snippets - response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute + # We're setting the hard_delete param because we dont need to perform the access checks within the service since + # the user has enough access rights to remove the project and its resources. + response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute(hard_delete: true) + + if response.error? + log_error("Snippet deletion failed on #{project.full_path} with the following message: #{response.message}") + end response.success? end @@ -194,6 +200,10 @@ module Projects ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline) end + project.secure_files.find_each(batch_size: BATCH_SIZE) do |secure_file| # rubocop: disable CodeReuse/ActiveRecord + ::Ci::DestroySecureFileService.new(project, current_user).execute(secure_file) + end + deleted_count = ::CommitStatus.for_project(project).delete_all Gitlab::AppLogger.info( diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index 9da72d9300e..76005a1c96e 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -11,6 +11,7 @@ module Projects LARGE_FILE_SIZE = 1.megabytes attr_reader :lfs_download_object + delegate :oid, :size, :credentials, :sanitized_url, :headers, to: :lfs_download_object, prefix: :lfs def initialize(project, lfs_download_object) diff --git a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb new file mode 100644 index 00000000000..794c042ea39 --- /dev/null +++ b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Projects + class RefreshBuildArtifactsSizeStatisticsService + BATCH_SIZE = 1000 + + def execute + refresh = Projects::BuildArtifactsSizeRefresh.process_next_refresh! + return unless refresh + + batch = refresh.next_batch(limit: BATCH_SIZE).to_a + + if batch.any? + # We are doing the sum in ruby because the query takes too long when done in SQL + total_artifacts_size = batch.sum(&:size) + + Projects::BuildArtifactsSizeRefresh.transaction do + # Mark the refresh ready for another worker to pick up and process the next batch + refresh.requeue!(batch.last.id) + + refresh.project.statistics.delayed_increment_counter(:build_artifacts_size, total_artifacts_size) + end + else + # Remove the refresh job from the table if there are no more + # remaining job artifacts to calculate for the given project. + refresh.destroy! + end + + refresh + end + end +end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 0000e713cb4..2ec965fe2f4 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -22,8 +22,8 @@ module Projects register_attempt # Create status notifying the deployment of pages - @status = build_commit_status - ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(@status) do |job| + @commit_status = build_commit_status + ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(@commit_status) do |job| job.enqueue! job.run! end @@ -46,17 +46,17 @@ module Projects private def success - @status.success - @project.mark_pages_as_deployed(artifacts_archive: build.job_artifacts_archive) + @commit_status.success + @project.mark_pages_as_deployed super end def error(message) register_failure log_error("Projects::UpdatePagesService: #{message}") - @status.allow_failure = !latest? - @status.description = message - @status.drop(:script_failure) + @commit_status.allow_failure = !latest? + @commit_status.description = message + @commit_status.drop(:script_failure) super end diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb index efb6f6de8db..13ad126f8f0 100644 --- a/app/services/repositories/base_service.rb +++ b/app/services/repositories/base_service.rb @@ -18,8 +18,6 @@ class Repositories::BaseService < BaseService end def mv_repository(from_path, to_path) - return true unless repo_exists?(from_path) - gitlab_shell.mv_repository(repository.shard, from_path, to_path) end diff --git a/app/services/repositories/destroy_rollback_service.rb b/app/services/repositories/destroy_rollback_service.rb index 5ef4e11bf55..a19e305607f 100644 --- a/app/services/repositories/destroy_rollback_service.rb +++ b/app/services/repositories/destroy_rollback_service.rb @@ -12,8 +12,14 @@ class Repositories::DestroyRollbackService < Repositories::BaseService log_info(%Q{Repository "#{removal_path}" moved to "#{disk_path}" for repository "#{full_path}"}) success - else + elsif repo_exists?(removal_path) + # If the repo does not exist, there is no need to return an + # error because there was nothing to do. move_error(removal_path) + else + success end + rescue Gitlab::Git::Repository::NoRepository + success end end diff --git a/app/services/repositories/destroy_service.rb b/app/services/repositories/destroy_service.rb index 1e34dfbe398..c5a0af56066 100644 --- a/app/services/repositories/destroy_service.rb +++ b/app/services/repositories/destroy_service.rb @@ -30,8 +30,12 @@ class Repositories::DestroyService < Repositories::BaseService log_info("Repository \"#{full_path}\" was removed") success - else + elsif repo_exists?(disk_path) move_error(disk_path) + else + success end + rescue Gitlab::Git::Repository::NoRepository + success end end diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb index ea77cd98ba3..7f3b66d40e1 100644 --- a/app/services/security/ci_configuration/base_create_service.rb +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -41,7 +41,7 @@ module Security end def existing_gitlab_ci_content - @gitlab_ci_yml ||= project.repository.gitlab_ci_yml_for(project.repository.root_ref_sha) + @gitlab_ci_yml ||= project.ci_config_for(project.repository.root_ref_sha) YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml end diff --git a/app/services/security/ci_configuration/container_scanning_create_service.rb b/app/services/security/ci_configuration/container_scanning_create_service.rb index 788533575e6..da2f1ac0981 100644 --- a/app/services/security/ci_configuration/container_scanning_create_service.rb +++ b/app/services/security/ci_configuration/container_scanning_create_service.rb @@ -6,7 +6,8 @@ module Security private def action - Security::CiConfiguration::ContainerScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate + Security::CiConfiguration::ContainerScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content, + project.ci_config_path).generate end def next_branch diff --git a/app/services/security/ci_configuration/dependency_scanning_create_service.rb b/app/services/security/ci_configuration/dependency_scanning_create_service.rb index 71e8d5025ae..b11eccc680c 100644 --- a/app/services/security/ci_configuration/dependency_scanning_create_service.rb +++ b/app/services/security/ci_configuration/dependency_scanning_create_service.rb @@ -6,7 +6,8 @@ module Security private def action - Security::CiConfiguration::DependencyScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate + Security::CiConfiguration::DependencyScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content, + project.ci_config_path).generate end def next_branch diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb index 47e01847b17..d78e22f1fe1 100644 --- a/app/services/security/ci_configuration/sast_create_service.rb +++ b/app/services/security/ci_configuration/sast_create_service.rb @@ -26,7 +26,7 @@ module Security nil end - Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content).generate + Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content, project.ci_config_path).generate end def next_branch diff --git a/app/services/security/ci_configuration/sast_iac_create_service.rb b/app/services/security/ci_configuration/sast_iac_create_service.rb index 80e9cf963da..fbc65484216 100644 --- a/app/services/security/ci_configuration/sast_iac_create_service.rb +++ b/app/services/security/ci_configuration/sast_iac_create_service.rb @@ -6,7 +6,8 @@ module Security private def action - Security::CiConfiguration::SastIacBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate + Security::CiConfiguration::SastIacBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content, + project.ci_config_path).generate end def next_branch diff --git a/app/services/security/ci_configuration/secret_detection_create_service.rb b/app/services/security/ci_configuration/secret_detection_create_service.rb index ff3458d36fc..ca5138b6ed6 100644 --- a/app/services/security/ci_configuration/secret_detection_create_service.rb +++ b/app/services/security/ci_configuration/secret_detection_create_service.rb @@ -6,7 +6,8 @@ module Security private def action - Security::CiConfiguration::SecretDetectionBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate + Security::CiConfiguration::SecretDetectionBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content, + project.ci_config_path).generate end def next_branch diff --git a/app/services/security/merge_reports_service.rb b/app/services/security/merge_reports_service.rb index 5f6f98a3c39..a982ec7efe2 100644 --- a/app/services/security/merge_reports_service.rb +++ b/app/services/security/merge_reports_service.rb @@ -21,7 +21,10 @@ module Security source_reports.first.type, source_reports.first.pipeline, source_reports.first.created_at - ).tap { |report| report.errors = source_reports.flat_map(&:errors) } + ).tap do |report| + report.errors = source_reports.flat_map(&:errors) + report.warnings = source_reports.flat_map(&:warnings) + end end def copy_resources_to_target_report diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 2a28b66f09b..4fa9c0e4993 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -65,22 +65,19 @@ module Spam # ask the SpamVerdictService what to do with the target. spam_verdict_service.execute.tap do |result| case result - when CONDITIONAL_ALLOW - # at the moment, this means "ask for reCAPTCHA" - create_spam_log - - break if target.allow_possible_spam? - - target.needs_recaptcha! - when DISALLOW - # TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService` - # https://gitlab.com/gitlab-org/gitlab/-/issues/214739 - target.spam! unless target.allow_possible_spam? - create_spam_log when BLOCK_USER # TODO: improve BLOCK_USER handling, non-existent until now # https://gitlab.com/gitlab-org/gitlab/-/issues/329666 - target.spam! unless target.allow_possible_spam? + target.spam! + create_spam_log + when DISALLOW + target.spam! + create_spam_log + when CONDITIONAL_ALLOW + # This means "require a CAPTCHA to be solved" + target.needs_recaptcha! + create_spam_log + when OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM create_spam_log when ALLOW target.clear_spam_flags! diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb index b654fbbbcc8..d300525710c 100644 --- a/app/services/spam/spam_constants.rb +++ b/app/services/spam/spam_constants.rb @@ -2,11 +2,12 @@ module Spam module SpamConstants - CONDITIONAL_ALLOW = "conditional_allow" - DISALLOW = "disallow" - ALLOW = "allow" - BLOCK_USER = "block" - NOOP = "noop" + BLOCK_USER = 'block' + DISALLOW = 'disallow' + CONDITIONAL_ALLOW = 'conditional_allow' + OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM = 'override_via_allow_possible_spam' + ALLOW = 'allow' + NOOP = 'noop' SUPPORTED_VERDICTS = { BLOCK_USER => { @@ -18,11 +19,14 @@ module Spam CONDITIONAL_ALLOW => { priority: 3 }, - ALLOW => { + OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM => { priority: 4 }, - NOOP => { + ALLOW => { priority: 5 + }, + NOOP => { + priority: 6 } }.freeze end diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb index ccc17a42f01..81db6b390b2 100644 --- a/app/services/spam/spam_params.rb +++ b/app/services/spam/spam_params.rb @@ -25,6 +25,7 @@ module Spam # then the spam check may fail, or the SpamLog or UserAgentDetail may have missing fields. class SpamParams def self.new_from_request(request:) + self.normalize_grape_request_headers(request: request) self.new( captcha_response: request.headers['X-GitLab-Captcha-Response'], spam_log_id: request.headers['X-GitLab-Spam-Log-Id'], @@ -52,5 +53,14 @@ module Spam other.user_agent == user_agent && other.referer == referer end + + def self.normalize_grape_request_headers(request:) + # If needed, make a normalized copy of Grape headers with the case of 'GitLab' (with an + # uppercase 'L') instead of 'Gitlab' (with a lowercase 'l'), because Grape header helper keys + # are "coerced into a capitalized kebab case". See https://github.com/ruby-grape/grape#request + %w[X-Gitlab-Captcha-Response X-Gitlab-Spam-Log-Id].each do |header| + request.headers[header.gsub('Gitlab', 'GitLab')] = request.headers[header] if request.headers.key?(header) + end + end end end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index c8bdcf4310b..e73b2666c02 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -39,21 +39,24 @@ module Spam return ALLOW unless valid_results.any? # Favour the most restrictive result. - final_verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] } + verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] } + + # The target can override the verdict via the `allow_possible_spam` feature flag + verdict = OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM if override_via_allow_possible_spam?(verdict: verdict) logger.info(class: self.class.name, akismet_verdict: akismet_verdict, spam_check_verdict: original_spamcheck_result, extra_attributes: spamcheck_attribs, spam_check_rtt: external_spam_check_round_trip_time.real, - final_verdict: final_verdict, + final_verdict: verdict, username: user.username, user_id: user.id, target_type: target.class.to_s, project_id: target.project_id ) - final_verdict + verdict end private @@ -87,6 +90,14 @@ module Spam end end + def override_via_allow_possible_spam?(verdict:) + # If the verdict is already going to allow (because current verdict's priority value is greater + # than the override verdict's priority value), then we don't need to override it. + return false if SUPPORTED_VERDICTS[verdict][:priority] > SUPPORTED_VERDICTS[OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM][:priority] + + target.allow_possible_spam? + end + def spamcheck_client @spamcheck_client ||= Gitlab::Spamcheck::Client.new end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1f1edad7a69..9db39a5e174 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,12 +49,12 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_contacts(added_count, removed_count) end - def relate_issue(noteable, noteable_ref, user) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref) + def relate_issuable(noteable, noteable_ref, user) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issuable(noteable_ref) end - def unrelate_issue(noteable, noteable_ref, user) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).unrelate_issue(noteable_ref) + def unrelate_issuable(noteable, noteable_ref, user) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).unrelate_issuable(noteable_ref) end # Called when the due_date of a Noteable is changed diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 09f36bb6501..89212288a6b 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -10,8 +10,9 @@ module SystemNotes # "marked this issue as related to gitlab-foss#9001" # # Returns the created Note object - def relate_issue(noteable_ref) - body = "marked this issue as related to #{noteable_ref.to_reference(noteable.project)}" + def relate_issuable(noteable_ref) + issuable_type = noteable.to_ability_name.humanize(capitalize: false) + body = "marked this #{issuable_type} as related to #{noteable_ref.to_reference(noteable.resource_parent)}" issue_activity_counter.track_issue_related_action(author: author) if noteable.is_a?(Issue) @@ -26,8 +27,8 @@ module SystemNotes # "removed the relation with gitlab-foss#9001" # # Returns the created Note object - def unrelate_issue(noteable_ref) - body = "removed the relation with #{noteable_ref.to_reference(noteable.project)}" + def unrelate_issuable(noteable_ref) + body = "removed the relation with #{noteable_ref.to_reference(noteable.resource_parent)}" issue_activity_counter.track_issue_unrelated_action(author: author) if noteable.is_a?(Issue) @@ -160,6 +161,7 @@ module SystemNotes body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" issue_activity_counter.track_issue_title_changed_action(author: author) if noteable.is_a?(Issue) + work_item_activity_counter.track_work_item_title_changed_action(author: author) if noteable.is_a?(WorkItem) create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end @@ -484,6 +486,10 @@ module SystemNotes Gitlab::UsageDataCounters::IssueActivityUniqueCounter end + def work_item_activity_counter + Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter + end + def track_cross_reference_action issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue) end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 091f441831a..64309c7f786 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -9,6 +9,7 @@ # class TodoService include Gitlab::Utils::UsageData + # When create an issue we should: # # * create a todo for assignee if issue is assigned @@ -229,8 +230,24 @@ class TodoService return if users.empty? - users_with_pending_todos = pending_todos(users, attributes).distinct_user_ids - users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) } + users_single_todos, users_multiple_todos = users.partition { |u| Feature.disabled?(:multiple_todos, u) } + excluded_user_ids = [] + + if users_single_todos.present? + excluded_user_ids += pending_todos( + users_single_todos, + attributes.slice(:project_id, :target_id, :target_type, :commit_id, :discussion) + ).distinct_user_ids + end + + if users_multiple_todos.present? && !Todo::ACTIONS_MULTIPLE_ALLOWED.include?(attributes.fetch(:action)) + excluded_user_ids += pending_todos( + users_multiple_todos, + attributes.slice(:project_id, :target_id, :target_type, :commit_id, :discussion, :action) + ).distinct_user_ids + end + + users.reject! { |user| excluded_user_ids.include?(user.id) } todos = users.map do |user| issue_type = attributes.delete(:issue_type) diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 575614e8743..604b83f621f 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -66,20 +66,20 @@ module Users # rubocop: disable CodeReuse/ActiveRecord def migrate_issues - user.issues.update_all(author_id: ghost_user.id) - Issue.where(last_edited_by_id: user.id).update_all(last_edited_by_id: ghost_user.id) + batched_migrate(Issue, :author_id) + batched_migrate(Issue, :last_edited_by_id) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def migrate_merge_requests - user.merge_requests.update_all(author_id: ghost_user.id) - MergeRequest.where(merge_user_id: user.id).update_all(merge_user_id: ghost_user.id) + batched_migrate(MergeRequest, :author_id) + batched_migrate(MergeRequest, :merge_user_id) end # rubocop: enable CodeReuse/ActiveRecord def migrate_notes - user.notes.update_all(author_id: ghost_user.id) + batched_migrate(Note, :author_id) end def migrate_abuse_reports @@ -96,8 +96,17 @@ module Users end def migrate_reviews - user.reviews.update_all(author_id: ghost_user.id) + batched_migrate(Review, :author_id) end + + # rubocop:disable CodeReuse/ActiveRecord + def batched_migrate(base_scope, column) + loop do + update_count = base_scope.where(column => user.id).limit(100).update_all(column => ghost_user.id) + break if update_count == 0 + end + end + # rubocop:enable CodeReuse/ActiveRecord end end diff --git a/app/services/users/saved_replies/create_service.rb b/app/services/users/saved_replies/create_service.rb new file mode 100644 index 00000000000..21378ec4435 --- /dev/null +++ b/app/services/users/saved_replies/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Users + module SavedReplies + class CreateService + def initialize(current_user:, name:, content:) + @current_user = current_user + @name = name + @content = content + end + + def execute + saved_reply = saved_replies.build(name: name, content: content) + + if saved_reply.save + ServiceResponse.success(payload: { saved_reply: saved_reply }) + else + ServiceResponse.error(message: saved_reply.errors.full_messages) + end + end + + private + + attr_reader :current_user, :name, :content + + delegate :saved_replies, to: :current_user + end + end +end diff --git a/app/services/users/saved_replies/update_service.rb b/app/services/users/saved_replies/update_service.rb new file mode 100644 index 00000000000..ab0a3eaf87d --- /dev/null +++ b/app/services/users/saved_replies/update_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Users + module SavedReplies + class UpdateService + def initialize(current_user:, saved_reply:, name:, content:) + @current_user = current_user + @saved_reply = saved_reply + @name = name + @content = content + end + + def execute + if saved_reply.update(name: name, content: content) + ServiceResponse.success(payload: { saved_reply: saved_reply.reset }) + else + ServiceResponse.error(message: saved_reply.errors.full_messages) + end + end + + private + + attr_reader :current_user, :saved_reply, :name, :content + end + end +end diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index 6e58e15f093..0ee7c41469f 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -2,34 +2,86 @@ module WebHooks class LogExecutionService + include ::Gitlab::ExclusiveLeaseHelpers + + LOCK_TTL = 15.seconds.freeze + LOCK_SLEEP = 0.25.seconds.freeze + LOCK_RETRY = 65 + attr_reader :hook, :log_data, :response_category def initialize(hook:, log_data:, response_category:) @hook = hook - @log_data = log_data + @log_data = log_data.transform_keys(&:to_sym) @response_category = response_category + @prev_state = hook.active_state(ignore_flag: true) end def execute - update_hook_executability + update_hook_failure_state log_execution end private def log_execution - WebHookLog.create!(web_hook: hook, **log_data.transform_keys(&:to_sym)) + WebHookLog.create!(web_hook: hook, **log_data) end - def update_hook_executability - case response_category - when :ok - hook.enable! - when :error - hook.backoff! - when :failed - hook.failed! + # Perform this operation within an `Gitlab::ExclusiveLease` lock to make it + # safe to be called concurrently from different workers. + def update_hook_failure_state + in_lock(lock_name, ttl: LOCK_TTL, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRY) do |retried| + hook.reset # Reload within the lock so properties are guaranteed to be current. + + case response_category + when :ok + hook.enable! + when :error + hook.backoff! + when :failed + hook.failed! + end + + log_state_change end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + raise if raise_lock_error? + end + + def log_state_change + new_state = hook.active_state(ignore_flag: true) + + return if @prev_state == new_state + + Gitlab::AuthLogger.info( + message: 'WebHook change active_state', + # identification + hook_id: hook.id, + hook_type: hook.type, + project_id: hook.project_id, + group_id: hook.group_id, + # relevant data + prev_state: @prev_state, + new_state: new_state, + duration: log_data[:execution_duration], + response_status: log_data[:response_status], + recent_hook_failures: hook.recent_failures, + # context + **Gitlab::ApplicationContext.current + ) + end + + def lock_name + "web_hooks:update_hook_failure_state:#{hook.id}" + end + + # Allow an error to be raised after failing to obtain a lease only if the hook + # is not already in the correct failure state. + def raise_lock_error? + hook.reset # Reload so properties are guaranteed to be current. + + hook.executable? != (response_category == :ok) 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 new file mode 100644 index 00000000000..534d220a846 --- /dev/null +++ b/app/services/work_items/create_and_link_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module WorkItems + # Create and link operations are not run inside a transaction in this class + # because CreateFromTaskService also creates a transaction. + # 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:, current_user: nil, params: {}, spam_params:, link_params: {}) + @create_service = CreateService.new( + project: project, + current_user: current_user, + params: params, + spam_params: spam_params + ) + @project = project + @current_user = current_user + @link_params = link_params + end + + def execute + create_result = @create_service.execute + return create_result if create_result.error? + + work_item = create_result[:work_item] + return ::ServiceResponse.success(payload: payload(work_item)) if @link_params.blank? + + result = IssueLinks::CreateService.new(work_item, @current_user, @link_params).execute + + if result[:status] == :success + ::ServiceResponse.success(payload: payload(work_item)) + else + ::ServiceResponse.error(message: result[:message], http_status: 404) + end + end + + private + + def payload(work_item) + { work_item: work_item } + end + end +end diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb new file mode 100644 index 00000000000..4203c96e676 --- /dev/null +++ b/app/services/work_items/create_from_task_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module WorkItems + class CreateFromTaskService + def initialize(work_item:, current_user: nil, work_item_params: {}, spam_params:) + @work_item = work_item + @current_user = current_user + @work_item_params = work_item_params + @spam_params = spam_params + @errors = [] + end + + def execute + transaction_result = ApplicationRecord.transaction do + create_and_link_result = CreateAndLinkService.new( + project: @work_item.project, + current_user: @current_user, + params: @work_item_params.slice(:title, :work_item_type_id), + spam_params: @spam_params, + link_params: { target_issuable: @work_item } + ).execute + + if create_and_link_result.error? + @errors += create_and_link_result.errors + raise ActiveRecord::Rollback + end + + replacement_result = TaskListReferenceReplacementService.new( + work_item: @work_item, + work_item_reference: create_and_link_result[:work_item].to_reference, + line_number_start: @work_item_params[:line_number_start], + line_number_end: @work_item_params[:line_number_end], + title: @work_item_params[:title], + lock_version: @work_item_params[:lock_version] + ).execute + + if replacement_result.error? + @errors += replacement_result.errors + raise ActiveRecord::Rollback + end + + create_and_link_result + end + + return transaction_result if transaction_result + + ::ServiceResponse.error(message: @errors, http_status: 422) + end + end +end diff --git a/app/services/work_items/task_list_reference_replacement_service.rb b/app/services/work_items/task_list_reference_replacement_service.rb new file mode 100644 index 00000000000..1044a4feb88 --- /dev/null +++ b/app/services/work_items/task_list_reference_replacement_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module WorkItems + class TaskListReferenceReplacementService + STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version' + + def initialize(work_item:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:) + @work_item = work_item + @work_item_reference = work_item_reference + @line_number_start = line_number_start + @line_number_end = line_number_end + @title = title + @lock_version = lock_version + end + + def execute + return ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE) if @work_item.lock_version > @lock_version + return ::ServiceResponse.error(message: 'line_number_start must be greater than 0') if @line_number_start < 1 + return ::ServiceResponse.error(message: 'line_number_end must be greater or equal to line_number_start') if @line_number_end < @line_number_start + return ::ServiceResponse.error(message: "Work item description can't be blank") if @work_item.description.blank? + + source_lines = @work_item.description.split("\n") + markdown_task_first_line = source_lines[@line_number_start - 1] + task_line = Taskable::ITEM_PATTERN.match(markdown_task_first_line) + + return ::ServiceResponse.error(message: "Unable to detect a task on line #{@line_number_start}") unless task_line + + captures = task_line.captures + + markdown_task_first_line.sub!(Taskable::ITEM_PATTERN, "#{captures[0]} #{captures[1]} #{@work_item_reference}+") + + source_lines[@line_number_start - 1] = markdown_task_first_line + remove_additional_lines!(source_lines) + + @work_item.update!(description: source_lines.join("\n")) + + ::ServiceResponse.success + rescue ActiveRecord::StaleObjectError + ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE) + end + + private + + def remove_additional_lines!(source_lines) + return if @line_number_end <= @line_number_start + + source_lines.delete_if.each_with_index do |_line, index| + index >= @line_number_start && index < @line_number_end + end + end + end +end diff --git a/app/uploaders/content_type_whitelist.rb b/app/uploaders/content_type_whitelist.rb index 64bde16cb69..82c6b9b3a61 100644 --- a/app/uploaders/content_type_whitelist.rb +++ b/app/uploaders/content_type_whitelist.rb @@ -30,7 +30,7 @@ module ContentTypeWhitelist content_type = mime_magic_content_type(new_file.path) unless whitelisted_content_type?(content_type) - message = I18n.translate(:"errors.messages.content_type_whitelist_error", allowed_types: Array(content_type_whitelist).join(", ")) + message = I18n.t(:"errors.messages.content_type_whitelist_error", allowed_types: Array(content_type_whitelist).join(", ")) raise CarrierWave::IntegrityError, message end end diff --git a/app/validators/color_validator.rb b/app/validators/color_validator.rb index 974dfbbf394..d108e4c5426 100644 --- a/app/validators/color_validator.rb +++ b/app/validators/color_validator.rb @@ -12,11 +12,13 @@ # end # class ColorValidator < ActiveModel::EachValidator - PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze - def validate_each(record, attribute, value) - unless value =~ PATTERN - record.errors.add(attribute, "must be a valid color code") + case value + when NilClass then return + when ::Gitlab::Color then return if value.valid? + when ::String then return if ::Gitlab::Color.new(value).valid? end + + record.errors.add(attribute, "must be a valid color code") end end diff --git a/app/validators/import/gitlab_projects/remote_file_validator.rb b/app/validators/import/gitlab_projects/remote_file_validator.rb new file mode 100644 index 00000000000..67bf102e928 --- /dev/null +++ b/app/validators/import/gitlab_projects/remote_file_validator.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Import + module GitlabProjects + # Validates the given object's #content_type and #content_length accordingly + # with the Project Import requirements + class RemoteFileValidator < ActiveModel::Validator + FILE_SIZE_LIMIT = 10.gigabytes + ALLOWED_CONTENT_TYPES = [ + 'application/gzip', + # S3 uses different file types + 'application/x-tar', + 'application/x-gzip' + ].freeze + + def validate(record) + validate_content_length(record) + validate_content_type(record) + end + + private + + def validate_content_length(record) + if record.content_length.to_i <= 0 + record.errors.add(:content_length, :size_too_small, file_size: humanize(1.byte)) + elsif record.content_length > FILE_SIZE_LIMIT + record.errors.add(:content_length, :size_too_big, file_size: humanize(FILE_SIZE_LIMIT)) + end + end + + def humanize(number) + ActiveSupport::NumberHelper.number_to_human_size(number) + end + + def validate_content_type(record) + return if ALLOWED_CONTENT_TYPES.include?(record.content_type) + + record.errors.add(:content_type, "'%{content_type}' not allowed. (Allowed: %{allowed})" % { + content_type: record.content_type, + allowed: ALLOWED_CONTENT_TYPES.join(', ') + }) + end + end + end +end diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json index 20be49f9eae..19258ee7677 100644 --- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json +++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json @@ -2,8 +2,8 @@ "$schema": "http://json-schema.org/draft-07/schema#", "global": [ { - "field" : "SECURE_ANALYZERS_PREFIX", - "label" : "Image prefix", + "field": "SECURE_ANALYZERS_PREFIX", + "label": "Image prefix", "type": "string", "default_value": "", "value": "", diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index dbfc7bf1046..00e5650b551 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -25,9 +25,9 @@ %td - if user = link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true), - data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-block btn-danger js-remove-tr" + data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger" }, aria: { label: _('Remove user & report') }, remote: true, method: :delete, class: "gl-button btn btn-block btn-danger js-remove-tr" - if user && !user.blocked? - = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-block" + = link_to _('Block user'), block_admin_user_path(user), data: { confirm: _('USER WILL BE BLOCKED! Are you sure?') }, aria: { label: _('Block user') }, method: :put, class: "gl-button btn btn-default btn-block" - else .gl-button.btn.btn-default.disabled.btn-block = _('Already blocked') diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_default_branch.html.haml index 8832bc02056..f5f45d7a6e9 100644 --- a/app/views/admin/application_settings/_initial_branch_name.html.haml +++ b/app/views/admin/application_settings/_default_branch.html.haml @@ -1,13 +1,17 @@ -= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value}</code>" %fieldset .form-group - = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light' + = f.label :default_branch_name, _('Initial default branch name'), class: 'label-light' = f.text_field :default_branch_name, placeholder: Gitlab::DefaultBranch.value, class: 'form-control gl-form-input' %span.form-text.text-muted = (s_("AdminSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name } ).html_safe + = render 'shared/default_branch_protection', f: f + + = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f + = f.submit _('Save changes'), class: 'gl-button btn-confirm' diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index c83e28d7f0b..d9c0a01beb0 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -22,15 +22,15 @@ = f.label :eks_account_id, _('Account ID'), class: 'label-bold' = f.text_field :eks_account_id, class: 'form-control gl-form-input' .form-group - = f.label :eks_access_key_id, _('Access key ID'), class: 'label-bold' + = f.label :eks_access_key_id, _('AWS access key ID (Optional)'), class: 'label-bold' = f.text_field :eks_access_key_id, class: 'form-control gl-form-input' .form-text.text-muted - = _('AWS Access Key. Only required if not using role instance credentials') + = _('Only required if not using role instance credentials.') .form-group - = f.label :eks_secret_access_key, _('Secret access key'), class: 'label-bold' + = f.label :eks_secret_access_key, _('AWS secret access key (Optional)'), class: 'label-bold' = f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control gl-form-input' .form-text.text-muted - = _('AWS Secret Access Key. Only required if not using role instance credentials') + = _('Only required if not using role instance credentials.') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index 08befa59952..11830fac336 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -13,7 +13,7 @@ - unless Gitlab::Metrics.metrics_folder_present? .form-text.text-muted %strong.cred= _("WARNING:") - = _("Environment variable %{code_start}%{environment_variable}%{code_end} does not exist or is not pointing to a valid directory.").html_safe % { environment_variable: prometheus_multiproc_dir, code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } + = _("Environment variable %{environment_variable} does not exist or is not pointing to a valid directory.").html_safe % { environment_variable: '<code>prometheus_multiproc_dir</code>'.html_safe } = link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') .form-group = f.label :metrics_method_call_threshold, _('Method call threshold (ms)'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index b55c2f05300..364a7cf5a8e 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -30,5 +30,13 @@ = f.number_field :container_registry_cleanup_tags_service_max_list_size, min: 0, class: 'form-control' .form-text.text-muted = _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.") + .form-group + .form-check + = f.check_box :container_registry_expiration_policies_caching, class: 'form-check-input' + = f.label :container_registry_expiration_policies_caching, class: 'form-check-label' do + = _("Enable container expiration caching.") + .form-text.text-muted + = _("When enabled, cleanup polices execute faster but put more load on Redis.") + = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml new file mode 100644 index 00000000000..945c9397f0d --- /dev/null +++ b/app/views/admin/application_settings/_search_limits.html.haml @@ -0,0 +1,16 @@ += form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :search_rate_limit, _('Maximum number of requests per minute for an authenticated user'), class: 'label-bold' + .form-text.gl-text-gray-600 + = _("Set this number to 0 to disable the limit.") + = f.number_field :search_rate_limit, class: 'form-control gl-form-input' + + .form-group + = f.label :search_rate_limit_unauthenticated, _('Maximum number of requests per minute for an unauthenticated IP address'), class: 'label-bold' + = f.number_field :search_rate_limit_unauthenticated, class: 'form-control gl-form-input' + + + = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 156e7d3fb76..bce210d28d3 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -8,17 +8,17 @@ = f.label :password_authentication_enabled_for_web, class: 'form-check-label' do = _('Allow password authentication for the web interface') .form-text.text-muted - = _('When inactive, an external authentication provider must be used.') + = _('Clear this checkbox to use an external authentication provider instead.') .form-group .form-check = f.check_box :password_authentication_enabled_for_git, class: 'form-check-input' = f.label :password_authentication_enabled_for_git, class: 'form-check-label' do = _('Allow password authentication for Git over HTTP(S)') .form-text.text-muted - When inactive, a Personal Access Token - if Gitlab::Auth::Ldap::Config.enabled? - or LDAP password - must be used to authenticate. + = _('Clear this checkbox to use a personal access token or LDAP password instead.') + - else + = _('Clear this checkbox to use a personal access token instead.') - if omniauth_enabled? && button_based_providers.any? %fieldset.form-group %legend.gl-font-base.gl-mb-3.gl-border-none.gl-font-weight-bold= _('Enabled OAuth authentication sources') diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index b92cf7b156a..65b2a95bcc1 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -12,7 +12,7 @@ - link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe = s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end } %span - = link_to s_('SourcegraphAdmin|More information'), help_page_path('integration/sourcegraph.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('SourcegraphAdmin|Learn more.'), help_page_path('integration/sourcegraph.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -29,10 +29,10 @@ = f.check_box :sourcegraph_public_only, class: 'form-check-input' = f.label :sourcegraph_public_only, s_('SourcegraphAdmin|Block on private and internal projects'), class: 'form-check-label' .form-text.text-muted - = s_('SourcegraphAdmin|If checked, only public projects will have code intelligence and communicate with Sourcegraph.') + = s_('SourcegraphAdmin|Only public projects have code intelligence enabled and communicate with Sourcegraph.') .form-group = f.label :sourcegraph_url, s_('SourcegraphAdmin|Sourcegraph URL'), class: 'label-bold' - = f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com') + = f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|https://sourcegraph.example.com') .form-text.text-muted = s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.') = f.submit s_('SourcegraphAdmin|Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 326aae26d5e..02031880fab 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -27,7 +27,7 @@ %p.mb-2= s_('%{service_ping_link_start}What information is shared with GitLab Inc.?%{service_ping_link_end}').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe } %button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } } - .gl-spinner.js-spinner.gl-display-none.gl-mr-2 + = gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2') .js-text.gl-display-inline= _('Preview payload') %pre.service-data-payload-container.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } - else diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index e56c898b236..b0810d3d48a 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -2,9 +2,6 @@ = form_errors(@application_setting) %fieldset - = render 'shared/default_branch_protection', f: f - = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f - = render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection') = render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f = render_if_exists 'admin/application_settings/default_delayed_project_deletion_setting', form: f diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 0f7f0109a54..84c26da8772 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -16,7 +16,7 @@ = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to _('Remove header logo'), header_logos_admin_application_settings_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" + = link_to _('Remove header logo'), header_logos_admin_application_settings_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :header_logo_cache = f.file_field :header_logo, class: "", accept: 'image/*' @@ -35,7 +35,7 @@ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to _('Remove favicon'), favicon_admin_application_settings_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" + = link_to _('Remove favicon'), favicon_admin_application_settings_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :favicon_cache = f.file_field :favicon, class: '', accept: 'image/*' @@ -67,7 +67,7 @@ = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - if @appearance.persisted? %br - = link_to _('Remove logo'), logo_admin_application_settings_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" + = link_to _('Remove logo'), logo_admin_application_settings_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" %hr = f.hidden_field :logo_cache = f.file_field :logo, class: "", accept: 'image/*' diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index 18ec43407c3..762dba69e6a 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -39,7 +39,7 @@ .settings-content = render 'registry' -- if Feature.enabled?(:runner_registration_control) +- if Feature.enabled?(:runner_registration_control, default_enabled: :yaml) %section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 90183b028f0..ea35b7ab9c4 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -48,6 +48,17 @@ .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' } +%section.settings.as-search-limits.no-animate#js-search-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Search rate limits') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Set rate limits for searches performed by web or API requests.') + .settings-content + = render 'search_limits' + %section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index ac200002cd2..c3a39ddf86d 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -5,13 +5,13 @@ %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 - = _('Default initial branch name') + = _('Default branch') %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = s_('AdminSettings|The default name for the initial branch of new repositories created in the instance.') + = s_('AdminSettings|Set the initial name and protections for the default branch of new repositories created in the instance.') .settings-content - = render 'initial_branch_name' + = render 'default_branch' %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) } .settings-header diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml index d348ad507c2..16ec8014c5e 100644 --- a/app/views/admin/applications/_delete_form.html.haml +++ b/app/views/admin/applications/_delete_form.html.haml @@ -1,4 +1,5 @@ -- submit_btn_css ||= 'gl-button btn btn-danger btn-sm' -= form_tag admin_application_path(application) do - %input{ :name => "_method", :type => "hidden", :value => "delete" }/ - = submit_tag 'Destroy', class: submit_btn_css, data: { confirm: _('Are you sure?') } + +- submit_btn_css ||= 'gl-button btn btn-danger btn-sm js-application-delete-button' +%button{ class: submit_btn_css, data: { path: admin_application_path(application), name: application.name } } + = _('Destroy') + diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 28a7bd1820a..86a4ab00ba3 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -33,3 +33,5 @@ %td= render 'delete_form', application: application = paginate @applications, theme: 'gitlab' + +.js-application-delete-modal diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index b68c22b6942..3e698f0508c 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -16,7 +16,7 @@ - else = _('Your message here') -= form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| += gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| = form_errors(@broadcast_message) .form-group.row.mt-4 @@ -52,9 +52,16 @@ .col-sm-2.col-form-label.pt-0 = f.label :starts_at, _("Dismissable") .col-sm-10 - = f.check_box :dismissable - = f.label :dismissable do - = _('Allow users to dismiss the broadcast message') + = f.gitlab_ui_checkbox_component :dismissable, _('Allow users to dismiss the broadcast message') + - if Feature.enabled?(:role_targeted_broadcast_messages, default_enabled: :yaml) + .form-group.row + .col-sm-2.col-form-label + = f.label :target_access_levels, _('Target roles') + .col-sm-10 + - target_access_level_options.each do |human_access_level, access_level| + = f.gitlab_ui_checkbox_component :target_access_levels, human_access_level, checked_value: access_level, unchecked_value: false, checkbox_options: { multiple: true } + .form-text.text-muted + = _('The broadcast message displays only to users in projects and groups who have these roles.') .form-group.row.js-toggle-colors-container.toggle-colors.hide .col-sm-2.col-form-label = f.label :font, _("Font Color") diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index 3f07bea7840..54c2a9d5250 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -1,10 +1,11 @@ - breadcrumb_title _("Messages") - page_title _("Broadcast Messages") +- targeted_broadcast_messages_enabled = Feature.enabled?(:role_targeted_broadcast_messages, default_enabled: :yaml) %h3.page-title = _('Broadcast Messages') %p.light - = _('Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more.') + = _('Use banners and notifications to notify your users about scheduled maintenance, recent upgrades, and more.') = render 'form' @@ -19,8 +20,10 @@ %th= _('Preview') %th= _('Starts') %th= _('Ends') - %th= _(' Target Path') - %th= _(' Type') + - if targeted_broadcast_messages_enabled + %th= _('Target roles') + %th= _('Target Path') + %th= _('Type') %th %tbody - @broadcast_messages.each do |message| @@ -33,6 +36,9 @@ = message.starts_at %td = message.ends_at + - if targeted_broadcast_messages_enabled + %td + = target_access_levels_display(message.target_access_levels) %td = message.target_path %td diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml index 3aba91e8765..aced997bada 100644 --- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml +++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml @@ -4,7 +4,6 @@ title: s_('AdminArea|Get security updates from GitLab and stay up to date'), variant: :tip, alert_class: 'js-security-newsletter-callout', - is_contained: true, alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' }, close_button_data: { testid: 'close-security-newsletter-callout' } do .gl-alert-body diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 91a018121c0..0c3ce1f3fa4 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -27,12 +27,9 @@ - if @group.new_record? .form-group.row .offset-sm-2.col-sm-10 - .gl-alert.gl-alert- - .gl-alert-container - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - = render 'shared/group_tips' + = render 'shared/global_alert', dismissible: false do + .gl-alert-body + = render 'shared/group_tips' .form-actions = f.submit _('Create group'), class: "gl-button btn btn-confirm" = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel" diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index 459df5c8d85..bd63172a0ee 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -3,7 +3,7 @@ .form-group = form.label :url, _('URL'), class: 'label-bold' = form.text_field :url, class: 'form-control gl-form-input' - %p.form-text.text-muted= _('URL must be percent-encoded if neccessary.') + %p.form-text.text-muted= _('URL must be percent-encoded if necessary.') .form-group = form.label :token, _('Secret token'), class: 'label-bold' = form.text_field :token, class: 'form-control gl-form-input' diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml index b65fead49ab..55fd09ac203 100644 --- a/app/views/admin/runners/edit.html.haml +++ b/app/views/admin/runners/edit.html.haml @@ -25,15 +25,12 @@ - if project %tr %td - .gl-alert.gl-alert-danger - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - %strong - = project.full_name - .gl-alert-actions - = link_to _('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-confirm btn-md gl-button' + = render 'shared/global_alert', + variant: :danger, + dismissible: false, + title: project.full_name do + .gl-alert-actions + = link_to _('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-confirm btn-md gl-button' %table.table{ data: { testid: 'unassigned-projects' } } %thead diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 2bfe905fb9d..cd6df5f30f3 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -24,7 +24,7 @@ %td - if user = link_to _('Remove user'), admin_spam_log_path(spam_log, remove_user: true), - data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name } }, method: :delete, class: "gl-button btn btn-sm btn-danger" + data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') }, method: :delete, class: "gl-button btn btn-sm btn-danger" %td - if spam_log.submitted_as_ham? .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3 diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index 21a1d74a8c6..c40484ea494 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -27,7 +27,7 @@ = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90') = render 'shared/choose_avatar_button', f: f - if @topic.avatar? - = link_to _('Remove avatar'), admin_topic_avatar_path(@topic), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary gl-mt-2' + .js-remove-topic-avatar{ data: { path: admin_topic_avatar_path(@topic) } } - if @topic.new_record? .form-actions diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index ca14d898d79..e429a16d5ec 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -27,8 +27,6 @@ = render_if_exists 'admin/users/gma_user_badge' .gl-my-3.gl-display-flex.gl-flex-wrap.gl-my-n2.gl-mx-n2 - .gl-p-2 - #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) } - if @user != current_user .gl-p-2 - if impersonation_enabled? && @user.can?(:log_in) @@ -36,6 +34,8 @@ - if can_force_email_confirmation?(@user) %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: confirm_user_data(@user) } = _('Confirm user') + .gl-p-2 + #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) } = gl_tabs_nav do = gl_tab_link_to _("Account"), admin_user_path(@user) = gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user) diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 6fdf383d571..ad7ce57ebda 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -60,21 +60,12 @@ = hidden_field_tag :sort, @sort = sprite_icon('search', css_class: 'search-icon') = button_tag s_('AdminUsers|Search users') if Rails.env.test? - .dropdown.gl-ml-3 - = label_tag 'Sort by', nil, class: 'label-bold' - - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) - %ul.dropdown-menu.dropdown-menu-right - %li.dropdown-header - = s_('AdminUsers|Sort by') - %li - - users_sort_options_hash.each do |value, title| - = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do - = title + .dropdown.gl-sm-ml-3 + = label_tag s_('AdminUsers|Sort by') + = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { right: true } #js-admin-users-app{ data: admin_users_data_attributes(@users) } - .gl-spinner-container.gl-my-7 - %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } + = gl_loading_icon(size: 'lg', css_class: 'gl-my-7') = paginate_collection @users diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 3a7f7a241ac..483c767d029 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -1,23 +1,16 @@ - form_field = local_assigns.fetch(:form_field, nil) - variable = local_assigns.fetch(:variable, nil) -- only_key_value = local_assigns.fetch(:only_key_value, false) - id = variable&.id - variable_type = variable&.variable_type - key = variable&.key - value = variable&.value -- is_protected_default = ci_variable_protected_by_default? -- is_protected = ci_variable_protected?(variable, only_key_value) -- is_masked_default = false -- is_masked = ci_variable_masked?(variable, only_key_value) - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" - variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]" - key_input_name = "#{form_field}[variables_attributes][][key]" - value_input_name = "#{form_field}[variables_attributes][][secret_value]" -- protected_input_name = "#{form_field}[variables_attributes][][protected]" -- masked_input_name = "#{form_field}[variables_attributes][][masked]" %li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } .ci-variable-row-body.border-bottom @@ -40,25 +33,5 @@ %p.masking-validation-error.gl-field-error.hide = s_("CiVariables|Cannot use Masked Variable with current value") = link_to sprite_icon('question-o'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' - - unless only_key_value - .ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0 - .gl-mr-3 - = s_("CiVariable|Protected") - = render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do - %input{ type: "hidden", - class: 'js-ci-variable-input-protected js-project-feature-toggle-input', - name: protected_input_name, - value: is_protected, - data: { default: is_protected_default.to_s } } - .ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0 - .gl-mr-3 - = s_("CiVariable|Masked") - = render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do - %input{ type: "hidden", - class: 'js-ci-variable-input-masked js-project-feature-toggle-input', - name: masked_input_name, - value: is_masked, - data: { default: is_masked_default.to_s } } - = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable %button.gl-button.btn.btn-default.btn-icon.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } = sprite_icon('close') diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 1ca4f9c670e..6fb3f26ff4f 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -9,7 +9,6 @@ = render 'shared/global_alert', variant: :warning, alert_class: 'hidden js-cluster-api-unreachable', - is_contained: true, close_button_class: 'js-close' do .gl-alert-body = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') @@ -17,7 +16,6 @@ = render 'shared/global_alert', variant: :warning, alert_class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable', - is_contained: true, close_button_class: 'js-close' do .gl-alert-body = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml deleted file mode 100644 index e5e1b68225e..00000000000 --- a/app/views/clusters/clusters/_cluster_list.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- if !clusters.empty? - .top-area.adjust - .gl-display-block.gl-text-right.gl-my-4.gl-w-full - - if clusterable.can_add_cluster? - = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', data: { qa_selector: 'integrate_kubernetes_cluster_button' } - - else - %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2 - = s_("ClusterIntegration|Connect cluster with certificate") - -#js-clusters-list-app{ data: js_clusters_list_data(clusterable) } 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 9d249931a34..3a4632affdc 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,11 +1,10 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } } - .gl-alert-container - %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - %h4.gl-alert-title= s_('ClusterIntegration|Did you know?') - %p.gl-alert-body= 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 } - %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } - = s_("ClusterIntegration|Apply for credit") += render 'shared/global_alert', + title: s_('ClusterIntegration|Did you know?'), + alert_class: 'gcp-signup-offer', + alert_data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } do + .gl-alert-body + = 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 } + .gl-alert-actions + %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } + = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml index ed95744c11d..04c1f9b6e7a 100644 --- a/app/views/clusters/clusters/_multiple_clusters_message.html.haml +++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml @@ -2,5 +2,5 @@ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - help_link_end = '</a>'.html_safe -%p - = s_('ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{help_link_start}read this first%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end } +%p.gl-font-weight-bold + = s_('ClusterIntegration|Using AutoDevOps with multiple clusters? %{help_link_start}Read this first.%{help_link_end}').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end } diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml index 31add011bfa..bda774ee780 100644 --- a/app/views/clusters/clusters/_sidebar.html.haml +++ b/app/views/clusters/clusters/_sidebar.html.haml @@ -1,5 +1,5 @@ -%h4.gl-mt-0 - = s_('ClusterIntegration|Add a Kubernetes cluster integration') +%h3 + = s_('ClusterIntegration|Connect a Kubernetes cluster') %p = clusterable.sidebar_text %p diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index c10983a5405..826dc749dad 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -3,10 +3,10 @@ - logo_path = local_assigns.fetch(:logo_path) - label = local_assigns.fetch(:label) - last = local_assigns.fetch(:last, false) -- classes = ["btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center w-50 js-create-#{provider}-cluster-button"] -- conditional_classes = [('mr-3' unless last), ('active' if is_current_provider)] +- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-w-half js-create-#{provider}-cluster-button"] +- conditional_classes = [('gl-mr-5' unless last), ('active' if is_current_provider)] = link_to clusterable.new_path(provider: provider), class: classes + conditional_classes do - .svg-content.p-2= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64' + .svg-content.gl-p-3= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64' %span = label diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index aee355bbf71..321fb854e0d 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -1,10 +1,10 @@ - gke_label = s_('ClusterIntegration|Google GKE') - eks_label = s_('ClusterIntegration|Amazon EKS') -- create_cluster_label = s_('ClusterIntegration|Create cluster on') -.d-flex.flex-column.p-3 - %h4.mb-3 +- create_cluster_label = s_('ClusterIntegration|Where do you want to create a cluster?') +.gl-p-5 + %h4.gl-mb-5 = create_cluster_label - .d-flex + .gl-display-flex = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', locals: { provider: 'aws', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' } = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', diff --git a/app/views/clusters/clusters/connect.html.haml b/app/views/clusters/clusters/connect.html.haml new file mode 100644 index 00000000000..1043f78bd3c --- /dev/null +++ b/app/views/clusters/clusters/connect.html.haml @@ -0,0 +1,11 @@ +- @content_class = 'limit-container-width' unless fluid_layout +- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path +- breadcrumb_title _('Connect a cluster') +- page_title _('Connect a Kubernetes Cluster') + +.row.gl-mt-3 + .col-md-3 + = render 'sidebar' + .col-md-9 + #js-cluster-new{ data: js_cluster_new } + = render 'clusters/clusters/user/form' diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index c8105fd1152..58b8e8b1003 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -67,20 +67,20 @@ label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank', rel: 'noopener noreferrer' .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' .form-group = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' .form-group.js-gke-cluster-creation-submit-container = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 457e34b306a..abe9cc9f27d 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -4,9 +4,5 @@ = render_gcp_signup_offer .clusters-container - - if display_cluster_agents?(clusterable) - .gl-my-6 - .js-clusters-main-view{ data: js_clusters_data(clusterable) } - - - else - = render 'cluster_list', clusters: @clusters + .gl-my-6 + .js-clusters-main-view{ data: js_clusters_list_data(clusterable) } diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index 7af7a812338..a184f412565 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -1,9 +1,8 @@ -- breadcrumb_title _('Kubernetes') -- page_title _('Kubernetes Cluster') +- @content_class = 'limit-container-width' unless fluid_layout +- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path +- breadcrumb_title _('Create a cluster') +- page_title _('Create a Kubernetes cluster') - provider = params[:provider] -- active_tab = params[:tab] || local_assigns.fetch(:active_tab, 'create') -- is_active_tab_create = active_tab === 'create' -- is_active_tab_add = active_tab === 'add' = render_gcp_signup_offer @@ -11,21 +10,8 @@ .col-md-3 = render 'sidebar' .col-md-9 - = gl_tabs_nav({ class: 'nav-justified' }) do - = gl_tab_link_to clusterable.new_path(tab: 'create'), { item_active: is_active_tab_create } do - %span= create_new_cluster_label(provider: params[:provider]) - = gl_tab_link_to s_('ClusterIntegration|Connect existing cluster'), clusterable.new_path(tab: 'add'), { item_active: is_active_tab_add, qa_selector: 'add_existing_cluster_tab' } + = render 'clusters/clusters/cloud_providers/cloud_provider_selector' - .tab-content - - if is_active_tab_create - .tab-pane.active{ role: 'tabpanel' } - = render 'clusters/clusters/cloud_providers/cloud_provider_selector' - - - if ['aws', 'gcp'].include?(provider) - .p-3.border-top - = render "clusters/clusters/#{provider}/new" - - - if is_active_tab_add - .tab-pane.active.gl-p-5{ role: 'tabpanel' } - #js-cluster-new{ data: js_cluster_new } - = render 'clusters/clusters/user/form' + - if ['aws', 'gcp'].include?(provider) + .gl-p-5.gl-border-1.gl-border-t-solid.gl-border-gray-100 + = render "clusters/clusters/#{provider}/new" diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 29af79cee5f..2dd15ebd266 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -1,6 +1,6 @@ -- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', +- more_info_link = link_to _('Learn more.'), help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'add-existing-cluster'), target: '_blank', rel: 'noopener noreferrer' -- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', +- rbac_help_link = link_to _('Learn more.'), help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'access-controls'), target: '_blank', rel: 'noopener noreferrer' - api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.') @@ -47,13 +47,13 @@ label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' .form-group = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| - if @user_cluster.allow_user_defined_namespace? diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index ec07c636b79..7c948260d4b 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -6,4 +6,4 @@ .content_list .loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md') diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index d5cd4b66e2b..601b6a8b1a7 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -1,4 +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' } } - .loading-container.text-center.prepend-top-20 - .gl-spinner.gl-spinner-md diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 2c6c721a51c..c932b416b66 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -50,12 +50,12 @@ .todo-actions.gl-ml-3 - if todo.pending? = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do + = gl_loading_icon(inline: true) Done - %span.gl-spinner.ml-1 = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do + = gl_loading_icon(inline: true) Undo - %span.gl-spinner.ml-1 - else = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do + = gl_loading_icon(inline: true) Add a to do - %span.gl-spinner.ml-1 diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index f6dc62e1d44..1d711f366c4 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,6 +2,7 @@ - page_title _("To-Do List") - header_title _("To-Do List"), dashboard_todos_path += render_two_factor_auth_recovery_settings_check = render_dashboard_ultimate_trial(current_user) - add_page_specific_style 'page_bundles/todos' @@ -22,11 +23,11 @@ - if @allowed_todos.any?(&:pending?) .gl-mr-3 = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'gl-button btn btn-default btn-loading align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do + = gl_loading_icon(inline: true) = s_("Todos|Mark all as done") - %span.gl-spinner.ml-1 = link_to bulk_restore_dashboard_todos_path, class: 'gl-button btn btn-default btn-loading align-items-center js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do + = gl_loading_icon(inline: true) = s_("Todos|Undo mark all as done") - %span.gl-spinner.ml-1 .todos-filters .issues-details-filters.row-content-block.second-block diff --git a/app/views/devise/shared/_email_opted_in.html.haml b/app/views/devise/shared/_email_opted_in.html.haml index 3817f9f651d..898b8f31f1d 100644 --- a/app/views/devise/shared/_email_opted_in.html.haml +++ b/app/views/devise/shared/_email_opted_in.html.haml @@ -1,4 +1,4 @@ -- return unless Gitlab.dev_env_or_com? +- return unless Gitlab.com? .gl-mb-3.js-email-opt-in.hidden .gl-font-weight-bold.gl-mb-3 diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml index 75d567a03fd..1c6dc1f2d5d 100644 --- a/app/views/devise/shared/_terms_of_service_notice.html.haml +++ b/app/views/devise/shared/_terms_of_service_notice.html.haml @@ -1,7 +1,7 @@ - return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms? %p.gl-text-gray-500.gl-mt-5.gl-mb-0 - - if Gitlab.dev_env_or_com? + - if Gitlab.com? = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } - else diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 075eb99fc36..477f6c73388 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -11,7 +11,8 @@ %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) } = sprite_icon('chevron-up', css_class: "js-sidebar-collapse #{'hidden' unless expanded}") = sprite_icon('chevron-down', css_class: "js-sidebar-expand #{'hidden' if expanded}") - = _('Toggle thread') + %span.js-sidebar-collapse{ class: "#{'hidden' unless expanded}" }= _('Hide thread') + %span.js-sidebar-expand{ class: "#{'hidden' if expanded}" }= _('Show thread') = link_to_member(@project, discussion.author, avatar: false) .inline.discussion-headline-light diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml index 0358fc524d3..bb2bd193565 100644 --- a/app/views/explore/groups/_groups.html.haml +++ b/app/views/explore/groups/_groups.html.haml @@ -1,4 +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' } } - .loading-container.text-center.prepend-top-20 - .gl-spinner.gl-spinner-md + = 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 2ead8fc2cfd..f02d30081b6 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -1,22 +1,9 @@ - has_label = local_assigns.fetch(:has_label, false) - feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) +- klass = feature_project_list_filter_bar ? 'gl-ml-3 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1' : 'gl-ml-3' +- selected = projects_filter_selected(params[:visibility_level]) - if current_user - .dropdown.js-project-filter-dropdown-wrap{ class: ('d-flex flex-grow-1 flex-shrink-1' if feature_project_list_filter_bar) } - %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } - - unless has_label - %span= _("Visibility:") - - if params[:visibility_level].present? - = visibility_level_label(params[:visibility_level].to_i) - - else - = _('Any') - = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') - %ul.dropdown-menu.dropdown-menu-right - %li - = link_to filter_projects_path(visibility_level: nil) do - = _('Any') - - Gitlab::VisibilityLevel.values.each do |level| - %li{ class: active_when(level.to_s == params[:visibility_level]) || 'light' } - = link_to filter_projects_path(visibility_level: level) do - = visibility_level_icon(level) - = visibility_level_label(level) + - unless has_label + %span.gl-float-left= _("Visibility:") + = gl_redirect_listbox_tag(projects_filter_items, selected, class: klass, data: { right: true }) diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 1695d3b5539..614d9610f31 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -6,4 +6,4 @@ .content_list .loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md') diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml index 959c26acae0..21107cc22a1 100644 --- a/app/views/groups/_archived_projects.html.haml +++ b/app/views/groups/_archived_projects.html.haml @@ -4,5 +4,4 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder - .loading-container.text-center.prepend-top-20 - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md', css_class: 'gl-mt-6') diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index 3b079ea00b7..5b9cd80799c 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -23,7 +23,8 @@ = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token' .gl-font-weight-normal - pat_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/profile/personal_access_tokens') } - = s_('GroupsNew|Navigate to user settings to find your %{link_start}personal access token%{link_end}.').html_safe % { link_start: pat_link_start, link_end: '</a>'.html_safe } + - short_living_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('security/token_overview', anchor: 'security-considerations') } + = s_('GroupsNew|Create this in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, use a short expiration date when creating the token.').html_safe % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe, short_living_link_start: short_living_link_start, short_living_link_end: '</a>'.html_safe } = f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8', required: true, autocomplete: 'off', diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index bfd056ccdd2..ef6410ad439 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -4,5 +4,4 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder - .loading-container.text-center.prepend-top-20 - .gl-spinner.gl-spinner-md + = gl_loading_icon diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index d1f56a50907..5c579cf6488 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,6 +1,5 @@ - add_page_specific_style 'page_bundles/members' - page_title _('Group members') -- groups_select_tag_data = group_select_data(@group).merge({ skip_groups: @skip_groups }) .row.gl-mt-3 .col-lg-12 @@ -11,28 +10,15 @@ = _('Group members') %p = html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - - if Feature.enabled?(:invite_members_group_modal, @group, default_enabled: :yaml) - .gl-w-half.gl-xs-w-full - .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3 - .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } - .js-invite-members-trigger{ data: { variant: 'success', - classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', - trigger_source: 'group-members-page', - display_text: _('Invite members') } } - = render 'groups/invite_groups_modal', group: @group - = render 'groups/invite_members_modal', group: @group - - if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml) - %hr.gl-mt-4 - %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } - %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _('Invite member') - %li.nav-tab{ role: 'presentation' } - %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _('Invite group') - .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render_invite_member_for_group(@group, @group_member.access_level) - .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } - = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access', groups_select_tag_data: groups_select_tag_data + .gl-w-half.gl-xs-w-full + .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3 + .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } + .js-invite-members-trigger{ data: { variant: 'confirm', + classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', + trigger_source: 'group-members-page', + display_text: _('Invite members') } } + = render 'groups/invite_groups_modal', group: @group + = render 'groups/invite_members_modal', group: @group = render_if_exists 'groups/group_members/ldap_sync' @@ -40,5 +26,4 @@ members: @members, invited: @invited_members, access_requests: @requesters).to_json } } - .loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml new file mode 100644 index 00000000000..1ee15557e21 --- /dev/null +++ b/app/views/groups/harbor/repositories/index.html.haml @@ -0,0 +1,9 @@ +- page_title _("Harbor Registry") +- @content_class = "limit-container-width" unless fluid_layout + +#js-harbor-registry-list-group{ data: { endpoint: group_harbor_registries_path(@group), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + connection_error: (!!@connection_error).to_s, + invalid_path_error: (!!@invalid_path_error).to_s, } } diff --git a/app/views/groups/imports/show.html.haml b/app/views/groups/imports/show.html.haml index 79cac364016..9cfb58da7e4 100644 --- a/app/views/groups/imports/show.html.haml +++ b/app/views/groups/imports/show.html.haml @@ -4,7 +4,7 @@ .save-group-loader .center %h2 - %i.loading.gl-spinner + = gl_loading_icon(size: 'md') = page_title %p = s_('GroupImport|Please wait while we import the group for you. Refresh at will.') diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index f6d05959d2e..6060d697f52 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -12,6 +12,7 @@ "registry_host_url_with_port" => escape_once(registry_config.host_port), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), + "container_registry_importing_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion'), "is_admin": current_user&.admin.to_s, is_group_page: "true", "group_path": @group.full_path, diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml index 78ce5b3e110..b2d8b9668e7 100644 --- a/app/views/groups/runners/_runner.html.haml +++ b/app/views/groups/runners/_runner.html.haml @@ -9,7 +9,7 @@ - if runner.locked? = gl_badge_tag s_('Runners|locked'), variant: :warning, size: :sm - unless runner.active? - = gl_badge_tag s_('Runners|paused'), variant: :danger, size: :sm + = gl_badge_tag s_('Runners|paused'), { variant: :danger, size: :sm }, { title: s_('Runners|Not accepting jobs'), data: { toggle: 'tooltip', container: 'body' } } .table-section.section-30 .table-mobile-header{ role: 'rowheader' }= s_('Runners|Runner') @@ -33,7 +33,7 @@ .table-mobile-header{ role: 'rowheader' }= _('Projects') .table-mobile-content - if runner.group_type? - = _('n/a') + \- - else = runner.runner_projects.count(:all) @@ -64,10 +64,10 @@ = sprite_icon('pencil', css_class: 'gl-icon') .btn-group - if runner.active? - = link_to pause_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = link_to pause_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon', title: s_('Runners|Pause from accepting jobs'), ref: 'tooltip', aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _('Are you sure?') } do = sprite_icon('pause', css_class: 'gl-icon') - else - = link_to resume_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do + = link_to resume_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon', title: s_('Runners|Resume accepting jobs'), ref: 'tooltip', aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body'} do = sprite_icon('play', css_class: 'gl-icon') - if runner.belongs_to_more_than_one_project? - delete_runner_tooltip = _('Multi-project Runners cannot be removed') diff --git a/app/views/groups/runners/_settings.html.haml b/app/views/groups/runners/_settings.html.haml index 55960703f9a..bbcadc08a8b 100644 --- a/app/views/groups/runners/_settings.html.haml +++ b/app/views/groups/runners/_settings.html.haml @@ -1,3 +1,17 @@ +- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml) + .gl-card.gl-px-8.gl-py-6.gl-line-height-20 + .gl-card-body.gl-display-flex{ :class => "gl-p-0!" } + .gl-banner-illustration + = image_tag('illustrations/rocket-launch-md.svg', alt: s_('Runners|Rocket launch illustration')) + .gl-banner-content + %h1.gl-banner-title + = s_('Runners|New group runners view') + %p + = s_('Runners|The new view gives you more space and better visibility into your fleet of runners.') + %a.btn.btn-confirm.btn-md.gl-button{ :href => group_runners_path(@group) } + %span.gl-button-text + = s_('Runners|Take me there!') + = render 'shared/runners/runner_description' %hr diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml index a0d7b8acb47..4a5bab94246 100644 --- a/app/views/groups/runners/edit.html.haml +++ b/app/views/groups/runners/edit.html.haml @@ -1,8 +1,14 @@ - breadcrumb_title _('Edit') - page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})" -- add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group) + +- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml) + - add_to_breadcrumbs _('Runners'), group_runners_path(@group) +- else + - add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group) + - add_to_breadcrumbs "#{@runner.short_sha}", group_runner_path(@group, @runner) + %h2.page-title = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) = render 'shared/runners/runner_type_badge', runner: @runner diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml index 5cf83e8ccfd..72701491c67 100644 --- a/app/views/groups/runners/show.html.haml +++ b/app/views/groups/runners/show.html.haml @@ -1,3 +1,6 @@ -- add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group) +- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml) + - add_to_breadcrumbs _('Runners'), group_runners_path(@group) +- else + - add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group) = render 'shared/runners/runner_details', runner: @runner diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index d4b74665398..dd62c9e118d 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -35,7 +35,6 @@ = render_if_exists 'groups/settings/ip_restriction', f: f, group: @group = render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group = render 'groups/settings/lfs', f: f - = render 'groups/settings/default_branch_protection', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group = render_if_exists 'groups/settings/prevent_forking', f: f, group: @group @@ -43,7 +42,7 @@ = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render 'groups/settings/membership', f: f, group: @group - - if crm_feature_flag_enabled?(@group) + - if crm_feature_available?(@group) %h5= _('Customer relations') .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :crm_enabled, diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index d52d9d59ab3..dde8213b293 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -16,5 +16,5 @@ .gl-alert.gl-alert-info.gl-mb-5 = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body - = html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } + = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } .js-transfer-group-form{ data: initial_data } diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml index 15a3bacf12d..f2644465a49 100644 --- a/app/views/groups/settings/repository/_initial_branch_name.html.haml +++ b/app/views/groups/settings/repository/_default_branch.html.haml @@ -1,22 +1,24 @@ %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _('Default initial branch name') + = _('Default branch') %button.gl-button.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = s_('GroupSettings|The default name for the initial branch of new repositories created in the group.') + = s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.') .settings-content - = form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| + = gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| = form_errors(@group) - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>" %fieldset .form-group - = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light' + = f.label :default_branch_name, _('Initial default branch name'), class: 'label-light' = f.text_field :default_branch_name, value: group.namespace_settings&.default_branch_name, placeholder: Gitlab::DefaultBranch.value(object: @group), class: 'form-control' %span.form-text.text-muted = (s_("GroupSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name }).html_safe - = f.hidden_field :redirect_target, value: "repository_settings" - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm' + = render 'groups/settings/default_branch_protection', f: f, group: @group + + = f.hidden_field :redirect_target, value: "repository_settings" + = f.submit _('Save changes'), class: 'btn gl-button btn-confirm' diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml index a5819320405..072c8c4d821 100644 --- a/app/views/groups/settings/repository/show.html.haml +++ b/app/views/groups/settings/repository/show.html.haml @@ -4,4 +4,4 @@ - deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.') = render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description -= render "initial_branch_name", group: @group += render "default_branch", group: @group diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index 755c4151115..95c15612adf 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -9,5 +9,5 @@ #ide.ide-loading{ data: ide_data } .text-center - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md') %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml index badd8c1278f..3e8a99c541a 100644 --- a/app/views/import/shared/_errors.html.haml +++ b/app/views/import/shared/_errors.html.haml @@ -1,8 +1,8 @@ - if @errors.present? - .gl-alert.gl-alert-danger.gl-mb-5 - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - - @errors.each do |error| - = error + = render 'shared/global_alert', + variant: :danger, + dismissible: false, + alert_class: 'gl-mb-5' do + .gl-alert-body + - @errors.each do |error| + = error diff --git a/app/views/jira_connect/oauth_callbacks/index.html.haml b/app/views/jira_connect/oauth_callbacks/index.html.haml new file mode 100644 index 00000000000..d35834bf05d --- /dev/null +++ b/app/views/jira_connect/oauth_callbacks/index.html.haml @@ -0,0 +1 @@ +%p= s_('Integrations|You can close this window.') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 5ca4a2f9888..15cd9bece71 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -38,6 +38,7 @@ = render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) } - if user_application_theme == 'gl-dark' + %meta{ name: 'color-scheme', content: 'dark light' } = stylesheet_link_tag_defer "application_dark" = yield :page_specific_styles = stylesheet_link_tag_defer "application_utilities_dark" @@ -56,7 +57,7 @@ = Gon::Base.render_data(nonce: content_security_policy_nonce) - = javascript_include_tag locale_path unless I18n.locale == :en + = render_if_exists 'layouts/header/translations' = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml new file mode 100644 index 00000000000..d2fe9a9a6ee --- /dev/null +++ b/app/views/layouts/_header_search.html.haml @@ -0,0 +1,24 @@ +#js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json, +'search-path' => search_path, +'issues-path' => issues_dashboard_path, +'mr-path' => merge_requests_dashboard_path, +'autocomplete-path' => search_autocomplete_path } } + = form_tag search_path, method: :get do |_f| + .gl-search-box-by-type + = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon') + %input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', autocomplete: 'off' } + + = hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group] + = hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project] + + - if header_search_context[:group] || header_search_context[:project] + = hidden_field_tag :scope, header_search_context[:scope] + = hidden_field_tag :search_code, header_search_context[:code_search] + + = hidden_field_tag :snippets, header_search_context[:for_snippets] + = hidden_field_tag :repository_ref, header_search_context[:ref] + = hidden_field_tag :nav_source, 'navbar' + + -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb + - if ENV['RAILS_ENV'] == 'test' + %noscript= button_tag 'Search' diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index b7299df1bc1..a656b61dc8f 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -3,23 +3,22 @@ = render "layouts/nav/sidebar/#{nav}" .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } .mobile-overlay - = render_if_exists 'layouts/header/verification_reminder' + = dispensable_render_if_exists 'layouts/header/verification_reminder' .alert-wrapper.gl-force-block-formatting-context - = render 'shared/outdated_browser' - = render_if_exists "layouts/header/licensed_user_count_threshold" - = render_if_exists "layouts/header/token_expiry_notification" - = render "layouts/broadcast" - = render "layouts/header/read_only_banner" - = render "layouts/header/registration_enabled_callout" - = render "layouts/nav/classification_level_banner" + = dispensable_render 'shared/outdated_browser' + = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold" + = dispensable_render_if_exists "layouts/header/token_expiry_notification" + = dispensable_render "layouts/broadcast" + = dispensable_render "layouts/header/read_only_banner" + = dispensable_render "layouts/header/registration_enabled_callout" + = dispensable_render "layouts/nav/classification_level_banner" = yield :flash_message - = render "shared/service_ping_consent" - = render_two_factor_auth_recovery_settings_check - = render_if_exists "layouts/header/ee_subscribable_banner" - = render_if_exists "layouts/header/seats_count_alert" - = render_if_exists "shared/namespace_storage_limit_alert" - = render_if_exists "shared/namespace_user_cap_reached_alert" - = render_if_exists "shared/new_user_signups_cap_reached_alert" + = dispensable_render "shared/service_ping_consent" + = dispensable_render_if_exists "layouts/header/ee_subscribable_banner" + = dispensable_render_if_exists "layouts/header/seats_count_alert" + = dispensable_render_if_exists "shared/namespace_storage_limit_alert" + = dispensable_render_if_exists "shared/namespace_user_cap_reached_alert" + = dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert" = yield :page_level_alert = yield :group_invite_members_banner - unless @hide_breadcrumbs diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 58fed89dfe7..940724e0e4a 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -6,6 +6,9 @@ - display_namespace_storage_limit_alert! - @left_sidebar = true +- content_for :flash_message do + = render "layouts/header/storage_enforcement_banner", namespace: @group + - content_for :page_specific_javascripts do - if current_user = javascript_tag do diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 871d1213c0e..512a4185bee 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -38,17 +38,10 @@ = render 'layouts/header/new_dropdown', class: 'gl-display-none gl-sm-display-block' - if top_nav_show_search - search_menu_item = top_nav_search_menu_item_attrs - %li.nav-item.d-none.d-lg-block.m-auto + %li.nav-item.header-search-new.d-none.d-lg-block.m-auto - unless current_controller?(:search) - if Feature.enabled?(:new_header_search) - #js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json, - 'search-path' => search_path, - 'issues-path' => issues_dashboard_path, - 'mr-path' => merge_requests_dashboard_path, - 'autocomplete-path' => search_autocomplete_path } } - .gl-search-box-by-type - = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon') - %input{ type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', id: 'search', autocomplete: 'off' } + = render 'layouts/header_search' - else = render 'layouts/search' %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' } @@ -68,7 +61,8 @@ = number_with_delimiter(issues_count) - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do - = link_to assigned_mrs_dashboard_path, class: 'dashboard-shortcuts-merge_requests', title: _('Merge requests'), aria: { label: _('Merge requests') }, + - top_level_link = Feature.enabled?(:mr_attention_requests, default_enabled: :yaml) ? attention_requested_mrs_dashboard_path : assigned_mrs_dashboard_path + = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests', title: _('Merge requests'), aria: { label: _('Merge requests') }, data: { qa_selector: 'merge_requests_shortcut_button', toggle: "dropdown", placement: 'bottom', @@ -84,6 +78,13 @@ %ul %li.dropdown-header = _('Merge requests') + - if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml) + %li#js-need-attention-nav + #js-need-attention-nav-onboarding + = link_to attention_requested_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do + = _('Need your attention') + = gl_badge_tag user_merge_requests_counts[:attention_requested_count], { size: :sm, variant: user_merge_requests_counts[:attention_requested_count] == 0 ? :neutral : :warning }, { class: 'merge-request-badge gl-ml-auto js-attention-count' } + %li.divider %li = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do = _('Assigned to you') diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml index 90f3ac61614..d1d23c86c81 100644 --- a/app/views/layouts/header/_registration_enabled_callout.html.haml +++ b/app/views/layouts/header/_registration_enabled_callout.html.haml @@ -1,14 +1,17 @@ - return unless show_registration_enabled_user_callout? = render 'shared/global_alert', - title: _('Open registration is enabled on your instance.'), + title: _('Anyone can register for an account.'), variant: :warning, alert_class: 'js-registration-enabled-callout', alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path }, close_button_data: { testid: 'close-registration-enabled-callout' } do .gl-alert-body - = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\" class=\"gl-link\">".html_safe, anchorClose: '</a>'.html_safe } + = _('Only allow anyone to register for accounts on GitLab instances that you intend to be used by anyone. Allowing anyone to register makes GitLab instances more vulnerable.') .gl-alert-actions = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-confirm btn-md gl-button' do %span.gl-button-text - = _('View setting') + = _('Turn off') + %button.btn.gl-alert-action.btn-default.btn-md.gl-button.js-close + %span.gl-button-text + = _('Acknowledge') diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml new file mode 100644 index 00000000000..851fc57e44d --- /dev/null +++ b/app/views/layouts/header/_storage_enforcement_banner.html.haml @@ -0,0 +1,9 @@ +- return unless current_user +- namespace = local_assigns.fetch(:namespace) +- banner_info = storage_enforcement_banner_info(namespace) +- return unless banner_info.present? + += render 'shared/global_alert', variant: :warning, alert_class: 'js-storage-enforcement-banner', alert_data: { feature_id: banner_info[:callouts_feature_name], dismiss_endpoint: banner_info[:callouts_path], group_id: namespace.id, defer_links: "true" } do + .gl-alert-body + = banner_info[:text] + = banner_info[:learn_more_link] diff --git a/app/views/layouts/header/_translations.html.haml b/app/views/layouts/header/_translations.html.haml new file mode 100644 index 00000000000..979f39ad3e0 --- /dev/null +++ b/app/views/layouts/header/_translations.html.haml @@ -0,0 +1 @@ += javascript_include_tag locale_path unless I18n.locale == :en diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index e922b505be8..3b979f69cac 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -3,7 +3,10 @@ %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } %title GitLab - = stylesheet_link_tag 'notify' + - if Feature.enabled?(:enhanced_notify_css) + = stylesheet_link_tag 'notify_enhanced' + - else + = stylesheet_link_tag 'notify' = yield :head %body .content diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml index 17153e72e6e..322a77116c8 100644 --- a/app/views/layouts/profile.html.haml +++ b/app/views/layouts/profile.html.haml @@ -5,4 +5,8 @@ - @left_sidebar = true - enable_search_settings locals: { container_class: 'gl-my-5' } + +- content_for :flash_message do + = render "layouts/header/storage_enforcement_banner", namespace: current_user.namespace + = render template: "layouts/application" diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml index 26d15a74403..a838ba91d26 100644 --- a/app/views/layouts/service_desk.html.haml +++ b/app/views/layouts/service_desk.html.haml @@ -5,7 +5,10 @@ %title GitLab -# haml-lint:enable NoPlainNodes - = stylesheet_link_tag 'notify' + - if Feature.enabled?(:enhanced_notify_css) + = stylesheet_link_tag 'notify_enhanced' + - else + = stylesheet_link_tag 'notify' = yield :head %body .content diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index ad0c873bf56..55984472047 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -25,11 +25,11 @@ = content_for :head do = stylesheet_link_tag 'mailers/highlighted_diff_email' - %table + %table.code = render partial: "projects/diffs/email_line", collection: discussion.truncated_diff_lines(diff_limit: diff_limit), as: :line, locals: { diff_file: discussion.diff_file } -%div{ style: note_style } +.md{ style: note_style } = markdown(note.note, pipeline: :email, author: note.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/access_token_created_email.html.haml b/app/views/notify/access_token_created_email.html.haml new file mode 100644 index 00000000000..9eea8f44142 --- /dev/null +++ b/app/views/notify/access_token_created_email.html.haml @@ -0,0 +1,7 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = html_escape(_('A new personal access token, named %{token_name}, has been created.')) % { token_name: @token_name } +%p + - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } + = html_escape(_('You can check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/access_token_created_email.text.erb b/app/views/notify/access_token_created_email.text.erb new file mode 100644 index 00000000000..caf01410de6 --- /dev/null +++ b/app/views/notify/access_token_created_email.text.erb @@ -0,0 +1,5 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('A new personal access token, named %{token_name}, has been created.') % { token_name: @token_name } %> + +<%= _('You can check it in your in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml index c9cd9c32b54..e512d7732e2 100644 --- a/app/views/notify/issue_due_email.html.haml +++ b/app/views/notify/issue_due_email.html.haml @@ -8,5 +8,5 @@ This issue is due on: #{@issue.due_date.to_s(:medium)} - if @issue.description - %div - = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true) + .md + = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index 439604a950a..592b3f453af 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -7,5 +7,5 @@ = assignees_label(@issue) - if @issue.description - %div - = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true) + .md + = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 54fb6573c26..f67ac5f8fb2 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -15,5 +15,5 @@ = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter - if @merge_request.description - %div + .md = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml index 1cd3a2340c6..09c0e7a8abd 100644 --- a/app/views/notify/new_release_email.html.haml +++ b/app/views/notify/new_release_email.html.haml @@ -1,7 +1,7 @@ - release_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } - description_details = { tag: @release.tag, name: @project.name, release_link_start: release_link_start, release_link_end: '</a>'.html_safe } -%div{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" } +.md{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" } %p = _("A new Release %{tag} for %{name} was published. Visit the %{release_link_start}Releases page%{release_link_end} to read more about it.").html_safe % description_details diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml index 186bdf133e3..0c16cf3315f 100644 --- a/app/views/notify/service_desk_new_note_email.html.haml +++ b/app/views/notify/service_desk_new_note_email.html.haml @@ -1,5 +1,5 @@ - if Gitlab::CurrentSettings.email_author_in_body %div = _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) } -%div +.md = markdown(@note.note, pipeline: :email, author: @note.author, issuable_reference_expansion_enabled: true) diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 97056db6b74..fdcee3670b7 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -5,7 +5,6 @@ = render 'shared/global_alert', variant: :info, alert_class: 'gl-my-5', - is_contained: true, dismissible: false do .gl-alert-body = s_('Profiles|Some options are unavailable for LDAP accounts') @@ -14,7 +13,6 @@ = render 'shared/global_alert', variant: :success, alert_class: 'gl-my-5', - is_contained: true, close_button_class: 'js-close-2fa-enabled-success-alert' do .gl-alert-body = 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 } diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml index 3206fca6bcd..8f80c9fdc6c 100644 --- a/app/views/profiles/chat_names/_chat_name.html.haml +++ b/app/views/profiles/chat_names/_chat_name.html.haml @@ -24,4 +24,4 @@ = _('Never') %td - = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') } + = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', aria: { label: _('Remove') }, data: { confirm: _('Are you sure you want to remove this nickname?'), confirm_btn_variant: 'danger' } diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index aae6212f964..5f8b21b2646 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -40,12 +40,10 @@ = _('Time based: Yes') = form_tag profile_two_factor_auth_path, method: :post do |f| - if @error - .gl-alert.gl-alert-danger.gl-mb-5 - .gl-alert-container - .gl-alert-content - %p.gl-alert-body.gl-md-0 - = @error[:message] - = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' + = render 'shared/global_alert', title: @error[:message], variant: :danger, dismissible: false do + .gl-alert-body + = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' + .form-group = label_tag :pin_code, _('Pin code'), class: "label-bold" = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } @@ -113,7 +111,7 @@ %span.gl-text-gray-500 = _("no name set") %td= registration[:created_at].to_date.to_s(:medium) - %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "gl-button btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') } + %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "gl-button btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.'), confirm_btn_variant: "danger" }, aria: { label: _('Delete') } - else .settings-message.text-center diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index c5a0b6a1428..05166395067 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -11,4 +11,4 @@ .content_list.project-activity{ :"data-href" => activity_project_path(@project) } .loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md') diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 2c18921d874..d987c4b1033 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -2,6 +2,6 @@ = button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-confirm js-commit-button qa-commit-button' = link_to _('Cancel'), cancel_path, - class: 'gl-button btn btn-default gl-ml-3', data: {confirm: leave_edit_message} + id: 'cancel-changes', class: 'gl-button btn btn-default gl-ml-3', data: {confirm: leave_edit_message, confirm_btn_variant: "danger"}, aria: { label: _('Discard changes') } = render 'shared/projects/edit_information' diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml index 21c799f5bb6..b713b805009 100644 --- a/app/views/projects/_deletion_failed.html.haml +++ b/app/views/projects/_deletion_failed.html.haml @@ -1,10 +1,7 @@ - project = local_assigns.fetch(:project) - return unless project.delete_error.present? -.project-deletion-failed-message.gl-alert.gl-alert-warning - .gl-alert-container - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - This project was scheduled for deletion, but failed with the following message: - = project.delete_error += render 'shared/global_alert', variant: :warning, dismissible: false, alert_class: 'project-deletion-failed-message' do + .gl-alert-body + This project was scheduled for deletion, but failed with the following message: + = project.delete_error diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 2f4a61865f8..a7cf50623f0 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -12,8 +12,7 @@ .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column #js-last-commit.gl-m-auto - .gl-spinner-container.m-auto - = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') + = gl_loading_icon(size: 'md') #js-code-owners - if is_project_overview diff --git a/app/views/projects/_gitlab_import_modal.html.haml b/app/views/projects/_gitlab_import_modal.html.haml deleted file mode 100644 index 689e100ab96..00000000000 --- a/app/views/projects/_gitlab_import_modal.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -#gitlab_import_modal.modal - .modal-dialog - .modal-content - .modal-header - %h3.modal-title Import projects from GitLab.com - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": "true" } × - .modal-body - To enable importing projects from GitLab.com, - - if current_user.admin? - as administrator you need to configure - - else - ask your GitLab administrator to configure - = link_to 'OAuth integration', help_page_path("integration/gitlab") diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index aca7b73267b..a8b809d1871 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -8,7 +8,7 @@ .import-buttons - if gitlab_project_import_enabled? .import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } } - = link_to new_import_gitlab_project_path, class: 'gl-button btn-default btn btn_import_gitlab_project js-import-project-btn', data: { platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } do + = link_to '#', class: 'gl-button btn-default btn btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } do .gl-button-icon = sprite_icon('tanuki') = _("GitLab export") @@ -36,12 +36,11 @@ %div - if gitlab_import_enabled? %div - = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'how_to_import_link' unless gitlab_import_configured?}", data: { platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do + = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}", + data: { modal_title: _("Import projects from GitLab.com"), modal_message: import_from_gitlab_message, platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do .gl-button-icon = sprite_icon('tanuki') = _("GitLab.com") - - unless gitlab_import_configured? - = render 'projects/gitlab_import_modal' - if fogbugz_import_enabled? %div diff --git a/app/views/projects/_invite_groups_modal.html.haml b/app/views/projects/_invite_groups_modal.html.haml index d16e87d1c26..40dc0009b24 100644 --- a/app/views/projects/_invite_groups_modal.html.haml +++ b/app/views/projects/_invite_groups_modal.html.haml @@ -1,3 +1,3 @@ -- return unless can_admin_project_member?(project) +- return unless can_invite_members_for_project?(project) .js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') } diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 9ba7d25b662..88cce9e71c0 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -1,9 +1,9 @@ - event = last_push_event - if event && show_last_push_widget?(event) - .gl-alert.gl-alert-success.mt-2{ role: 'alert' } - = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = render 'shared/global_alert', + variant: :success, + alert_class: 'gl-mt-3', + close_button_class: 'js-close-banner' do .gl-alert-body %span= s_("LastPushEvent|You pushed to") %strong.gl-display-inline-flex.gl-max-w-50p{ data: { toggle: 'tooltip' }, title: event.ref_name } 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 778586a592e..250f7e94e84 100644 --- a/app/views/projects/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/_merge_request_merge_method_settings.html.haml @@ -19,9 +19,9 @@ .text-secondary = s_('ProjectSettings|Every merge creates a merge commit.') %br - = s_('ProjectSettings|Fast-forward merges only.') + = s_('ProjectSettings|Merging is only allowed when the source branch is up-to-date with its target.') %br - = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.') + = s_('ProjectSettings|When semi-linear merge is not possible, the user is given the option to rebase.') .form-check.mb-2 = form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", 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 966587a9210..1fb045544aa 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -8,7 +8,7 @@ .form-group.project-name.col-sm-12 = f.label :name, class: 'label-bold' do %span= _("Project name") - = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } + = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } .form-group.project-path.col-sm-6 = f.label :namespace_id, class: 'label-bold' do %span= _('Project URL') @@ -29,14 +29,13 @@ .form-group.project-path.col-sm-6 = f.label :path, class: 'label-bold' do %span= _("Project slug") - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { username: current_user.username } + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_path', username: current_user.username } - if current_user.can_create_group? .form-text.text-muted - link_start_group_path = '<a href="%{path}">' % { path: new_group_path } - project_tip = s_('ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' } = project_tip.html_safe -.gl-alert.gl-alert-success.gl-mb-4.gl-display-none.js-user-readme-repo - = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') += render 'shared/global_alert', alert_class: "gl-mb-4 gl-display-none js-user-readme-repo", dismissible: false, variant: :success do .gl-alert-body - 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 } @@ -44,14 +43,15 @@ .form-group = f.label :description, class: 'label-bold' do = s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe } - = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" } + = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { qa_selector: 'project_description', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" } -.js-deployment-target-select +- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? + .js-deployment-target-select = f.label :visibility_level, class: 'label-bold' do = s_('ProjectsNew|Visibility Level') = link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer' -= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false += render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'} - if !hide_init_with_readme = f.label :project_configuration, class: 'label-bold' do @@ -74,5 +74,5 @@ - e.variant(:unchecked_free_indicator) do = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: false, with_free_badge: true -= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" } += f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" } = link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml index 68489fba06c..d00ed2afa3c 100644 --- a/app/views/projects/_project_templates.html.haml +++ b/app/views/projects/_project_templates.html.haml @@ -1,11 +1,10 @@ - f ||= local_assigns[:f] .project-templates-buttons - %ul.nav-tabs.nav-links.nav.scrolling-tabs - %li.built-in-tab - %a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} } - = _('Built-in') - = gl_tab_counter_badge Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count + = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do + = gl_tab_link_to '#built-in', tab_class: 'built-in-tab', class: 'active', data: { toggle: 'tab' } do + = _('Built-in') + = gl_tab_counter_badge Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count .tab-content .project-templates-buttons.import-buttons.tab-pane.active#built-in diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml index 229de2f759c..9e548582396 100644 --- a/app/views/projects/artifacts/_artifact.html.haml +++ b/app/views/projects/artifacts/_artifact.html.haml @@ -57,5 +57,5 @@ = sprite_icon('folder-open', css_class: 'gl-icon') - if can?(current_user, :destroy_artifacts, @project) - = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger btn-icon has-tooltip' do + = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?'), confirm_btn_variant: "danger" }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger btn-icon has-tooltip' do = sprite_icon('remove', css_class: 'gl-icon') diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 919cafe7ce8..85b9a69ab4c 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -21,8 +21,7 @@ project_path: @project.full_path, target_branch: project.empty_repo? ? ref : @ref, original_branch: @ref } } - .gl-spinner-container - = loading_icon(size: 'md') + = gl_loading_icon(size: 'md') - else %article.file-holder = render 'projects/blob/header', blob: blob diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 41333c416de..c9303e19d5d 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -45,5 +45,4 @@ - if local_assigns[:path] .js-edit-mode-pane#preview.hide .center - %h2 - %i.icon-spinner.icon-spin + = gl_loading_icon(size: 'lg') diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 74df53a8d15..8260aa0fb7e 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -5,13 +5,7 @@ .file-actions.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-md-justify-content-end< = render 'projects/blob/viewer_switcher', blob: blob unless blame - - if Feature.enabled?(:consolidated_edit_button, @project) - = render 'shared/web_ide_button', blob: blob - - else - = edit_blob_button(@project, @ref, @path, blob: blob) - = ide_edit_button(@project, @ref, @path, blob: blob) - - if can_view_pipeline_editor?(@project) && @path == @project.ci_config_path_or_default - = link_to "Pipeline Editor", project_ci_pipeline_editor_path(@project, branch_name: @ref), class: "btn gl-button btn-confirm-secondary gl-ml-3" + = render 'shared/web_ide_button', blob: blob .btn-group{ role: "group", class: ("gl-ml-3" if current_user) }> = render_if_exists 'projects/blob/header_file_locks_link' - if current_user diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index 6d2751bb7d4..1d3bec1ad44 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -20,8 +20,8 @@ = render 'shared/new_commit_form', placeholder: placeholder, ref: local_assigns[:ref] .form-actions - = button_tag class: 'btn gl-button btn-confirm btn-upload-file', id: 'submit-all', type: 'button' do - .gl-spinner.gl-mr-2.js-loading-icon.hidden + = button_tag class: 'btn gl-button btn-confirm btn-upload-file gl-mr-2', id: 'submit-all', type: 'button' do + = gl_loading_icon(inline: true, css_class: 'gl-mr-2 js-loading-icon hidden') = button_title = link_to _("Cancel"), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal" diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 8378ce2c7e5..773137ff3f2 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -4,14 +4,16 @@ - webpack_preload_asset_tag('monaco') - if @conflict - .gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5 - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - Someone edited the file the same time you did. Please check out - = link_to _('the file'), project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link' - and make sure your changes will not unintentionally remove theirs. + = render 'shared/global_alert', + alert_class: 'gl-mb-5 gl-mt-5', + variant: :danger, + dismissible: false do + - blob_url = project_blob_path(@project, @id) + - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do + - sprite_icon('external-link', css_class: 'gl-icon').html_safe + - blob_link_start = '<a href="%{url}" class="gl-link" target="_blank" rel="noopener noreferrer">'.html_safe % { url: blob_url } + = _('Someone edited the file the same time you did. Please check out %{link_start}the file %{icon}%{link_end} and make sure your changes will not unintentionally remove theirs.').html_safe % { link_start: blob_link_start, link_end: '</a>'.html_safe , icon: external_link_icon } + %h3.page-title.blob-edit-page-title Edit file diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 2aeffa88c8f..60877db581f 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,16 +1,13 @@ - breadcrumb_title _("Repository") - page_title _("New File"), @path.presence, @ref -%h3.page-title.blob-new-page-title#js-code-quality-walkthrough +%h3.page-title.blob-new-page-title = _('New file') - .js-code-quality-walkthrough{ data: { step: 'commit_ci_file' } } .file-editor = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do = render 'projects/blob/editor', ref: @ref = render 'shared/new_commit_form', placeholder: "Add new file" - - if params[:code_quality_walkthrough] - = hidden_field_tag 'code_quality_walkthrough', 'true' = hidden_field_tag 'content', '', id: 'file-content' = render 'projects/commit_button', ref: @ref, cancel_path: project_tree_path(@project, @id) diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml index cf57f1b531d..2b8f62d98bf 100644 --- a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml @@ -1,4 +1,4 @@ -= loading_icon(css_class: "gl-vertical-align-text-bottom mr-1") += gl_loading_icon(inline: true, css_class: "gl-mr-2!") = s_('Pipelines|Validating GitLab CI configuration…') = link_to _('Learn more'), help_page_path('ci/yaml/index') diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml index 18fd0d87ce6..9cb934da7c0 100644 --- a/app/views/projects/blob/viewers/_loading.html.haml +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -1,2 +1 @@ -.text-center.gl-mt-4.gl-mb-3 - = loading_icon(size: "md", css_class: "qa-spinner") += gl_loading_icon(size: "md", css_class: "qa-spinner gl-my-4") diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml index 5a2212e0b4e..19aa96a9302 100644 --- a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml +++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml @@ -1,2 +1,2 @@ -= loading_icon(css_class: "gl-vertical-align-text-bottom") += gl_loading_icon(inline: true) = _("Analyzing file…") diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml index db4b04eaeb8..5e355ecc4b8 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml @@ -1,4 +1,4 @@ -= loading_icon(css_class: "gl-vertical-align-text-bottom mr-1") += gl_loading_icon(inline: true, css_class: "mr-1") = _('Metrics Dashboard YAML definition') + '…' = link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml index c48ab84654f..d9e965246a8 100644 --- a/app/views/projects/blob/viewers/_route_map_loading.html.haml +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -1,4 +1,4 @@ -= loading_icon(css_class: "gl-vertical-align-text-bottom gl-mr-1") += gl_loading_icon(inline: true, css_class: "gl-mr-1") Validating Route Map… = link_to 'Learn more', help_page_path('ci/environments/index.md', anchor: 'go-from-source-files-to-public-pages') diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml index 08c21258d3f..4feaa7392fd 100644 --- a/app/views/projects/blob/viewers/_sketch.html.haml +++ b/app/views/projects/blob/viewers/_sketch.html.haml @@ -1,3 +1,2 @@ .file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } } - .text-center.gl-mt-4.gl-mb-3.js-loading-icon - = loading_icon(size: "md") + = gl_loading_icon(size: "md", css_class: "gl-my-4 js-loading-icon") diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml index f98deebacf9..8bf0339fc3c 100644 --- a/app/views/projects/blob/viewers/_stl.html.haml +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -1,6 +1,6 @@ .file-content.is-stl-loading .text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } } - = loading_icon(size: "md", css_class: "gl-mt-4 gl-mb-3") + = gl_loading_icon(size: "md", css_class: "gl-my-4") .text-center.gl-mt-3.gl-mb-3.stl-controls .btn-group %button.gl-button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } } diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 8ee7910de4b..5cc83111b34 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -4,8 +4,7 @@ - if @error = render 'shared/global_alert', variant: :danger, - close_button_class: 'js-close', - is_contained: true do + close_button_class: 'js-close' do .gl-alert-body = @error %h3.page-title diff --git a/app/views/projects/ci/secure_files/show.html.haml b/app/views/projects/ci/secure_files/show.html.haml new file mode 100644 index 00000000000..db0734be6bd --- /dev/null +++ b/app/views/projects/ci/secure_files/show.html.haml @@ -0,0 +1,5 @@ +- @content_class = "limit-container-width" + +- page_title s_('Secure Files') + +#js-ci-secure-files{ data: { project_id: @project.id } } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 36d3520cb59..a3343aa4228 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -37,13 +37,13 @@ - @commit.parents.each do |parent| = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha" .commit-info.branches - .gl-spinner.vertical-align-middle + = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle') .well-segment.merge-request-info .icon-container = custom_icon('mr_bold') %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) } - .gl-spinner.vertical-align-middle + = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle') - if can?(current_user, :read_pipeline, @last_pipeline) .well-segment.pipeline-info diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 9e0dd93c683..02b5fe00ad0 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -22,7 +22,7 @@ - if context_commits.present? %li.commit-header.js-commit-header %span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count - - if project.context_commits_enabled? && can_update_merge_request + - if can_update_merge_request %button.gl-button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } } = _('Add/remove') @@ -34,13 +34,14 @@ = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request } - if hidden > 0 - %li.gl-alert.gl-alert-warning - .gl-alert-container - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content + %li + = render 'shared/global_alert', + variant: :warning, + dismissible: false do + .gl-alert-body = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) -- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty? +- if can_update_merge_request && context_commits&.empty? %button.gl-button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } } = _('Add previously merged commits') diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 22a5bada311..36641a8c508 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -34,5 +34,4 @@ %div{ id: dom_id(@project) } %ol#commits-list.list-unstyled.content_list = render 'commits', project: @project, ref: @ref - .loading.hide - = loading_icon(size: "lg") + = gl_loading_icon(size: 'lg', css_class: 'loading hide') diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 718f129cba8..23f9afe8352 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -1,3 +1,12 @@ - diff_file = local_assigns.fetch(:diff_file, nil) +- file_hash = hexdigest(diff_file.file_path) + .diff-content - = render 'projects/diffs/viewer', viewer: diff_file.viewer + - if diff_file.has_renderable? + %div{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toHide' } } + = render 'projects/diffs/viewer', viewer: diff_file.viewer + %div{ id: "#rendered-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toShow' } } + = render 'projects/diffs/viewer', viewer: diff_file.rendered.viewer + - else + = render 'projects/diffs/viewer', viewer: diff_file.viewer + diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 418a65118f5..0638481d968 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -25,6 +25,11 @@ = edit_blob_button(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, blob: diff_file.blob, link_opts: link_opts) + - if diff_file.has_renderable? + .btn-group.gl-ml-3 + = diff_mode_swap_button('rendered', file_hash) + = diff_mode_swap_button('raw', file_hash) + - if image_diff && image_replaced = view_file_button(diff_file.old_content_sha, diff_file.old_path, project, replaced: true) diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 330e2f564c9..a5d3328b439 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -3,7 +3,7 @@ - plain = local_assigns.fetch(:plain, false) - discussions = local_assigns.fetch(:discussions, nil) - line_code = diff_file.line_code(line) -- if discussions && line.discussable? +- if discussions - line_discussions = discussions[line_code] %tr.line_holder{ class: line.type, id: (line_code unless plain) } @@ -15,11 +15,12 @@ %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" if !plain)], data: { linenumber: line.old_pos } } + %td.old_line.diff-line-num{ class: [line.type, ("js-avatar-container" unless plain)], data: { linenumber: line.old_pos } } - if plain = diff_link_number(line.type, "new", line.old_pos) - else - = add_diff_note_button(line_code, diff_file.position(line), line.type) + - if line.discussable? + = 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 } } diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index 1d9b1b13d5c..3d31773694f 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -1,7 +1,6 @@ = render 'shared/global_alert', title: _('Too many changes to show.'), variant: :warning, - is_contained: true, alert_class: 'gl-mb-5' do .gl-alert-body = html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f32514141c5..1609d81c0fd 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -66,6 +66,8 @@ %p= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') .settings-content + = render_if_exists 'projects/settings/restore', project: @project + .sub-section %h4= _('Housekeeping') %p @@ -107,6 +109,6 @@ .save-project-loader.hide .center %h2 - .gl-spinner.gl-spinner-md.align-text-bottom + = gl_loading_icon(inline: true, size: 'md', css_class: 'gl-vertical-align-middle') = _('Saving project.') %p= _('Please wait a moment, this page will automatically refresh when ready.') diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index c3fbf774faa..6a54eedf6c8 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,5 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout - default_branch_name = @project.default_branch_or_main +- escaped_default_branch_name = default_branch_name.shellescape - @skip_current_level_breadcrumb = true = render partial: 'flash_messages', locals: { project: @project } @@ -31,51 +32,47 @@ %p = _('You can also upload existing files from your computer using the instructions below.') .git-empty.js-git-empty - %fieldset - %h5= _('Git global setup') - %pre.bg-light - :preserve - git config --global user.name "#{h git_user_name}" - git config --global user.email "#{h git_user_email}" + %h5= _('Git global setup') + %pre.bg-light + :preserve + git config --global user.name "#{h git_user_name}" + git config --global user.email "#{h git_user_email}" - %fieldset - %h5= _('Create a new repository') - %pre.bg-light - :preserve - git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - cd #{h @project.path} - git switch -c #{h 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 default_branch_name } + %h5= _('Create a new repository') + %pre.bg-light + :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} + 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 } - %fieldset - %h5= _('Push an existing folder') - %pre.bg-light - :preserve - cd existing_folder - git init --initial-branch=#{h default_branch_name} - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - git add . - git commit -m "Initial commit" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin #{h default_branch_name } + %h5= _('Push an existing folder') + %pre.bg-light + :preserve + cd existing_folder + git init --initial-branch=#{h escaped_default_branch_name} + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + git add . + git commit -m "Initial commit" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin #{h escaped_default_branch_name } - %fieldset - %h5= _('Push an existing Git repository') - %pre.bg-light - :preserve - cd existing_repo - git remote rename origin old-origin - 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 + %h5= _('Push an existing Git repository') + %pre.bg-light + :preserve + cd existing_repo + git remote rename origin old-origin + 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 - if @project.upload_anchor_data.present? = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, default_branch_name), ref: default_branch_name, method: :post diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 2b05ffe3eea..e4b8750b96c 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -1,19 +1,11 @@ - page_title _("Environments") - add_page_specific_style 'page_bundles/environments' -- if Feature.enabled?(:new_environments_table) - #environments-table{ data: { endpoint: project_environments_path(@project, format: :json), - "can-read-environment" => can?(current_user, :read_environment, @project).to_s, - "can-create-environment" => can?(current_user, :create_environment, @project).to_s, - "new-environment-path" => new_project_environment_path(@project), - "help-page-path" => help_page_path("ci/environments/index.md"), - "project-path" => @project.full_path, - "default-branch-name" => @project.default_branch_or_main } } -- else - #environments-list-view{ data: { environments_data: environments_list_data, - "can-read-environment" => can?(current_user, :read_environment, @project).to_s, - "can-create-environment" => can?(current_user, :create_environment, @project).to_s, - "new-environment-path" => new_project_environment_path(@project), - "help-page-path" => help_page_path("ci/environments/index.md"), - "project-path" => @project.full_path, - "default-branch-name" => @project.default_branch_or_main } } +#environments-table{ data: { endpoint: project_environments_path(@project, format: :json), + "can-read-environment" => can?(current_user, :read_environment, @project).to_s, + "can-create-environment" => can?(current_user, :create_environment, @project).to_s, + "new-environment-path" => new_project_environment_path(@project), + "help-page-path" => help_page_path("ci/environments/index.md"), + "project-path" => @project.full_path, + "project-id" => @project.id, + "default-branch-name" => @project.default_branch_or_main } } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 194b10e9ef4..af5ad06d30e 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -23,5 +23,4 @@ = _('There are no matching files') %p.text-secondary = _('Try using a different search term to find the file you are looking for.') - .text-center.gl-mt-3.loading - = loading_icon(size: 'md') + = gl_loading_icon(size: 'md', css_class: 'gl-mt-3 loading') diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml deleted file mode 100644 index 84259890a44..00000000000 --- a/app/views/projects/forks/_fork_button.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -- avatar = namespace_icon(namespace, 100) -- can_create_project = current_user.can?(:create_projects, namespace) - -.bordered-box.fork-thumbnail.text-center.gl-m-3.gl-pb-5{ class: ("disabled" unless can_create_project) } - - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto") - - else - .avatar-container.s100.mx-auto.gl-mt-5 - = image_tag(avatar, class: "avatar s100") - %h5.gl-mt-3 - = namespace.human_name - - if forked_project = namespace.find_fork_of(@project) - = link_to _("Go to project"), project_path(forked_project), class: "btn gl-button btn-default" - - else - %div{ class: ('has-tooltip' unless can_create_project), - title: (_('You have reached your project limit') unless can_create_project) } - = link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id), - data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name }, - method: "POST", - class: ["btn gl-button btn-confirm", ("disabled" unless can_create_project)] diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml index 30e2e9f19d9..7933e0e07b3 100644 --- a/app/views/projects/forks/error.html.haml +++ b/app/views/projects/forks/error.html.haml @@ -4,7 +4,6 @@ title: _('Fork Error!'), variant: :danger, alert_class: 'gl-mt-5', - is_contained: true, dismissible: false do .gl-alert-body %p diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 8848fbae9cb..7243852e1f5 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -1,30 +1,13 @@ - page_title s_("ForkProject|Fork project") -- if Feature.enabled?(:fork_project_form, @project, default_enabled: :yaml) - #fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'), - endpoint: new_project_fork_path(@project, format: :json), - new_group_path: new_group_path, - project_full_path: project_path(@project), - visibility_help_path: help_page_path("public_access/public_access"), - project_id: @project.id, - project_name: @project.name, - project_path: @project.path, - project_description: @project.description, - project_visibility: @project.visibility, - restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } } -- else - .row.gl-mt-3 - .col-lg-3 - %h4.gl-mt-0 - = s_("ForkProject|Fork project") - %p - = s_("ForkProject|A fork is a copy of a project.") - %br - = s_('ForkProject|Forking a repository allows you to make changes without affecting the original project.') - .col-lg-9 - - if @own_namespace.present? - .fork-thumbnail-container.js-fork-content - %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 - = s_("ForkProject|Select a namespace to fork the project") - = render 'fork_button', namespace: @own_namespace - #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } } +#fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'), + endpoint: new_project_fork_path(@project, format: :json), + new_group_path: new_group_path, + project_full_path: project_path(@project), + visibility_help_path: help_page_path("public_access/public_access"), + project_id: @project.id, + project_name: @project.name, + project_path: @project.path, + project_description: @project.description, + project_visibility: @project.visibility, + restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } } diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml new file mode 100644 index 00000000000..3a6f8ca059d --- /dev/null +++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml @@ -0,0 +1,8 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title _('Regions') +- page_title _('Regions') + +- @content_class = "limit-container-width" unless fluid_layout + += form_tag project_google_cloud_gcp_regions_path(@project), method: 'post' do + #js-google-cloud{ data: @js_data } diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml new file mode 100644 index 00000000000..b3f5b91596d --- /dev/null +++ b/app/views/projects/harbor/repositories/index.html.haml @@ -0,0 +1,9 @@ +- page_title _("Harbor Registry") +- @content_class = "limit-container-width" unless fluid_layout + +#js-harbor-registry-list-project{ data: { endpoint: project_harbor_registry_index_path(@project), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + connection_error: (!!@connection_error).to_s, + invalid_path_error: (!!@invalid_path_error).to_s, } } diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml index 0c1efab2195..8096bc6cead 100644 --- a/app/views/projects/imports/show.html.haml +++ b/app/views/projects/imports/show.html.haml @@ -4,7 +4,7 @@ .save-project-loader .center %h2 - = loading_icon + = gl_loading_icon(inline: true) = import_in_progress_title - if !has_ci_cd_only_params? && @project.external_import? %p.monospace git clone --bare #{@project.safe_import_url} diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml index 662270fb8e1..26bd65fbe26 100644 --- a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml +++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml @@ -4,7 +4,6 @@ = render 'shared/global_alert', variant: :warning, - is_contained: true, close_button_class: 'js-close', alert_class: 'hide js-alert-moved-from-service-desk-warning gl-mt-5' do .gl-alert-body.gl-mr-3 diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index d85c448b29a..34e46807fb6 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,3 +1,3 @@ = form_for [@project, @issue], - html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit js-requires-input' } do |f| + html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit gl-show-field-errors' } do |f| = render 'shared/issuable/form', f: f, issuable: @issue diff --git a/app/views/projects/issues/_service_desk_empty_state.html.haml b/app/views/projects/issues/_service_desk_empty_state.html.haml index 3e0b80700fe..efc319ed8df 100644 --- a/app/views/projects/issues/_service_desk_empty_state.html.haml +++ b/app/views/projects/issues/_service_desk_empty_state.html.haml @@ -6,7 +6,7 @@ - if Gitlab::ServiceDesk.supported? .empty-state .svg-content - = render 'shared/empty_states/icons/service_desk_empty_state.svg' + = render partial: 'shared/empty_states/icons/service_desk_empty_state', formats: :svg .text-content %h4= title_text @@ -25,7 +25,7 @@ - else .empty-state .svg-content - = render 'shared/empty_states/icons/service_desk_setup.svg' + = render partial: 'shared/empty_states/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/index.html.haml b/app/views/projects/issues/index.html.haml index 10c48177ae4..d74b6c0639c 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -14,7 +14,7 @@ project_path: @project.full_path } } - if Feature.enabled?(:vue_issues_list, @project&.group, default_enabled: :yaml) - .js-issues-list{ data: project_issues_list_data(@project, current_user, finder) } + .js-issues-list{ data: project_issues_list_data(@project, current_user) } - if @can_bulk_update = render 'shared/issuable/bulk_update_sidebar', type: :issues - elsif project_issues(@project).exists? diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml index 9924b172875..0e950c26d34 100644 --- a/app/views/projects/learn_gitlab/index.html.haml +++ b/app/views/projects/learn_gitlab/index.html.haml @@ -5,8 +5,4 @@ = render 'projects/invite_members_modal', project: @project -- experiment(:confetti_post_signup, actor: current_user) do |e| - - e.control do - #js-learn-gitlab-app{ data: data } - - e.candidate do - #js-learn-gitlab-app{ data: data.merge(invite_members: 'true') } +#js-learn-gitlab-app{ data: data } diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml index ecf5df5d3b4..14ddaf8d2b7 100644 --- a/app/views/projects/merge_requests/_commits.html.haml +++ b/app/views/projects/merge_requests/_commits.html.haml @@ -5,7 +5,7 @@ = custom_icon ('illustration_no_commits') %h4 = _('There are no commits yet.') - - if @project&.context_commits_enabled? && can_update_merge_request + - if can_update_merge_request %p = _('Push commits to the source branch or add previously merged commits to review them.') %button.btn.gl-button.btn-confirm.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } } @@ -14,5 +14,5 @@ %ol#commits-list.list-unstyled = render "projects/commits/commits", merge_request: @merge_request -- if @project&.context_commits_enabled? && can_update_merge_request && @merge_request.iid +- if can_update_merge_request && @merge_request.iid .add-review-item-modal-wrapper{ data: { context_commits_path: context_commits_project_json_merge_request_url(@merge_request&.project, @merge_request, :json), target_branch: @merge_request.target_branch, merge_request_iid: @merge_request.iid, project_id: @merge_request.project.id } } diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index f2a271da771..d894aeaad65 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -6,12 +6,12 @@ = cache(cache_key, expires_in: 1.day) do - if @merge_request.closed_or_merged_without_fork? - .gl-alert.gl-alert-danger.gl-mb-5 - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-body - The source project of this merge request has been removed. + = render 'shared/global_alert', + alert_class: 'gl-mb-5', + variant: :danger, + dismissible: false do + .gl-alert-body + = _('The source project of this merge request has been removed.') .detail-page-header.border-bottom-0.pt-0.pb-0 .detail-page-header-body @@ -45,9 +45,9 @@ %li= link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - if can_update_merge_request - = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" } + = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "gl-display-none gl-md-display-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" } - if can_update_merge_request && !are_close_and_open_buttons_hidden = render 'projects/merge_requests/close_reopen_draft_report_toggle' - elsif !@merge_request.merged? - = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default float-right gl-ml-3', title: _('Report abuse') + = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default gl-float-right gl-ml-3', title: _('Report abuse') 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 ea778517374..e2ac8ef5abc 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -29,8 +29,7 @@ = dropdown_content = dropdown_loading .card-footer - .text-center - .js-source-loading.mt-1.gl-spinner + = gl_loading_icon(css_class: 'js-source-loading gl-my-3') %ul.list-unstyled.mr_source_commit .col-lg-6 @@ -58,8 +57,7 @@ = dropdown_content = dropdown_loading .card-footer - .text-center - .js-target-loading.mt-1.gl-spinner + = gl_loading_icon(css_class: 'js-target-loading gl-my-3') %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? 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 0036f1b4bde..253f50d5090 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -48,4 +48,4 @@ .mr-loading-status .loading.hide - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md') diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml index 28fd0b83824..aa68fe031bb 100644 --- a/app/views/projects/merge_requests/invalid.html.haml +++ b/app/views/projects/merge_requests/invalid.html.haml @@ -9,16 +9,15 @@ = render "projects/merge_requests/mr_title" = render "projects/merge_requests/mr_box" - .gl-alert.gl-alert-danger - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content{ role: 'alert' } - .gl-alert-body - - if @merge_request.for_fork? && !@merge_request.source_project - = err_fork_project_removed - - elsif !@merge_request.source_branch_exists? - = err_source_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.source_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) } - - elsif !@merge_request.target_branch_exists? - = err_target_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.target_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) } - - else - = err_internal + = render 'shared/global_alert', + variant: :danger, + dismissible: false do + .gl-alert-body + - if @merge_request.for_fork? && !@merge_request.source_project + = err_fork_project_removed + - elsif !@merge_request.source_branch_exists? + = err_source_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.source_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) } + - elsif !@merge_request.target_branch_exists? + = err_target_branch.html_safe % { branch_badge: gl_badge_tag(@merge_request.target_branch, variant: :info, size: :sm), path_badge: gl_badge_tag(@merge_request.source_project_path, variant: :info, size: :sm) } + - else + = err_internal diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index a7667d03138..008f2588dbd 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -76,14 +76,12 @@ = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do - if @number_of_pipelines.nonzero? = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) - - params = request.query_parameters - - if Feature.enabled?(:default_merge_ref_for_diffs, @project, default_enabled: :yaml) - - params = params.merge(diff_head: true) + - params = request.query_parameters.merge(diff_head: true) = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params) .mr-loading-status .loading.hide - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'lg') = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch @@ -94,5 +92,8 @@ #js-review-bar +- if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml) + #js-need-attention-sidebar-onboarding + = render 'projects/invite_members_modal', project: @project = render 'shared/web_ide_path' diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index dbde3346b81..225f8c7dd66 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -15,7 +15,6 @@ = render 'shared/global_alert', variant: :info, dismissible: false, - is_contained: true, alert_data: { testid: 'no-issues-alert' }, alert_class: 'gl-mt-3 gl-mb-5' do .gl-alert-body diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index b2fa735f76f..3af95633214 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -37,8 +37,9 @@ .panel-footer = f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror - else - .gl-alert.gl-alert-info{ role: 'alert' } - = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + = render 'shared/global_alert', + dismissible: false, + variant: :info do .gl-alert-body = _('Mirror settings are only available to GitLab administrators.') diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 4cabb930433..b6700c9ed1e 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -16,5 +16,4 @@ - 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 } } - .text-center.gl-mt-3 - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md', css_class: 'gl-mt-3') diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml index de74b703e95..24f51aa91e6 100644 --- a/app/views/projects/pages/_ssl_limitations_warning.html.haml +++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml @@ -2,6 +2,6 @@ = sprite_icon("warning-solid", css_class: "gl-text-orange-600") %strong= _("Warning:") - pages_host = Gitlab.config.pages.host - - docs_link_start = "<a href='#{help_page_path('user/project/pages/introduction', anchor: 'limitations')}' target='_blank' rel='noopener noreferrer'>".html_safe + - docs_link_start = "<a href='#{help_page_path('user/project/pages/introduction', anchor: 'subdomains-of-subdomains')}' target='_blank' rel='noopener noreferrer'>".html_safe - link_end = '</a>'.html_safe - = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS. %{docs_link_start}Learn more.%{link_end}").html_safe % { pages_host: pages_host, docs_link_start: docs_link_start, link_end: link_end } + = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with subdomains of subdomains. If your namespace or groupname contains a dot, it does not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages work if you don't redirect HTTP to HTTPS. %{docs_link_start}Learn more.%{link_end}").html_safe % { pages_host: pages_host, docs_link_start: docs_link_start, link_end: link_end } diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml index 33db7896065..861305dc93b 100644 --- a/app/views/projects/pages_domains/_certificate.html.haml +++ b/app/views/projects/pages_domains/_certificate.html.haml @@ -14,14 +14,13 @@ - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url } - lets_encrypt_link_end = "</a>".html_safe = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end } - %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button", - class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}", - "aria-label": _("Automatic certificate management using Let's Encrypt") } + = render Pajamas::ToggleComponent.new(id: 'pages_domain_auto_ssl_enabled_button', + classes: 'js-project-feature-toggle js-enable-ssl-gl-toggle mt-2', + is_checked: auto_ssl_available_and_enabled, + label: _("Automatic certificate management using Let's Encrypt"), + label_position: :hidden) = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" - %span.toggle-icon - = sprite_icon("status_success_borderless", size: 18, css_class: "gl-text-blue-500 toggle-status-checked") - = sprite_icon("status_failed_borderless", size: 18, css_class: "gl-text-gray-400 toggle-status-unchecked") - %p.text-secondary.mt-3 + %p.gl-text-secondary.gl-mt-1 - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } - docs_link_end = "</a>".html_safe diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 66aee7dedf3..0818c3d5cff 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -15,8 +15,9 @@ = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true .form-group.row .col-md-9 - = f.label :ref, _('Target Branch'), class: 'label-bold' - = dropdown_tag(_("Select target branch"), options: { toggle_class: 'gl-button btn btn-default js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) + = f.label :ref, Feature.enabled?(:pipeline_schedules_with_tags, default_enabled: :yaml) ? _('Target branch or tag') : _('Target branch'), class: 'label-bold' + %div{ data: { testid: 'schedule-target-ref' } } + .js-target-ref-dropdown{ data: { project_id: @project.id, default_branch: @project.default_branch } } = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true .form-group.row.js-ci-variable-list-section .col-md-9 @@ -24,8 +25,8 @@ #{ s_('PipelineSchedules|Variables') } %ul.ci-variable-list - @schedule.variables.each do |variable| - = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true - = render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true + = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable + = render 'ci/variables/variable_row', form_field: 'schedule' - if @schedule.variables.size > 0 %button.gl-button.btn.btn-confirm-secondary.gl-mt-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } } - if @schedule.variables.size == 0 diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 908de68f825..edcd44563f7 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -3,9 +3,12 @@ %td = pipeline_schedule.description %td.branch-name-cell - = sprite_icon('fork', size: 12) + - if pipeline_schedule.for_tag? + = sprite_icon('tag', size: 12) + - else + = sprite_icon('fork', size: 12) - if pipeline_schedule.ref.present? - = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" + = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name" %td - if pipeline_schedule.last_pipeline .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 4e93d7a04e7..54435f675a7 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -27,7 +27,7 @@ - if @pipeline.latest? = gl_badge_tag s_('Pipelines|latest'), { variant: :success, size: :sm }, { class: 'js-pipeline-url-latest has-tooltip', title: _("Latest pipeline for the most recent commit on this branch") } - if @pipeline.merge_train_pipeline? - = gl_badge_tag s_('Pipelines|train'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-train has-tooltip', title: _("This is a merge train pipeline") } + = gl_badge_tag s_('Pipelines|merge train'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-train has-tooltip', title: 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.") } - if @pipeline.has_yaml_errors? = gl_badge_tag s_('Pipelines|yaml invalid'), { variant: :danger, size: :sm }, { class: 'js-pipeline-url-yaml has-tooltip', title: @pipeline.yaml_errors } - if @pipeline.failure_reason? @@ -38,7 +38,7 @@ - popover_content_text = _('Learn more about Auto DevOps') = gl_badge_tag s_('Pipelines|Auto DevOps'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-autodevops', href: "#", tabindex: "0", role: "button", data: { container: 'body', toggle: 'popover', placement: 'top', html: 'true', triggers: 'focus', title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>" } } - if @pipeline.detached_merge_request_pipeline? - = gl_badge_tag s_('Pipelines|detached'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: _('Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.') } + = gl_badge_tag s_('Pipelines|merge request'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: s_("Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.") } - if @pipeline.stuck? = gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' } diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index e844a3d4779..88e6b98b115 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -29,20 +29,7 @@ #js-tab-builds.tab-pane - if stages.present? - - if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml) - #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } } - - else - .table-holder.pipeline-holder - %table.table.ci-table.pipeline - %thead - %tr - %th= _('Status') - %th= _('Name') - %th= _('Job ID') - %th - %th= _('Coverage') - %th - = render partial: "projects/stage/stage", collection: stages, as: :stage + #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } } - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane.build-page diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 547e2c8a7f4..5a655e7e83d 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -5,4 +5,5 @@ should_render_quality_summary: should_render_quality_summary.to_s, failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'), coverage_chart_path: charts_project_graph_path(@project, @project.default_branch), + test_runs_empty_state_image_path: image_path('illustrations/pipeline.svg'), default_branch: @project.default_branch } } diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index ae76d4905e0..f4b242ffc40 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -1,28 +1,10 @@ - page_title _('Pipelines') - add_page_specific_style 'page_bundles/pipelines' - add_page_specific_style 'page_bundles/ci_status' -- artifacts_endpoint_placeholder = ':pipeline_artifacts_id' = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -- list_url = project_pipelines_path(@project, format: :json, code_quality_walkthrough: params[:code_quality_walkthrough]) +- list_url = project_pipelines_path(@project, format: :json) - add_page_startup_api_call list_url -#pipelines-list-vue{ data: { endpoint: list_url, - project_id: @project.id, - params: params.to_json, - "artifacts-endpoint" => downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json), - "artifacts-endpoint-placeholder" => artifacts_endpoint_placeholder, - "pipeline-schedule-url" => pipeline_schedules_path(@project), - "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), - "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), - "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.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), - "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project), - "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, - "code-quality-page-path" => @project.present(current_user: current_user).add_code_quality_ci_yml_path, - "ci-runner-settings-path" => project_settings_ci_cd_path(@project, ci_runner_templates: true, anchor: 'js-runners-settings') } } +#pipelines-list-vue{ data: pipelines_list_data(@project, list_url) } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 70815dbe7a7..ba498352278 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -20,7 +20,7 @@ .bs-callout.bs-callout-danger %h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' } %ul - - @pipeline.yaml_errors.split(",").each do |error| + - @pipeline.yaml_errors.split("\n").each do |error| %li= error - lint_link_url = project_ci_pipeline_editor_path(@project, tab: "LINT_TAB") - lint_link_start = '<a href="%{url}" class="gl-text-blue-500!">'.html_safe % { url: lint_link_url } diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml deleted file mode 100644 index 2f953db0d65..00000000000 --- a/app/views/projects/project_members/import.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -- page_title _("Import members") - -%h3.page-title - = _("Import members from another project") -%p.light - = _("Only project members will be imported. Group members will be skipped.") -%hr -= form_tag apply_import_project_project_members_path(@project), method: 'post' do - .form-group.row - = label_tag :source_project_id, _("Project"), class: 'col-form-label col-sm-2' - .col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true) - - .form-actions - = button_tag _('Import project members'), class: "btn gl-button btn-success" - = link_to _("Cancel"), project_project_members_path(@project), class: "btn gl-button btn-cancel" diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 220e44679cd..f97b9a2b02f 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -23,7 +23,7 @@ .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } } = render 'projects/invite_groups_modal', project: @project - if can_admin_project_member?(@project) - .js-invite-members-trigger{ data: { variant: 'success', + .js-invite-members-trigger{ data: { variant: 'confirm', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', trigger_source: 'project-members-page', display_text: _('Invite members') } } @@ -39,51 +39,9 @@ %p = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } - - if Feature.disabled?(:invite_members_group_modal, @project.group, default_enabled: :yaml) && can?(current_user, :admin_project_member, @project) && project_can_be_shared? - - if !membership_locked? && @project.allowed_to_share_with_group? - %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } - %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") - %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) } - %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") - - .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'shared/members/invite_member', - submit_url: project_project_members_path(@project), - access_levels: ProjectMember.access_level_roles, - default_access_level: @project_member.access_level, - can_import_members?: can_admin_project_member?(@project), - import_path: import_project_project_members_path(@project) - .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } - = render 'shared/members/invite_group', - submit_url: project_group_links_path(@project), - access_levels: ProjectGroupLink.access_options, - default_access_level: ProjectGroupLink.default_access, - group_link_field: 'link_group_id', - group_access_field: 'link_group_access', - groups_select_tag_data: { min_access_level: Gitlab::Access::GUEST, skip_groups: @skip_groups } - - elsif !membership_locked? - .invite-member - = render 'shared/members/invite_member', - submit_url: project_project_members_path(@project), - access_levels: ProjectMember.access_level_roles, - default_access_level: @project_member.access_level, - can_import_members?: can_admin_project_member?(@project), - import_path: import_project_project_members_path(@project) - - elsif @project.allowed_to_share_with_group? - .invite-group - = render 'shared/members/invite_group', - access_levels: ProjectGroupLink.access_options, - default_access_level: ProjectGroupLink.default_access, - submit_url: project_group_links_path(@project), - group_link_field: 'link_group_id', - group_access_field: 'link_group_access', - groups_select_tag_data: { min_access_level: Gitlab::Access::GUEST, skip_groups: @skip_groups } .js-project-members-list-app{ data: { members_data: project_members_app_data_json(@project, members: @project_members, group_links: @group_links, invited: @invited_members, access_requests: @requesters) } } - .loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index 57fc9a16c0a..e5810930be2 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -24,8 +24,9 @@ .form-group.row = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-md-2 gl-text-left text-md-right' .col-md-10 - = render "shared/buttons/project_feature_toggle", class_list: "js-force-push-toggle project-feature-toggle" - .form-text.gl-text-gray-600.gl-mt-0 + = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle', + label: s_("ProtectedBranch|Allowed to force push"), + label_position: :hidden) do - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push') - 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 diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 3efe1fd2e82..aab5e9fca98 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -15,6 +15,7 @@ "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), + "container_registry_importing_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion'), "project_path": @project.full_path, "gid_prefix": container_repository_gid_prefix, "is_admin": current_user&.admin.to_s, diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index c25fd7a7587..8134ee8f417 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -28,7 +28,11 @@ = _('This group does not have any group runners yet.') - if can?(current_user, :admin_group_runners, @project.group) - - group_link = link_to _("group's CI/CD settings."), group_settings_ci_cd_path(@project.group) + - if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml) + - register_runners_path = group_runners_path(@project.group) + - else + - register_runners_path = group_settings_ci_cd_path(@project.group) + - group_link = link_to _("group's CI/CD settings."), register_runners_path = _('Group owners can register group runners in the %{link}').html_safe % { link: group_link } - else = _('Ask your group owner to set up a group runner.') diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 28e5618f8b0..5eaf6c9d22b 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -16,10 +16,10 @@ = link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-icon', title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' } do = sprite_icon('pencil') - if runner.active? - = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: _('Pause'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', placement: 'top', container: 'body', confirm: _("Are you sure?") } do + = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Pause from accepting jobs'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _("Are you sure?") } do = sprite_icon('pause') - else - = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: _('Resume'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', placement: 'top', container: 'body' } do + = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' } do = sprite_icon('play') - if runner.belongs_to_one_project? = link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger' diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index 1357846876e..3634bacb6ec 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -2,7 +2,7 @@ = _('Specific runners') .bs-callout.help-callout - - if valid_runner_registrars.include?('project') + - if can?(current_user, :register_project_runners, @project) = _('These runners are specific to this project.') - if params[:ci_runner_templates] %hr diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 65b93dc930a..7a47b504b7c 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,6 +1,17 @@ - if lookup_context.template_exists?('top', "projects/services/#{integration.to_param}", true) = render "projects/services/#{integration.to_param}/top", integration: integration +- if integration.activate_disabled_reason.present? && integration.activate_disabled_reason[:trackers].any? + -# When using integration.activate_disabled_reason[:trackers], it's potentially insecure to use the raw records + -# when passed directly to the frontend. Only use specific fields that are needed for render. + -# For example, we can get the link to each tracker with scoped_edit_integration_path(tracker, tracker.project) + = render 'shared/global_alert', + title: s_('ExternalIssueIntegration|Another issue tracker is already in use'), + variant: :warning, + dismissible: false do + .gl-alert-body + = s_('ExternalIssueIntegration|Only one issue tracker integration can be active at a time. Please disable the active tracker first and try again.') + %h3.page-title = integration.title - if integration.operating? diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml index 4586ee844c0..896249c6163 100644 --- a/app/views/projects/services/prometheus/_custom_metrics.html.haml +++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml @@ -18,7 +18,7 @@ .flash-text .loading-metrics.js-loading-custom-metrics %p.m-3 - = loading_icon(css_class: 'metrics-load-spinner') + = gl_loading_icon(inline: true, css_class: 'metrics-load-spinner') = s_('PrometheusService|Finding custom metrics...') .empty-metrics.hidden.js-empty-custom-metrics %p.text-tertiary.m-3.js-no-active-integration-text.hidden diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 0d41584652f..8794f3e24da 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -16,7 +16,7 @@ .card-body .loading-metrics.js-loading-metrics %p.m-3 - = loading_icon(css_class: 'metrics-load-spinner') + = gl_loading_icon(inline: true, css_class: 'metrics-load-spinner') = s_('PrometheusService|Finding and configuring metrics...') .empty-metrics.hidden.js-empty-metrics %p.text-tertiary.m-3 diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 960b1d67610..3a62c6f41cc 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -23,7 +23,7 @@ .row .form-group.col-md-9 = f.label :description, _('Project description (optional)'), class: 'label-bold' - = f.text_area :description, class: 'form-control gl-form-input', rows: 3, maxlength: 250 + = f.text_area :description, class: 'form-control gl-form-input', rows: 3 .row= render_if_exists 'projects/classification_policy_settings', f: f diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index c70e153ae41..66a1cbb4649 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -94,7 +94,7 @@ .input-group-text / %p.form-text.text-muted = html_escape(_('The regular expression used to find test coverage output in the job log. For example, use %{regex} for Simplecov (Ruby). Leave blank to disable.')) % { regex: '<code>\(\d+.\d+%\)</code>'.html_safe } - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'add-test-coverage-results-to-a-merge-request-deprecated'), target: '_blank', rel: 'noopener noreferrer' + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'add-test-coverage-results-using-project-settings-deprecated'), target: '_blank', rel: 'noopener noreferrer' = f.submit _('Save changes'), class: "btn gl-button btn-confirm", data: { qa_selector: 'save_general_pipelines_changes_button' } diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 07910899aa0..658b2f2e65c 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -9,7 +9,7 @@ older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), - enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s, + enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), show_cleanup_policy_on_alert: show_cleanup_policy_on_alert(@project).to_s, tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples') } } diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml deleted file mode 100644 index 387c8fb3234..00000000000 --- a/app/views/projects/stage/_stage.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -- stage = stage.present(current_user: current_user) - -%tr - %th{ colspan: 10 } - %strong - %a{ name: stage.name } - %span{ class: "ci-status-link ci-status-icon-#{stage.status}" } - = ci_icon_for_status(stage.status) - - = stage.name.titleize -= render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true -= render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true -%tr - %td{ colspan: 10 } - diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 9d082436aa7..ce036606a1c 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -33,5 +33,5 @@ = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do = sprite_icon('pencil') - if can?(current_user, :manage_trigger, trigger) - = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do + = link_to project_trigger_path(@project, trigger), aria: { label: _('Revoke') }, data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button', confirm_btn_variant: "danger" }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do = sprite_icon('remove') diff --git a/app/views/sandbox/mermaid.html.erb b/app/views/sandbox/mermaid.html.erb index 2d2391c8866..48c7baeaeed 100644 --- a/app/views/sandbox/mermaid.html.erb +++ b/app/views/sandbox/mermaid.html.erb @@ -2,6 +2,9 @@ <html> <head> <%= webpack_bundle_tag("sandboxed_mermaid") %> + <% if params[:darkMode] == 'true' %> + <meta name="color-scheme" content="dark light"> + <% end %> </head> <body> <div id="app"></div> diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml index de1fa9a7fd5..729eda331b5 100644 --- a/app/views/search/results/_blob_highlight.html.haml +++ b/app/views/search/results/_blob_highlight.html.haml @@ -10,7 +10,13 @@ .line-numbers .gl-display-flex %span.diff-line-num.gl-pl-3 - %a.has-tooltip{ href: "#{blame_link}#L#{i}", id: "blame-L#{i}", 'data-line-number' => i, title: _('View blame') } + %a.has-tooltip{ href: "#{blame_link}#L#{i}", + id: "blame-L#{i}", + data: { "line_number" => i, + "track_action" => 'click_link', + "track_label" => 'git_blame', + "track_property" => 'search_result' }, + title: _('View blame') } = sprite_icon('git') %span.diff-line-num.flex-grow-1.gl-pr-3 %a{ href: "#{blob_link}#L#{i}", id: "blob-L#{i}", 'data-line-number' => i, class: 'gl-display-flex! gl-align-items-center gl-justify-content-end' } diff --git a/app/views/shared/_default_branch_protection.html.haml b/app/views/shared/_default_branch_protection.html.haml index 7a6152f6d96..1a660f3f896 100644 --- a/app/views/shared/_default_branch_protection.html.haml +++ b/app/views/shared/_default_branch_protection.html.haml @@ -1,4 +1,4 @@ -%fieldset.form-group - %legend.h5.gl-border-none.gl-mt-0.gl-mb-3= _('Default branch protection') +.form-group + %legend.h5.gl-border-none.gl-mt-0.gl-mb-3= _('Initial default branch protection') - Gitlab::Access.protection_options.each do |option| = f.gitlab_ui_radio_component :default_branch_protection, option[:value], option[:label], help_text: option[:help_text] diff --git a/app/views/shared/_gl_toggle.html.haml b/app/views/shared/_gl_toggle.html.haml deleted file mode 100644 index afaa6b6df92..00000000000 --- a/app/views/shared/_gl_toggle.html.haml +++ /dev/null @@ -1,28 +0,0 @@ --# This partial renders a GlToggle root element. --# To actually initialize the component, make sure to call the initToggle helper from ~/toggles. - -- classes = local_assigns.fetch(:classes) -- name = local_assigns.fetch(:name, nil) -- is_checked = local_assigns.fetch(:is_checked, false).to_s -- disabled = local_assigns.fetch(:disabled, false).to_s -- is_loading = local_assigns.fetch(:is_loading, false).to_s -- label = local_assigns.fetch(:label, nil) -- help = local_assigns.fetch(:help, nil) -- label_position = local_assigns.fetch(:label_position, nil) -- data = local_assigns.fetch(:data, {}) - -%span{ class: classes, - data: { name: name, - is_checked: is_checked, - disabled: disabled, - is_loading: is_loading, - label: label, - help: help, - label_position: label_position, - **data } } - --# Leverage this block to render a rich help text. To render a plain text help text, --# prefer the `help` parameter. -- if yield.present? - .gl-text-secondary.gl-mt-1 - = yield diff --git a/app/views/shared/_global_alert.html.haml b/app/views/shared/_global_alert.html.haml index 1eaf21fc568..cb7ad32e474 100644 --- a/app/views/shared/_global_alert.html.haml +++ b/app/views/shared/_global_alert.html.haml @@ -8,16 +8,14 @@ - close_button_class = local_assigns.fetch(:close_button_class, nil) - close_button_data = local_assigns.fetch(:close_button_data, nil) - icon = icons[variant] -- alert_container_class = [container_class, @content_class] unless fluid_layout || local_assigns.fetch(:is_contained, false) %div{ role: 'alert', class: ['gl-alert', "gl-alert-#{variant}", alert_class], data: alert_data } - .gl-alert-container{ class: alert_container_class } - = sprite_icon(icon, size: 16, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}") - - if dismissible - %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button', aria: { label: _('Dismiss') }, class: close_button_class, data: close_button_data } - = sprite_icon('close', size: 16) - .gl-alert-content{ role: 'alert' } - - if title - %h4.gl-alert-title - = title - = yield + = sprite_icon(icon, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}") + - if dismissible + %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button', aria: { label: _('Dismiss') }, class: close_button_class, data: close_button_data } + = sprite_icon('close') + .gl-alert-content{ role: 'alert' } + - if title + %h4.gl-alert-title + = title + = yield diff --git a/app/views/shared/_logo_ukraine.svg b/app/views/shared/_logo_ukraine.svg new file mode 100644 index 00000000000..e2c2bb3855d --- /dev/null +++ b/app/views/shared/_logo_ukraine.svg @@ -0,0 +1,5 @@ +<svg width="24" height="24" class="tanuki-logo" viewBox="0 0 24 24"> + <path d="M4.89929534,0.3165 L7.56629534,8.5025 L16.3922953,8.5025 L19.0592953,0.3165 C19.1962953,-0.1055 19.8432953,-0.1055 19.9792953,0.3165 L23.9122953,12.6095 C23.9722953,12.7935 23.9722953,12.9895 23.9192953,13.1695 L0.0392953418,13.1695 C-0.0143874393,12.9863283 -0.0119492421,12.7912726 0.0462953418,12.6095 L3.97929534,0.3165 C4.11529534,-0.1055 4.76229534,-0.1055 4.89929534,0.3165 Z" id="Path" fill="#005BBB"></path> + <path d="M7.20329534,9.0025 L16.7552953,9.0025 L16.8682953,8.6575 L19.5182953,0.5185 L23.4362953,12.7615 C23.4961172,12.9376949 23.435535,13.1323657 23.2862953,13.2435 L23.2852953,13.2455 L11.9852953,21.4655 L11.9792953,21.4715 L0.673295342,13.2455 C0.522422013,13.1321007 0.462258936,12.9374792 0.522295342,12.7615 L4.43929534,0.5185 L7.09029534,8.6585 L7.20329534,9.0025 Z" id="Shape" stroke="#FFFFFF" opacity="0.32" stroke-linejoin="round"></path> + <path d="M0.0012953418,12.8575 C-0.0152229638,13.1685309 0.127095079,13.4667211 0.379295342,13.6495 L11.9792953,22.0895 L11.9862953,22.0845 L11.9922953,22.0895 L11.9872953,22.0835 L23.5792953,13.6495 C23.8319507,13.466647 23.9743476,13.1679148 23.9572953,12.8565 L0.0012953418,12.8565 L0.0012953418,12.8575 Z" id="Path" fill="#FFD500"></path> +</svg>
\ No newline at end of file diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index 08003346d09..74a397d7a03 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -1,7 +1,7 @@ - if any_projects?(@projects) .project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-relative.gl-overflow-hidden{ class: 'gl-display-flex!' } %a.btn.gl-button.btn-confirm.js-new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" } - = loading_icon(color: 'light') + = gl_loading_icon(inline: true, color: 'light') = project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled] %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0.gl-w-100{ class: "gl-m-0!", 'aria-label': _('Toggle project select') } = sprite_icon('chevron-down') diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml index 821d92e9d7e..9cdff35ead2 100644 --- a/app/views/shared/_service_ping_consent.html.haml +++ b/app/views/shared/_service_ping_consent.html.haml @@ -1,7 +1,6 @@ - if session[:ask_for_usage_stats_consent] = render 'shared/global_alert', variant: :info, - is_contained: true, alert_class: 'service-ping-consent-message' do .gl-alert-body - docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link' 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 e7239661313..f21acd26ada 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 @@ -1,6 +1,6 @@ = render 'shared/global_alert', variant: :warning, - alert_class: 'js-recovery-settings-callout', + alert_class: 'js-recovery-settings-callout gl-mt-5', alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: callouts_path, defer_links: 'true' }, close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do .gl-alert-body diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index a52b7236137..0b68cfe65e5 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -19,18 +19,14 @@ .row = f.label :name, _('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.') + %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 } .row - .form-group.col-md-6 - = f.label :expires_at, _('Expiration date'), class: 'label-bold' - .input-icon-wrapper - - = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime' - - .js-access-tokens-expires-at - = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } + .col + .js-access-tokens-expires-at{ data: expires_at_field_data } + = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } - if resource .row diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml index e02c24b93f1..60641006e96 100644 --- a/app/views/shared/blob/_markdown_buttons.html.haml +++ b/app/views/shared/blob/_markdown_buttons.html.haml @@ -9,6 +9,10 @@ data: { "md-tag" => "_", "md-shortcuts": '["mod+i"]' }, title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) }) + = markdown_toolbar_button({ icon: "strikethrough", + data: { "md-tag" => "~~", "md-shortcuts": '["mod+shift+x"]' }, + title: sprintf(s_("MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)") % { modifier_key: modifier_key }) }) + = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") }) = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") }) diff --git a/app/views/shared/buttons/_project_feature_toggle.html.haml b/app/views/shared/buttons/_project_feature_toggle.html.haml deleted file mode 100644 index 321fbee1b35..00000000000 --- a/app/views/shared/buttons/_project_feature_toggle.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- class_list ||= "js-project-feature-toggle project-feature-toggle" -- data ||= nil -- disabled ||= false -- is_checked ||= false -- label ||= nil - -%button{ type: 'button', - class: "#{class_list} #{'is-disabled' if disabled} #{'is-checked' if is_checked}", - "aria-label": label, - disabled: disabled, - data: data } - - if yield.present? - = yield - %span.toggle-icon - = sprite_icon('status_success_borderless', size: 18, css_class: 'gl-text-blue-500 toggle-status-checked') - = sprite_icon('status_failed_borderless', size: 18, css_class: 'gl-text-gray-400 toggle-status-unchecked') diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index db9c646b694..a7bf3bfb81e 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -25,7 +25,7 @@ %span.token-never-expires-label= _('Never') %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') %td - .js-deploy-token-revoke-button{ data: { button_class: 'float-right', token: token.to_json, revoke_path: revoke_deploy_token_path(group_or_project, token) } } + .js-deploy-token-revoke-button{ data: deploy_token_revoke_button_data(token: token, group_or_project: group_or_project) } - else .settings-message.text-center diff --git a/app/views/shared/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml index caa553bc2ef..7cce0652f6f 100644 --- a/app/views/shared/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml @@ -2,9 +2,9 @@ = form_tag path do %input{ :name => "_method", :type => "hidden", :value => "delete" } - if defined? small - = button_tag type: "submit", class: "gl-button btn btn-danger btn-icon", data: { confirm: _("Are you sure?") } do + = button_tag type: "submit", class: "gl-button btn btn-danger btn-icon", data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } do %span.sr-only = _('Destroy') = sprite_icon('remove') - else - = submit_tag _('Destroy'), data: { confirm: _("Are you sure?") }, class: submit_btn_css + = submit_tag _('Destroy'), data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Destroy') }, class: submit_btn_css diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml index 96a68cbcdc6..366d4585435 100644 --- a/app/views/shared/errors/_gitaly_unavailable.html.haml +++ b/app/views/shared/errors/_gitaly_unavailable.html.haml @@ -1,8 +1,7 @@ -.gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5 - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-title - = reason - .gl-alert-body - = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.') += render 'shared/global_alert', + alert_class: 'gl-my-5', + variant: :danger, + dismissible: false, + title: reason do + .gl-alert-body + = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.') diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index ae896b7348d..3f6e7a6fb32 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -9,7 +9,6 @@ = render 'shared/global_alert', variant: :danger, dismissible: false, - is_contained: true, alert_class: 'gl-mb-5' do .gl-alert-body Someone edited the #{issuable.class.model_name.human.downcase} the same time you did. @@ -20,7 +19,9 @@ = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form .form-group.row - = form.label :title, class: 'col-form-label col-sm-2' + = form.label :title, class: 'col-form-label col-sm-2' do + = _('Title') + %i{ aria: { hidden: true } }= '*' = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) #js-suggestions{ data: { project_path: @project.full_path } } diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index 84cdf129cb2..6a58acf8c05 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -6,10 +6,7 @@ .dropdown-page-two.dropdown-new-label = dropdown_title(create_label_title(subject), options: { back: true, close: show_close }) = dropdown_content do - .js-label-error.gl-alert.gl-alert-danger.gl-mb-3 - .gl-alert-container - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content + = render 'shared/global_alert', variant: :danger, alert_class: 'js-label-error gl-mb-3', dismissible: false %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } .suggest-colors.suggest-colors-dropdown = render_suggested_colors diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index b02c6b65359..37a79a50fb1 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -5,10 +5,6 @@ - placeholder = local_assigns[:placeholder] || _('Search or filter results...') - block_css_class = type != :productivity_analytics ? 'row-content-block second-block' : '' - is_epic_board = board&.to_type == "EpicBoard" -- if @group.present? - - ff_resource = @group -- else - - ff_resource = board&.resource_parent&.group - if is_epic_board - user_can_admin_list = can?(current_user, :admin_epic_board_list, board.resource_parent) @@ -31,7 +27,7 @@ = check_box_tag checkbox_id, nil, false, class: "check-all-issues left" - if is_epic_board #js-board-filtered-search{ data: { full_path: @group&.full_path } } - - elsif Feature.enabled?(:issue_boards_filtered_search, ff_resource, default_enabled: :yaml) && board + - elsif board #js-issue-board-filtered-search - else .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 7787e5dd660..37d31515307 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -27,7 +27,7 @@ - if issuable_sidebar[:supports_escalation] .block.escalation-status{ data: { testid: 'escalation_status_container' } } - #js-escalation-status{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } + #js-escalation-status{ data: { can_update: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } = render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar - if @project.group.present? @@ -41,7 +41,7 @@ .block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', 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 @show_crm_contacts + - if issuable_sidebar[:show_crm_contacts] .block.contact #js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id] } } @@ -50,7 +50,7 @@ // Fallback while content is loading .title.hide-collapsed = _('Time tracking') - = loading_icon(css_class: 'gl-vertical-align-text-bottom') + = gl_loading_icon(inline: true) - if issuable_sidebar.has_key?(:due_date) #js-due-date-entry-point @@ -109,8 +109,8 @@ = dropdown_loading = dropdown_footer add_content_class: true do %button.gl-button.btn.btn-confirm.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true } + = gl_loading_icon(inline: true, css_class: 'sidebar-move-issue-confirmation-loading-icon gl-mr-2') = _('Move') - = loading_icon(css_class: 'gl-vertical-align-text-bottom sidebar-move-issue-confirmation-loading-icon') -# haml-lint:disable InlineJavaScript %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 9a0b25f4015..2fd4c598580 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -7,7 +7,7 @@ directly_invite_members: can_admin_project_member?(@project) } } .title.hide-collapsed = _('Assignee') - = loading_icon(css_class: 'gl-vertical-align-text-bottom') + = gl_loading_icon(inline: true) .js-sidebar-assignee-data.selectbox.hide-collapsed - if assignees.none? diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index bc76d292dd6..ce252e74570 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -3,7 +3,7 @@ #js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } } .title.hide-collapsed = _('Reviewer') - = loading_icon(css_class: 'gl-vertical-align-text-bottom') + = gl_loading_icon(inline: true) .selectbox.hide-collapsed - if reviewers.none? diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9e42c528a11..34720576526 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -4,6 +4,16 @@ - has_due_date = issuable.has_attribute?(:due_date) - form = local_assigns.fetch(:form) +- if @add_related_issue + .form-group.row + .offset-sm-2.col-sm-10 + .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 + - 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 } + - if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable) .form-group.row .offset-sm-2.col-sm-10 diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 257ad7a8518..6b00cdc5e24 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -8,9 +8,9 @@ - add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe - remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe } ).html_safe -%div{ class: div_class } - = form.text_field :title, required: true, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto' +%div{ class: div_class, data: { testid: 'issue-title-input-field' } } + = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true, + autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto' - if issuable.respond_to?(:work_in_progress?) .form-text.text-muted diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index e5197acf06f..1babc6885c2 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -5,7 +5,7 @@ .detail-page-description.content-block #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, full_path: @project.full_path } } .title-container - %h2.title= markdown_field(issuable, :title) + %h1.title= markdown_field(issuable, :title) - if issuable.description.present? .description .md= markdown_field(issuable, :description) diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index cfc00bd41ca..bb582b159ba 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -1,9 +1,3 @@ -- sort_title = label_sort_options_hash[@sort] || sort_title_name_desc -.dropdown.inline - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - = sort_title - = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort - %li - - label_sort_options_hash.each do |value, title| - = sortable_item(title, page_filter_path(sort: value), sort_title) +- label_sort_options = label_sort_options_hash.map { |value, text| { value: value, text: text, href: page_filter_path(sort: value) } } + += gl_redirect_listbox_tag label_sort_options, @sort, data: { right: true } diff --git a/app/views/shared/members/_invite_group.html.haml b/app/views/shared/members/_invite_group.html.haml deleted file mode 100644 index cefdf825eaa..00000000000 --- a/app/views/shared/members/_invite_group.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -- access_levels = local_assigns[:access_levels] -- default_access_level = local_assigns[:default_access_level] -- submit_url = local_assigns[:submit_url] -- group_link_field = local_assigns[:group_link_field] -- group_access_field = local_assigns[:group_access_field] -- groups_select_tag_data = local_assigns[:groups_select_tag_data] - -.row - .col-sm-12 - = form_tag submit_url, class: 'invite-group-form js-requires-input', method: :post do - .form-group - = label_tag group_link_field, _("Select a group to invite"), class: "label-bold" - = groups_select_tag(group_link_field, data: groups_select_tag_data, class: 'input-clamp qa-group-select-field', required: true) - .form-text.text-muted.gl-mb-3 - = _('Group sharing provides access to all group members (including members who inherited group membership from a parent group).') - .form-group - = label_tag group_access_field, _("Max role"), class: "label-bold" - .select-wrapper - = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control" - = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") - .form-text.text-muted.gl-mb-3 - - permissions_docs_path = help_page_path('user/permissions') - - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } - = _("%{link_start}Learn more%{link_end} about roles.").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - .form-group - = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' - .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups' - = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200') - = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'invite_group_button' } diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml deleted file mode 100644 index e6863ed56a5..00000000000 --- a/app/views/shared/members/_invite_member.html.haml +++ /dev/null @@ -1,28 +0,0 @@ -- access_levels = local_assigns[:access_levels] -- default_access_level = local_assigns[:default_access_level] -- submit_url = local_assigns[:submit_url] -- can_import_members = local_assigns[:can_import_members?] -- import_path = local_assigns[:import_path] -.row - .col-sm-12 - = form_tag submit_url, class: 'invite-users-form', data: { testid: 'invite-users-form' }, method: :post do - .form-group - = label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold" - = users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite') - .form-group - = label_tag :access_level, _("Select a role"), class: "label-bold" - .select-wrapper - = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control" - = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") - .form-text.text-muted.gl-mb-3 - - permissions_docs_path = help_page_path('user/permissions') - - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } - = _("%{link_start}Learn more%{link_end} about roles.").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - .form-group - = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' - .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' - = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200') - = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-2", data: { qa_selector: 'invite_member_button' } - - if can_import_members - = link_to _("Import"), import_path, class: "gl-button btn btn-default", title: _("Import members from another project") diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml index 6d4ff255f06..8a709a36835 100644 --- a/app/views/shared/milestones/_delete_button.html.haml +++ b/app/views/shared/milestones/_delete_button.html.haml @@ -6,7 +6,7 @@ milestone_issue_count: @milestone.issues.count, milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true } + = gl_loading_icon(inline: true, css_class: "gl-mr-2 js-loading-icon hidden") = _('Delete') - .gl-spinner.js-loading-icon.hidden #js-delete-milestone-modal diff --git a/app/views/shared/milestones/_milestone_complete_alert.html.haml b/app/views/shared/milestones/_milestone_complete_alert.html.haml index 1c25fae747e..5b05fdb6019 100644 --- a/app/views/shared/milestones/_milestone_complete_alert.html.haml +++ b/app/views/shared/milestones/_milestone_complete_alert.html.haml @@ -3,7 +3,6 @@ - if milestone.complete? && milestone.active? = render 'shared/global_alert', variant: :success, - is_contained: true, alert_data: { testid: 'all-issues-closed-alert' }, dismissible: false do .gl-alert-body diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml index b19e994ef80..ebd4ef7d4c3 100644 --- a/app/views/shared/milestones/_tab_loading.html.haml +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -1,2 +1 @@ -.text-center.gl-mt-3 - .gl-spinner.gl-spinner-md += gl_loading_icon(size: 'md', css_class: 'gl-mt-3') diff --git a/app/views/shared/nav/_sidebar_submenu.html.haml b/app/views/shared/nav/_sidebar_submenu.html.haml index 750e6c9ee57..344dafe7c0f 100644 --- a/app/views/shared/nav/_sidebar_submenu.html.haml +++ b/app/views/shared/nav/_sidebar_submenu.html.haml @@ -4,7 +4,7 @@ %strong.fly-out-top-item-name = sidebar_menu.title - if sidebar_menu.has_pill? - %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } + = gl_badge_tag({ variant: :info, size: :sm }, { class: "count fly-out-badge #{sidebar_menu.pill_html_options[:class]}" }) do = number_with_delimiter(sidebar_menu.pill_count) - if sidebar_menu.has_renderable_items? diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 6c8b2a9e5bb..8a79a17b166 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -18,7 +18,7 @@ %span.attaching-file-message -# Populated by app/assets/javascripts/dropzone_input.js %span.uploading-progress 0% - = loading_icon(css_class: 'align-text-bottom gl-mr-2') + = gl_loading_icon(inline: true, css_class: 'gl-mr-2') %span.uploading-error-container.hide %span.uploading-error-icon diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml index 3cbe35e5c15..32b9044c551 100644 --- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml @@ -34,4 +34,7 @@ = _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence } %td - = render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allowed to force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name } + = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle', + label: s_("ProtectedBranch|Toggle allowed to force push"), + is_checked: protected_branch.allow_force_push, + label_position: :hidden) diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml index 23010b8349c..03f373783f8 100644 --- a/app/views/shared/web_hooks/_hook_errors.html.haml +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -13,7 +13,6 @@ = render 'shared/global_alert', title: s_('Webhooks|Webhook was automatically disabled'), variant: :danger, - is_contained: true, close_button_class: 'js-close' do .gl-alert-body = s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders @@ -21,7 +20,6 @@ = render 'shared/global_alert', title: s_('Webhooks|Webhook failed to connect'), variant: :danger, - is_contained: true, close_button_class: 'js-close' do .gl-alert-body = 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 } @@ -35,7 +33,6 @@ = render 'shared/global_alert', title: s_('Webhooks|Webhook fails to connect'), variant: :warning, - is_contained: true, close_button_class: 'js-close' do .gl-alert-body = 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/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml index 0a8ca309823..abe7753b9f1 100644 --- a/app/views/shared/wikis/pages.html.haml +++ b/app/views/shared/wikis/pages.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _('Wiki'), wiki_path(@wiki) - breadcrumb_title s_("Wiki|Pages") - page_title s_("Wiki|Pages"), _("Wiki") -- sort_title = wiki_sort_title(params[:sort]) - add_page_specific_style 'page_bundles/wiki' +- wiki_sort_options = [{ text: s_("Wiki|Title"), value: 'title', href: wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER)}, { text: s_("Wiki|Created date"), value: 'created_at', href: wiki_path(@wiki, action: :pages, sort: Wiki::CREATED_AT_ORDER) }] .wiki-page-header.top-area.flex-column.flex-lg-row %h3.page-title.gl-flex-grow-1 @@ -15,14 +15,7 @@ .dropdown.inline.wiki-sort-dropdown .btn-group{ role: 'group' } - .btn-group{ role: 'group' } - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn gl-button btn-default' } - = sort_title - = sprite_icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort - %li - = sortable_item(s_("Wiki|Title"), wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER), sort_title) - = sortable_item(s_("Wiki|Created date"), wiki_path(@wiki, action: :pages, sort: Wiki::CREATED_AT_ORDER), sort_title) + = gl_redirect_listbox_tag wiki_sort_options, params[:sort], data: { right: true } = wiki_sort_controls(@wiki, params[:sort], params[:direction]) %ul.wiki-pages-list.content-list diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index c0a6ab44a26..a7875f9b089 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -3,7 +3,7 @@ .row.d-none.d-sm-flex .col-12.calendar-block.gl-my-3 .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_timezone_instance(@user.timezone).now.utc_offset } } - .gl-spinner.gl-spinner-md.gl-my-8 + = gl_loading_icon(size: 'md', css_class: 'gl-my-8') .user-calendar-error.invisible = _('There was an error loading users activity calendar.') %a.js-retry-load{ href: '#' } @@ -35,8 +35,7 @@ = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity') = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" .overview-content-list{ data: { href: user_activity_path, qa_selector: 'user_activity_content' } } - .center.light.loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md', css_class: 'loading') - unless Feature.enabled?(:security_auto_fix) && @user.bot? .col-md-12.col-lg-6 @@ -47,5 +46,4 @@ = s_('UserProfile|Personal projects') = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" .overview-content-list{ data: { href: user_projects_path } } - .center.light.loading - .gl-spinner.gl-spinner-md + = gl_loading_icon(size: 'md', css_class: 'loading') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index fb1fcb7937c..48bdee4062b 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -309,6 +309,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:database_batched_background_migration_ci_database + :worker_name: Database::BatchedBackgroundMigration::CiDatabaseWorker + :feature_category: :database + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:database_drop_detached_partitions :worker_name: Database::DropDetachedPartitionsWorker :feature_category: :database @@ -552,6 +561,15 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:projects_schedule_refresh_build_artifacts_size_statistics + :worker_name: Projects::ScheduleRefreshBuildArtifactsSizeStatisticsWorker + :feature_category: :build_artifacts + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:prune_old_events :worker_name: PruneOldEventsWorker :feature_category: :users @@ -561,6 +579,15 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:quality_test_data_cleanup + :worker_name: Quality::TestDataCleanupWorker + :feature_category: :quality_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:releases_manage_evidence :worker_name: Releases::ManageEvidenceWorker :feature_category: :release_evidence @@ -1654,7 +1681,7 @@ :worker_name: Ci::DropPipelineWorker :feature_category: :continuous_integration :has_external_dependencies: - :urgency: :low + :urgency: :high :resource_boundary: :unknown :weight: 3 :idempotent: true @@ -2785,6 +2812,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: projects_refresh_build_artifacts_size_statistics + :worker_name: Projects::RefreshBuildArtifactsSizeStatisticsWorker + :feature_category: :build_artifacts + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_schedule_bulk_repository_shard_moves :worker_name: Projects::ScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index 8bc0acc9b22..21040178cee 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -14,6 +14,10 @@ module BulkImports entity = BulkImports::Entity.find(entity_id) request_export(entity) + rescue BulkImports::NetworkError => e + log_export_failure(e, entity) + + entity.fail_op! end private @@ -28,5 +32,24 @@ module BulkImports token: configuration.access_token ) end + + def log_export_failure(exception, entity) + attributes = { + bulk_import_entity_id: entity.id, + pipeline_class: 'ExportRequestWorker', + exception_class: exception.class.to_s, + exception_message: exception.message.truncate(255), + correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id + } + + Gitlab::Import::Logger.warn( + attributes.merge( + bulk_import_id: entity.bulk_import.id, + bulk_import_entity_type: entity.source_type + ) + ) + + BulkImports::Failure.create(attributes) + end end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 8e5d7013c2c..03ec2f058ca 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -43,6 +43,10 @@ module BulkImports private def run(pipeline_tracker) + if pipeline_tracker.entity.failed? + raise(Entity::FailedError, 'Failed entity status') + end + if ndjson_pipeline?(pipeline_tracker) status = ExportStatus.new(pipeline_tracker, pipeline_tracker.pipeline_class.relation) diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb index 56cfaa7e674..70c234bd4c7 100644 --- a/app/workers/ci/build_finished_worker.rb +++ b/app/workers/ci/build_finished_worker.rb @@ -39,6 +39,7 @@ module Ci # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? + build.track_deployment_usage if build.failed? && !build.auto_retry_expected? ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id) diff --git a/app/workers/ci/drop_pipeline_worker.rb b/app/workers/ci/drop_pipeline_worker.rb index edb97c3cac5..6018290b3a2 100644 --- a/app/workers/ci/drop_pipeline_worker.rb +++ b/app/workers/ci/drop_pipeline_worker.rb @@ -9,6 +9,8 @@ module Ci sidekiq_options retry: 3 include PipelineQueue + urgency :high + idempotent! def perform(pipeline_id, failure_reason) diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb index c46deeb716f..13b7e7b5b1f 100644 --- a/app/workers/concerns/git_garbage_collect_methods.rb +++ b/app/workers/concerns/git_garbage_collect_methods.rb @@ -83,17 +83,27 @@ module GitGarbageCollectMethods def gitaly_call(task, resource) repository = resource.repository.raw_repository - client = get_gitaly_client(task, repository) - - case task - when :prune, :gc - client.garbage_collect(bitmaps_enabled?, prune: task == :prune) - when :full_repack - client.repack_full(bitmaps_enabled?) - when :incremental_repack - client.repack_incremental - when :pack_refs - client.pack_refs + if Feature.enabled?(:optimized_housekeeping, container(resource), default_enabled: :yaml) + client = repository.gitaly_repository_client + + if task == :prune + client.prune_unreachable_objects + else + client.optimize_repository + end + else + client = get_gitaly_client(task, repository) + + case task + when :prune, :gc + client.garbage_collect(bitmaps_enabled?, prune: task == :prune) + when :full_repack + client.repack_full(bitmaps_enabled?) + when :incremental_repack + client.repack_incremental + when :pack_refs + client.pack_refs + end end rescue GRPC::NotFound => e Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found") diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 7f7a77d0524..cd3ed5d4c9b 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -123,7 +123,7 @@ module ContainerExpirationPolicies end def throttling_enabled? - Feature.enabled?(:container_registry_expiration_policies_throttling) + Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml) end def max_cleanup_execution_time diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index 16ac61976eb..308ccfe2cb3 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -99,7 +99,7 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo end def throttling_enabled? - Feature.enabled?(:container_registry_expiration_policies_throttling) + Feature.enabled?(:container_registry_expiration_policies_throttling, default_enabled: :yaml) end def lease_timeout diff --git a/app/workers/database/batched_background_migration/ci_database_worker.rb b/app/workers/database/batched_background_migration/ci_database_worker.rb new file mode 100644 index 00000000000..98ec6f98123 --- /dev/null +++ b/app/workers/database/batched_background_migration/ci_database_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +module Database + module BatchedBackgroundMigration + class CiDatabaseWorker # rubocop:disable Scalability/IdempotentWorker + include SingleDatabaseWorker + + def self.tracking_database + @tracking_database ||= Gitlab::Database::CI_DATABASE_NAME + end + end + end +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 new file mode 100644 index 00000000000..78c82a6549f --- /dev/null +++ b/app/workers/database/batched_background_migration/single_database_worker.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Database + module BatchedBackgroundMigration + module SingleDatabaseWorker + extend ActiveSupport::Concern + + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + LEASE_TIMEOUT_MULTIPLIER = 3 + MINIMUM_LEASE_TIMEOUT = 10.minutes.freeze + INTERVAL_VARIANCE = 5.seconds.freeze + + included do + data_consistency :always + feature_category :database + idempotent! + end + + class_methods do + # :nocov: + def tracking_database + raise NotImplementedError, "#{self.name} does not implement #{__method__}" + end + # :nocov: + + def lease_key + name.demodulize.underscore + end + end + + def perform + unless base_model + Sidekiq.logger.info( + class: self.class.name, + database: self.class.tracking_database, + message: 'skipping migration execution for unconfigured database') + + return + end + + Gitlab::Database::SharedModel.using_connection(base_model.connection) do + break unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml) && active_migration + + with_exclusive_lease(active_migration.interval) do + # Now that we have the exclusive lease, reload migration in case another process has changed it. + # This is a temporary solution until we have better concurrency handling around job execution + # + # We also have to disable this cop, because ApplicationRecord aliases reset to reload, but our database + # models don't inherit from ApplicationRecord + active_migration.reload # rubocop:disable Cop/ActiveRecordAssociationReload + + run_active_migration if active_migration.active? && active_migration.interval_elapsed?(variance: INTERVAL_VARIANCE) + end + end + end + + private + + def active_migration + @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration + end + + def run_active_migration + Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new(connection: base_model.connection).run_migration_job(active_migration) + end + + def base_model + @base_model ||= Gitlab::Database.database_base_models[self.class.tracking_database] + end + + def with_exclusive_lease(interval) + timeout = [interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT].max + lease = Gitlab::ExclusiveLease.new(self.class.lease_key, timeout: timeout) + + yield if lease.try_obtain + ensure + lease&.cancel + end + end + end +end diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb index fda539b372d..29804be832d 100644 --- a/app/workers/database/batched_background_migration_worker.rb +++ b/app/workers/database/batched_background_migration_worker.rb @@ -1,56 +1,11 @@ # frozen_string_literal: true module Database - class BatchedBackgroundMigrationWorker - include ApplicationWorker + class BatchedBackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker + include BatchedBackgroundMigration::SingleDatabaseWorker - data_consistency :always - - include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - - feature_category :database - idempotent! - - LEASE_TIMEOUT_MULTIPLIER = 3 - MINIMUM_LEASE_TIMEOUT = 10.minutes.freeze - INTERVAL_VARIANCE = 5.seconds.freeze - - def perform - return unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml) && active_migration - - with_exclusive_lease(active_migration.interval) do - # Now that we have the exclusive lease, reload migration in case another process has changed it. - # This is a temporary solution until we have better concurrency handling around job execution - # - # We also have to disable this cop, because ApplicationRecord aliases reset to reload, but our database - # models don't inherit from ApplicationRecord - active_migration.reload # rubocop:disable Cop/ActiveRecordAssociationReload - - run_active_migration if active_migration.active? && active_migration.interval_elapsed?(variance: INTERVAL_VARIANCE) - end - end - - private - - def active_migration - @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration - end - - def run_active_migration - Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new.run_migration_job(active_migration) - end - - def with_exclusive_lease(interval) - timeout = [interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT].max - lease = Gitlab::ExclusiveLease.new(lease_key, timeout: timeout) - - yield if lease.try_obtain - ensure - lease&.cancel - end - - def lease_key - self.class.name.demodulize.underscore + def self.tracking_database + @tracking_database ||= Gitlab::Database::MAIN_DATABASE_NAME.to_sym end end end diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb index d16583975fc..a70c52abde2 100644 --- a/app/workers/projects/git_garbage_collect_worker.rb +++ b/app/workers/projects/git_garbage_collect_worker.rb @@ -7,6 +7,12 @@ module Projects private + # Used for getting a project/group out of the resource in order to scope a feature flag + # Can be removed within https://gitlab.com/gitlab-org/gitlab/-/issues/353607 + def container(resource) + resource + end + override :find_resource def find_resource(id) Project.find(id) diff --git a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb new file mode 100644 index 00000000000..a91af72cc2c --- /dev/null +++ b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Projects + class RefreshBuildArtifactsSizeStatisticsWorker + include ApplicationWorker + include LimitedCapacity::Worker + + MAX_RUNNING_LOW = 2 + MAX_RUNNING_MEDIUM = 20 + MAX_RUNNING_HIGH = 50 + + data_consistency :always + + feature_category :build_artifacts + + idempotent! + + def perform_work(*args) + refresh = Projects::RefreshBuildArtifactsSizeStatisticsService.new.execute + return unless refresh + + log_extra_metadata_on_done(:project_id, refresh.project_id) + log_extra_metadata_on_done(:last_job_artifact_id, refresh.last_job_artifact_id) + log_extra_metadata_on_done(:last_batch, refresh.destroyed?) + log_extra_metadata_on_done(:refresh_started_at, refresh.refresh_started_at) + end + + def remaining_work_count(*args) + # LimitedCapacity::Worker only needs to know if there is work left to do + # so we can get by with an EXISTS query rather than a count. + # https://gitlab.com/gitlab-org/gitlab/-/issues/356167 + if Projects::BuildArtifactsSizeRefresh.remaining.any? + 1 + else + 0 + end + end + + def max_running_jobs + if ::Feature.enabled?(:projects_build_artifacts_size_refresh_high) + MAX_RUNNING_HIGH + elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_medium) + MAX_RUNNING_MEDIUM + elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_low) + MAX_RUNNING_LOW + else + 0 + end + end + end +end diff --git a/app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb new file mode 100644 index 00000000000..ed2b642d998 --- /dev/null +++ b/app/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Projects + class ScheduleRefreshBuildArtifactsSizeStatisticsWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + data_consistency :always + + feature_category :build_artifacts + + idempotent! + + def perform + Projects::RefreshBuildArtifactsSizeStatisticsWorker.perform_with_capacity + end + end +end diff --git a/app/workers/quality/test_data_cleanup_worker.rb b/app/workers/quality/test_data_cleanup_worker.rb new file mode 100644 index 00000000000..68b36cacbbf --- /dev/null +++ b/app/workers/quality/test_data_cleanup_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Quality + class TestDataCleanupWorker + include ApplicationWorker + + data_consistency :always + feature_category :quality_management + urgency :low + + include CronjobQueue + idempotent! + + KEEP_RECENT_DATA_DAY = 3 + GROUP_PATH_PATTERN = 'test-group-fulfillment' + GROUP_OWNER_EMAIL_PATTERN = %w(test-user- gitlab-qa-user qa-user-).freeze + + # Remove test groups generated in E2E tests on gstg + # rubocop: disable CodeReuse/ActiveRecord + def perform + return unless Gitlab.staging? + + Group.where('path like ?', "#{GROUP_PATH_PATTERN}%").where('created_at < ?', KEEP_RECENT_DATA_DAY.days.ago).each do |group| + next unless GROUP_OWNER_EMAIL_PATTERN.any? { |pattern| group.owners.first.email.include?(pattern) } + + with_context(namespace: group, user: group.owners.first) do + Groups::DestroyService.new(group, group.owners.first).execute + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index fdcd22128a3..301f3720991 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -13,9 +13,6 @@ class WebHookWorker worker_has_external_dependencies! - # Webhook recursion detection properties may be passed through the `data` arg. - # This will be migrated to the `params` arg over the next few releases. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/347389. def perform(hook_id, data, hook_name, params = {}) hook = WebHook.find_by_id(hook_id) return unless hook @@ -23,9 +20,6 @@ class WebHookWorker data = data.with_indifferent_access params.symbolize_keys! - # TODO: Remove in 14.9 https://gitlab.com/gitlab-org/gitlab/-/issues/347389 - params[:recursion_detection_request_uuid] ||= data.delete(:_gitlab_recursion_detection_request_uuid) - # Before executing the hook, reapply any recursion detection UUID that was initially # present in the request header so the hook can pass this same header value in its request. Gitlab::WebHooks::RecursionDetection.set_request_uuid(params[:recursion_detection_request_uuid]) diff --git a/app/workers/wikis/git_garbage_collect_worker.rb b/app/workers/wikis/git_garbage_collect_worker.rb index 1b455c50618..b00190c6b98 100644 --- a/app/workers/wikis/git_garbage_collect_worker.rb +++ b/app/workers/wikis/git_garbage_collect_worker.rb @@ -7,6 +7,12 @@ module Wikis private + # Used for getting a project/group out of the resource in order to scope a feature flag + # Can be removed within https://gitlab.com/gitlab-org/gitlab/-/issues/353607 + def container(resource) + resource.container + end + override :find_resource def find_resource(id) Project.find(id).wiki |