diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-16 13:42:19 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-16 13:42:19 +0300 |
commit | 84d1bd786125c1c14a3ba5f63e38a4cc736a9027 (patch) | |
tree | f550fa965f507077e20dbb6d61a8269a99ef7107 /app | |
parent | 3a105e36e689f7b75482236712f1a47fd5a76814 (diff) |
Add latest changes from gitlab-org/gitlab@16-8-stable-eev16.8.0-rc42
Diffstat (limited to 'app')
882 files changed, 10846 insertions, 7173 deletions
diff --git a/app/assets/images/mailers/in_product_marketing/admin_verify-0.png b/app/assets/images/mailers/in_product_marketing/admin_verify-0.png Binary files differdeleted file mode 100644 index c6d3e55afc1..00000000000 --- a/app/assets/images/mailers/in_product_marketing/admin_verify-0.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/create-0.png b/app/assets/images/mailers/in_product_marketing/create-0.png Binary files differdeleted file mode 100644 index 7fc992f14f2..00000000000 --- a/app/assets/images/mailers/in_product_marketing/create-0.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/create-1.png b/app/assets/images/mailers/in_product_marketing/create-1.png Binary files differdeleted file mode 100644 index 0315ffefb31..00000000000 --- a/app/assets/images/mailers/in_product_marketing/create-1.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/create-2.png b/app/assets/images/mailers/in_product_marketing/create-2.png Binary files differdeleted file mode 100644 index 619f9fcd659..00000000000 --- a/app/assets/images/mailers/in_product_marketing/create-2.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/experience-0.png b/app/assets/images/mailers/in_product_marketing/experience-0.png Binary files differdeleted file mode 100644 index 346204d1db1..00000000000 --- a/app/assets/images/mailers/in_product_marketing/experience-0.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/team-0.png b/app/assets/images/mailers/in_product_marketing/team-0.png Binary files differdeleted file mode 100644 index f10ae998efa..00000000000 --- a/app/assets/images/mailers/in_product_marketing/team-0.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/team-1.png b/app/assets/images/mailers/in_product_marketing/team-1.png Binary files differdeleted file mode 100644 index cd68464e6e8..00000000000 --- a/app/assets/images/mailers/in_product_marketing/team-1.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/team-2.png b/app/assets/images/mailers/in_product_marketing/team.png Binary files differindex b199c659943..b199c659943 100644 --- a/app/assets/images/mailers/in_product_marketing/team-2.png +++ b/app/assets/images/mailers/in_product_marketing/team.png diff --git a/app/assets/images/mailers/in_product_marketing/trial-0.png b/app/assets/images/mailers/in_product_marketing/trial-0.png Binary files differdeleted file mode 100644 index 3b0d7a8ecd8..00000000000 --- a/app/assets/images/mailers/in_product_marketing/trial-0.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/trial-1.png b/app/assets/images/mailers/in_product_marketing/trial-1.png Binary files differdeleted file mode 100644 index 3a30b2acaee..00000000000 --- a/app/assets/images/mailers/in_product_marketing/trial-1.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/trial-2.png b/app/assets/images/mailers/in_product_marketing/trial-2.png Binary files differdeleted file mode 100644 index 95bd965b49f..00000000000 --- a/app/assets/images/mailers/in_product_marketing/trial-2.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/verify-0.png b/app/assets/images/mailers/in_product_marketing/verify-0.png Binary files differdeleted file mode 100644 index 04b6f172b37..00000000000 --- a/app/assets/images/mailers/in_product_marketing/verify-0.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/verify-1.png b/app/assets/images/mailers/in_product_marketing/verify-1.png Binary files differdeleted file mode 100644 index 8997e8ba575..00000000000 --- a/app/assets/images/mailers/in_product_marketing/verify-1.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/verify-2.png b/app/assets/images/mailers/in_product_marketing/verify.png Binary files differindex 93c99dee246..93c99dee246 100644 --- a/app/assets/images/mailers/in_product_marketing/verify-2.png +++ b/app/assets/images/mailers/in_product_marketing/verify.png diff --git a/app/assets/javascripts/admin/abuse_report/components/user_details.vue b/app/assets/javascripts/admin/abuse_report/components/user_details.vue index 0c32341652b..0e946fed8ac 100644 --- a/app/assets/javascripts/admin/abuse_report/components/user_details.vue +++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue @@ -26,12 +26,18 @@ export default { .map(([k]) => this.$options.i18n.verificationMethods[k]) .join(', '); }, - showSimilarRecords() { + showCreditCardSimilarRecords() { return this.user.creditCard.similarRecordsCount > 1; }, - similarRecordsCount() { + creditCardSimilarRecordsCount() { return formatNumber(this.user.creditCard.similarRecordsCount); }, + showPhoneNumberSimilarRecords() { + return this.user.phoneNumber.similarRecordsCount > 1; + }, + phoneNumberSimilarRecordsCount() { + return formatNumber(this.user.phoneNumber.similarRecordsCount); + }, }, i18n: USER_DETAILS_I18N, }; @@ -60,11 +66,33 @@ export default { data-testid="credit-card-verification" :label="$options.i18n.creditCard" > - <gl-sprintf v-if="showSimilarRecords" :message="$options.i18n.similarRecords"> + <gl-sprintf + v-if="showCreditCardSimilarRecords" + :message="$options.i18n.creditCardSimilarRecords" + > <template #cardMatchesLink="{ content }"> <gl-link :href="user.creditCard.cardMatchesLink"> <gl-sprintf :message="content"> - <template #count>{{ similarRecordsCount }}</template> + <template #count>{{ creditCardSimilarRecordsCount }}</template> + </gl-sprintf> + </gl-link> + </template> + </gl-sprintf> + </user-detail> + + <user-detail + v-if="user.phoneNumber" + data-testid="phone-number-verification" + :label="$options.i18n.phoneNumber" + > + <gl-sprintf + v-if="showPhoneNumberSimilarRecords" + :message="$options.i18n.phoneNumberSimilarRecords" + > + <template #phoneMatchesLink="{ content }"> + <gl-link :href="user.phoneNumber.phoneMatchesLink"> + <gl-sprintf :message="content"> + <template #count>{{ phoneNumberSimilarRecordsCount }}</template> </gl-sprintf> </gl-link> </template> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index c56ea678b1d..69bcdebad61 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -61,6 +61,7 @@ export const USER_DETAILS_I18N = { plan: s__('AbuseReport|Tier'), verification: s__('AbuseReport|Verification'), creditCard: s__('AbuseReport|Credit card'), + phoneNumber: s__('AbuseReport|Phone number'), pastReports: s__('AbuseReport|Past abuse reports'), normalLocation: s__('AbuseReport|Normal location'), lastSignInIp: s__('AbuseReport|Last login'), @@ -78,9 +79,12 @@ export const USER_DETAILS_I18N = { reportedFor: s__( 'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.', ), - similarRecords: s__( + creditCardSimilarRecords: s__( 'AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}', ), + phoneNumberSimilarRecords: s__( + 'AbuseReport|Phone matches %{phoneMatchesLinkStart}%{count} accounts%{phoneMatchesLinkEnd}', + ), }; export const REPORTED_CONTENT_I18N = { diff --git a/app/assets/javascripts/api/alert_management_alerts_api.js b/app/assets/javascripts/api/alert_management_alerts_api.js index fa66ca5b3dd..6f595cb7cb0 100644 --- a/app/assets/javascripts/api/alert_management_alerts_api.js +++ b/app/assets/javascripts/api/alert_management_alerts_api.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { buildApiUrl } from '~/api/api_utils'; -import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; +import { contentTypeMultipartFormData } from '~/lib/utils/headers'; const ALERT_METRIC_IMAGES_PATH = '/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images'; @@ -16,7 +16,7 @@ export function fetchAlertMetricImages({ alertIid, id }) { } export function uploadAlertMetricImage({ alertIid, id, file, url = null, urlText = null }) { - const options = { headers: { ...ContentTypeMultipartFormData } }; + const options = { headers: { ...contentTypeMultipartFormData } }; const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH) .replace(':id', encodeURIComponent(id)) .replace(':alert_iid', encodeURIComponent(alertIid)); diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index a4f88067fa9..3f6435d66ce 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -3,6 +3,7 @@ import { GlBadge, GlLoadingIcon, GlTable, + GlTooltipDirective, GlPagination, GlButton, GlModalDirective, @@ -27,6 +28,7 @@ export default { }, directives: { GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, i18n: { emptyGroupMessage: s__('Badges|This group has no badges. Add an existing badge or create one.'), @@ -107,19 +109,26 @@ export default { :current-page="currentPage" stacked="md" show-empty + class="b-table-fixed" data-testid="badge-list" > <template #cell(name)="{ item }"> - <label class="label-bold str-truncated mb-0">{{ item.name }}</label> + <label v-gl-tooltip class="label-bold str-truncated mb-0" :title="item.name">{{ + item.name + }}</label> <gl-badge size="sm">{{ badgeKindText(item) }}</gl-badge> </template> <template #cell(badge)="{ item }"> - <badge :image-url="item.renderedImageUrl" :link-url="item.renderedLinkUrl" /> + <div class="overflow-hidden"> + <badge :image-url="item.renderedImageUrl" :link-url="item.renderedLinkUrl" /> + </div> </template> <template #cell(url)="{ item }"> - {{ item.linkUrl }} + <span v-gl-tooltip :title="item.linkUrl" class="str-truncated"> + {{ item.linkUrl }} + </span> </template> <template #cell(actions)="{ item }"> diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js deleted file mode 100644 index b6ed14611cd..00000000000 --- a/app/assets/javascripts/behaviors/secret_values.js +++ /dev/null @@ -1,47 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; -import { n__ } from '~/locale'; - -export default class SecretValues { - constructor({ - container, - valueSelector = '.js-secret-value', - placeholderSelector = '.js-secret-value-placeholder', - }) { - this.container = container; - this.valueSelector = valueSelector; - this.placeholderSelector = placeholderSelector; - } - - init() { - this.revealButton = this.container.querySelector('.js-secret-value-reveal-button'); - - if (this.revealButton) { - const isRevealed = parseBoolean(this.revealButton.dataset.secretRevealStatus); - this.updateDom(isRevealed); - - this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this)); - } - } - - onRevealButtonClicked() { - const previousIsRevealed = parseBoolean(this.revealButton.dataset.secretRevealStatus); - this.updateDom(!previousIsRevealed); - } - - updateDom(isRevealed) { - const values = this.container.querySelectorAll(this.valueSelector); - values.forEach((value) => { - value.classList.toggle('hide', !isRevealed); - }); - - const placeholders = this.container.querySelectorAll(this.placeholderSelector); - placeholders.forEach((placeholder) => { - placeholder.classList.toggle('hide', isRevealed); - }); - - this.revealButton.textContent = isRevealed - ? n__('Hide value', 'Hide values', values.length) - : n__('Reveal value', 'Reveal values', values.length); - this.revealButton.dataset.secretRevealStatus = isRevealed; - } -} diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue index e81ceae57c0..f8b2331befa 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue @@ -79,9 +79,7 @@ export default { " > <template #link="{ content }"> - <gl-link :href="absoluteUserPreferencesPath"> - {{ content }} - </gl-link> + <gl-link :href="absoluteUserPreferencesPath">{{ content }}</gl-link> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 9c22d960bf5..e2715d89b4e 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -1,5 +1,5 @@ +import SwaggerClient from 'swagger-client'; import { setAttributes } from '~/lib/utils/dom_utils'; -import axios from '~/lib/utils/axios_utils'; import { getBaseURL, relativePathToAbsolute, @@ -42,11 +42,11 @@ export default async (el = document.getElementById('js-openapi-viewer')) => { const wrapperEl = el; const sandboxEl = createSandbox(); - const { data } = await axios.get(wrapperEl.dataset.endpoint); + const { spec } = await SwaggerClient.resolve({ url: wrapperEl.dataset.endpoint }); wrapperEl.appendChild(sandboxEl); sandboxEl.addEventListener('load', () => { - sandboxEl.contentWindow.postMessage(data, '*'); + if (spec) sandboxEl.contentWindow.postMessage(JSON.stringify(spec), '*'); }); }; diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 2c8aa1cbe21..b1b3e7f7022 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -155,7 +155,7 @@ export default { :is-swimlanes-on="isSwimlanesOn" :filter-params="formattedFilterParams" :board-lists="boardLists" - :apollo-error="error" + :error="error" :list-query-variables="listQueryVariables" @setActiveList="setActiveId" @setAddColumnFormVisibility="addColumnFormVisible = $event" diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index 9173503c888..398dcc494f7 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -1,6 +1,7 @@ <script> -import { GlDisclosureDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; import Tracking from '~/tracking'; +import { s__ } from '~/locale'; import { BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS, BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION, @@ -12,6 +13,9 @@ export default { components: { GlDisclosureDropdown, }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [Tracking.mixin()], props: { item: { @@ -87,6 +91,9 @@ export default { } }, }, + i18n: { + moveCardText: s__('Boards|Card options'), + }, }; </script> @@ -94,12 +101,16 @@ export default { <gl-disclosure-dropdown ref="dropdown" :key="itemIdentifier" + v-gl-tooltip.hover.focus.top="{ + title: $options.i18n.moveCardText, + boundary: 'viewport', + }" class="move-to-position gl-display-block gl-mb-2 gl-ml-auto gl-mt-n3 gl-mr-n3 js-no-trigger" category="tertiary" :items="$options.BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS" icon="ellipsis_v" :tabindex="index" - :toggle-text="s__('Boards|Move card')" + :aria-label="$options.i18n.moveCardText" :text-sr-only="true" no-caret data-testid="board-move-to-position" diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 2b9c5d52d5e..dbdfe314ae0 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -46,7 +46,7 @@ export default { required: false, default: () => {}, }, - apolloError: { + error: { type: String, required: false, default: null, @@ -97,9 +97,6 @@ export default { return this.canDragColumns ? options : {}; }, - errorToDisplay() { - return this.apolloError || null; - }, }, methods: { afterFormEnters() { @@ -195,8 +192,8 @@ export default { data-testid="boards-list" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0" > - <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="dismissError"> - {{ errorToDisplay }} + <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="dismissError"> + {{ error }} </gl-alert> <component :is="boardColumnWrapper" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 8a5c6882e56..58c20c0da91 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -3,6 +3,7 @@ import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { STATUS_CLOSED } from '~/issues/constants'; import { sprintf, __, s__ } from '~/locale'; +import { ESC_KEY_CODE } from '~/lib/utils/keycodes'; import { defaultSortableOptions, DRAG_DELAY } from '~/sortable/constants'; import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; @@ -82,6 +83,7 @@ export default { toList: {}, addItemToListInProgress: false, updateIssueOrderInProgress: false, + dragCancelled: false, }; }, apollo: { @@ -307,6 +309,11 @@ export default { return; } + // Reset dragCancelled flag + this.dragCancelled = false; + // Attach listener to detect `ESC` key press to cancel drag. + document.addEventListener('keyup', this.handleKeyUp.bind(this)); + sortableStart(); this.track('drag_card', { label: 'board' }); }, @@ -323,6 +330,11 @@ export default { return; } + // Detach listener as soon as drag ends. + document.removeEventListener('keyup', this.handleKeyUp.bind(this)); + // Drag was cancelled, prevent reordering. + if (this.dragCancelled) return; + sortableEnd(); let newIndex = originalNewIndex; let { children } = to; @@ -375,6 +387,20 @@ export default { this.updateIssueOrderInProgress = false; }); }, + /** + * This implementation is needed to support `Esc` key press to cancel drag. + * It matches with what we already shipped in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119311 + */ + handleKeyUp(e) { + if (e.keyCode === ESC_KEY_CODE) { + this.dragCancelled = true; + // Sortable.js internally listens for `mouseup` event on document + // to register drop event, see https://github.com/SortableJS/Sortable/blob/master/src/Sortable.js#L625 + // We need to manually trigger it to simulate cancel behaviour as VueDraggable doesn't + // natively support it, see https://github.com/SortableJS/Vue.Draggable/issues/968. + document.dispatchEvent(new Event('mouseup')); + } + }, isItemInTheList(itemIid) { const items = this.toList?.[`${this.issuableType}s`]?.nodes || []; return items.some((item) => item.iid === itemIid); diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index 69e6cc870d2..f4d4222e41a 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -1,7 +1,5 @@ <script> import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapGetters } from 'vuex'; import { formType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import { s__, __ } from '~/locale'; @@ -25,12 +23,11 @@ export default { }, }, computed: { - ...mapGetters(['hasScope']), buttonText() { return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); }, tooltipTitle() { - return this.hasScope || this.boardHasScope ? __("This board's scope is reduced") : ''; + return this.boardHasScope ? __("This board's scope is reduced") : ''; }, }, methods: { @@ -48,7 +45,7 @@ export default { v-gl-modal-directive="'board-config-modal'" v-gl-tooltip :title="tooltipTitle" - :class="{ 'dot-highlight': hasScope || boardHasScope }" + :class="{ 'dot-highlight': boardHasScope }" data-testid="boards-config-button" @click.prevent="showPage" > diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 7bbc444701a..9c5dd633092 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -77,10 +77,12 @@ export default { }, activeGroupProjects() { return ( - this.projects?.nodes?.map((project) => ({ - value: project.id, - text: project.nameWithNamespace, - })) || [] + this.projects?.nodes + ?.filter((p) => !p.archived) + .map((project) => ({ + value: project.id, + text: project.nameWithNamespace, + })) || [] ); }, selectedProjectName() { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index acf01a8c528..a3983f11c86 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -51,12 +51,6 @@ export const toggleFormEventPrefix = { issue: 'toggle-issue-form-', }; -export const active = 'active'; - -export const inactiveId = 0; - -export const ISSUABLE = 'issuable'; -export const LIST = 'list'; export const INCIDENT = 'INCIDENT'; export const flashAnimationDuration = 2000; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 72b8aef31a4..0d8882cf57e 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,10 +3,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; -import store from '~/boards/stores'; import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { - NavigationType, + navigationType, isLoggedIn, parseBoolean, convertObjectPropsToCamelCase, @@ -24,7 +23,6 @@ const apolloProvider = new VueApollo({ function mountBoardApp(el) { const { boardId, groupId, fullPath, rootPath } = el.dataset; - const isApolloBoard = true; const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -34,31 +32,12 @@ function mountBoardApp(el) { const boardType = el.dataset.parent; - if (!isApolloBoard) { - store.dispatch('fetchBoard', { - fullPath, - fullBoardId: fullBoardId(boardId), - boardType, - }); - - store.dispatch('setInitialBoardData', { - boardId, - fullBoardId: fullBoardId(boardId), - fullPath, - boardType, - disabled: parseBoolean(el.dataset.disabled) || true, - issuableType: TYPE_ISSUE, - }); - } - // eslint-disable-next-line no-new new Vue({ el, name: 'BoardAppRoot', - store, apolloProvider, provide: { - isApolloBoard, initialBoardId: fullBoardId(boardId), disabled: parseBoolean(el.dataset.disabled), groupId: Number(groupId), @@ -114,7 +93,7 @@ export default () => { // check for browser back and trigger a hard reload to circumvent browser caching. window.addEventListener('pageshow', (event) => { const isNavTypeBackForward = - window.performance && window.performance.navigation.type === NavigationType.TYPE_BACK_FORWARD; + window.performance && window.performance.navigation.type === navigationType.TYPE_BACK_FORWARD; if (event.persisted || isNavTypeBackForward) { window.location.reload(); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js deleted file mode 100644 index 97e40c8cc39..00000000000 --- a/app/assets/javascripts/boards/stores/actions.js +++ /dev/null @@ -1,925 +0,0 @@ -import { sortBy } from 'lodash'; -import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { - ListType, - inactiveId, - flashAnimationDuration, - ISSUABLE, - titleQueries, - subscriptionQueries, - deleteListQueries, - listsQuery, - updateListQueries, - FilterFields, - ListTypeTitles, - DraggableItemTypes, - DEFAULT_BOARD_LIST_ITEMS_SIZE, -} from 'ee_else_ce/boards/constants'; -import { - formatIssueInput, - formatBoardLists, - formatListIssues, - formatListsPageInfo, - formatIssue, - updateListPosition, - moveItemListHelper, - getMoveData, - FiltersInfo, - filterVariables, -} from 'ee_else_ce/boards/boards_util'; -import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; -import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; -import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; -import { fetchPolicies } from '~/lib/graphql'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; -import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { queryToObject } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; -import eventHub from '../eventhub'; -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'; -import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; -import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; - -import * as types from './mutation_types'; - -export default { - fetchBoard: ({ commit, dispatch }, { fullPath, fullBoardId, boardType }) => { - commit(types.REQUEST_CURRENT_BOARD); - - const variables = { - fullPath, - boardId: fullBoardId, - }; - - return gqlClient - .query({ - query: boardType === WORKSPACE_GROUP ? groupBoardQuery : projectBoardQuery, - variables, - }) - .then(({ data }) => { - if (data.workspace?.errors) { - commit(types.RECEIVE_BOARD_FAILURE); - } else { - const board = data.workspace?.board; - dispatch('setBoard', 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, - iterationCadenceId: board.iterationCadence?.id || 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); - }, - - setBoard: async ({ commit, dispatch }, board) => { - commit(types.RECEIVE_BOARD_SUCCESS, board); - await dispatch('setBoardConfig', board); - dispatch('performSearch', { resetLists: true }); - eventHub.$emit('updateTokens'); - }, - - setActiveId({ commit }, { id, sidebarType }) { - commit(types.SET_ACTIVE_ID, { id, sidebarType }); - }, - - unsetActiveId({ dispatch }) { - dispatch('setActiveId', { id: inactiveId, sidebarType: '' }); - }, - - setFilters: ({ commit, state: { issuableType } }, filters) => { - commit( - types.SET_FILTERS, - filterVariables({ - filters, - issuableType, - filterInfo: FiltersInfo, - filterFields: FilterFields, - }), - ); - }, - - performSearch({ dispatch }, { resetLists = false } = {}) { - dispatch( - 'setFilters', - convertObjectPropsToCamelCase(queryToObject(window.location.search, { gatherArrays: true })), - ); - dispatch('fetchLists', { resetLists }); - dispatch('resetIssues'); - }, - - fetchLists: ({ commit, state, dispatch }, { resetLists = false } = {}) => { - const { boardType, filterParams, fullPath, fullBoardId, issuableType } = state; - - const variables = { - fullPath, - boardId: fullBoardId, - filters: filterParams, - ...(issuableType === TYPE_ISSUE && { - isGroup: boardType === WORKSPACE_GROUP, - isProject: boardType === WORKSPACE_PROJECT, - }), - }; - - return gqlClient - .query({ - query: listsQuery[issuableType].query, - variables, - ...(resetLists ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}), - }) - .then(({ data }) => { - const { lists, hideBacklogList } = data[boardType].board; - commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists)); - // Backlog list needs to be created if it doesn't exist and it's not hidden - if (!lists.nodes.find((l) => l.listType === ListType.backlog) && !hideBacklogList) { - dispatch('createList', { backlog: true }); - } - }) - .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); - }, - - highlightList: ({ commit, state }, listId) => { - if ([ListType.backlog, ListType.closed].includes(state.boardLists[listId].listType)) { - return; - } - - commit(types.ADD_LIST_TO_HIGHLIGHTED_LISTS, listId); - - setTimeout(() => { - commit(types.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS, listId); - }, flashAnimationDuration); - }, - - createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { - dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId }); - }, - - createIssueList: ( - { state, commit, dispatch, getters }, - { backlog, labelId, milestoneId, assigneeId, iterationId }, - ) => { - const { fullBoardId } = state; - - const existingList = getters.getListByLabelId(labelId); - - if (existingList) { - dispatch('highlightList', existingList.id); - return; - } - - gqlClient - .mutate({ - mutation: createBoardListMutation, - variables: { - boardId: fullBoardId, - backlog, - labelId, - milestoneId, - assigneeId, - iterationId, - }, - }) - .then(({ data }) => { - if (data.boardListCreate?.errors.length) { - commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]); - } else { - const list = data.boardListCreate?.list; - dispatch('addList', list); - dispatch('highlightList', list.id); - } - }) - .catch((e) => { - commit(types.CREATE_LIST_FAILURE); - throw e; - }); - }, - - addList: ({ commit, dispatch, getters }, list) => { - commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); - - dispatch('fetchItemsForList', { - listId: getters.getListByTitle(ListTypeTitles.backlog)?.id, - }); - }, - - fetchLabels: ({ state, commit }, searchTerm) => { - const { fullPath, boardType } = state; - - const variables = { - fullPath, - searchTerm, - isGroup: boardType === WORKSPACE_GROUP, - isProject: boardType === WORKSPACE_PROJECT, - }; - - commit(types.RECEIVE_LABELS_REQUEST); - - return gqlClient - .query({ - query: boardLabelsQuery, - variables, - }) - .then(({ data }) => { - const labels = data[boardType]?.labels.nodes; - - commit(types.RECEIVE_LABELS_SUCCESS, labels); - return labels; - }) - .catch((e) => { - commit(types.RECEIVE_LABELS_FAILURE); - throw e; - }); - }, - - fetchMilestones({ state, commit }, searchTerm) { - commit(types.RECEIVE_MILESTONES_REQUEST); - - const { fullPath, boardType } = state; - - const variables = { - fullPath, - searchTerm, - }; - - let query; - if (boardType === WORKSPACE_PROJECT) { - query = projectBoardMilestonesQuery; - } - if (boardType === WORKSPACE_GROUP) { - query = groupBoardMilestonesQuery; - } - - if (!query) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Unknown board type'); - } - - return gqlClient - .query({ - query, - variables, - }) - .then(({ data }) => { - const errors = data.workspace?.errors; - const milestones = data.workspace?.milestones.nodes; - - if (errors?.[0]) { - throw new Error(errors[0]); - } - - commit(types.RECEIVE_MILESTONES_SUCCESS, milestones); - - return milestones; - }) - .catch((e) => { - commit(types.RECEIVE_MILESTONES_FAILURE); - throw e; - }); - }, - - moveList: ( - { state: { boardLists }, commit, dispatch }, - { - item: { - dataset: { listId: movedListId, draggableItemType }, - }, - newIndex, - to: { children }, - }, - ) => { - if (draggableItemType !== DraggableItemTypes.list) { - return; - } - - const displacedListId = children[newIndex].dataset.listId; - if (movedListId === displacedListId) { - return; - } - - const listIds = sortBy( - Object.keys(boardLists).filter( - (listId) => - listId !== movedListId && - boardLists[listId].listType !== ListType.backlog && - boardLists[listId].listType !== ListType.closed, - ), - (i) => boardLists[i].position, - ); - - const targetPosition = boardLists[displacedListId].position; - // When the dragged list moves left, displaced list should shift right. - const shiftOffset = Number(boardLists[movedListId].position < targetPosition); - const displacedListIndex = listIds.findIndex((listId) => listId === displacedListId); - - commit( - types.MOVE_LISTS, - listIds - .slice(0, displacedListIndex + shiftOffset) - .concat([movedListId], listIds.slice(displacedListIndex + shiftOffset)) - .map((listId, index) => ({ listId, position: index })), - ); - dispatch('updateList', { listId: movedListId, position: targetPosition }); - }, - - updateList: ( - { state: { issuableType, boardItemsByListId = {} }, dispatch }, - { listId, position, collapsed }, - ) => { - gqlClient - .mutate({ - mutation: updateListQueries[issuableType].mutation, - variables: { - listId, - position, - collapsed, - }, - }) - .then(({ data }) => { - if (data?.updateBoardList?.errors.length) { - throw new Error(); - } - - // Only fetch when board items havent been fetched on a collapsed list - if (!boardItemsByListId[listId]) { - dispatch('fetchItemsForList', { listId }); - } - }) - .catch(() => { - dispatch('handleUpdateListFailure'); - }); - }, - - handleUpdateListFailure: ({ dispatch, commit }) => { - dispatch('fetchLists'); - - commit( - types.SET_ERROR, - s__('Boards|An error occurred while updating the board list. Please try again.'), - ); - }, - - toggleListCollapsed: ({ commit }, { listId, collapsed }) => { - commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); - }, - - removeList: ({ state: { issuableType, boardLists }, commit, dispatch, getters }, listId) => { - const listsBackup = { ...boardLists }; - - commit(types.REMOVE_LIST, listId); - - return gqlClient - .mutate({ - mutation: deleteListQueries[issuableType].mutation, - variables: { - listId, - }, - }) - .then( - ({ - data: { - destroyBoardList: { errors }, - }, - }) => { - if (errors.length > 0) { - commit(types.REMOVE_LIST_FAILURE, listsBackup); - } else { - dispatch('fetchItemsForList', { - listId: getters.getListByTitle(ListTypeTitles.backlog)?.id, - }); - } - }, - ) - .catch(() => { - commit(types.REMOVE_LIST_FAILURE, listsBackup); - }); - }, - - fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { - if (!listId) return null; - - commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); - - const { fullPath, fullBoardId, boardType, filterParams } = state; - const variables = { - fullPath, - boardId: fullBoardId, - id: listId, - filters: filterParams, - isGroup: boardType === WORKSPACE_GROUP, - isProject: boardType === WORKSPACE_PROJECT, - first: DEFAULT_BOARD_LIST_ITEMS_SIZE, - after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, - }; - - return gqlClient - .query({ - query: listsIssuesQuery, - variables, - ...(!fetchNext ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}), - }) - .then(({ data }) => { - const { lists } = data[boardType].board; - const listItems = formatListIssues(lists); - const listPageInfo = formatListsPageInfo(lists); - commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId }); - }) - .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId)); - }, - - resetIssues: ({ commit }) => { - commit(types.RESET_ISSUES); - }, - - moveItem: ({ dispatch }, payload) => { - dispatch('moveIssue', payload); - }, - - moveIssue: ({ dispatch, state }, params) => { - const moveData = getMoveData(state, params); - - dispatch('moveIssueCard', moveData); - dispatch('updateMovedIssue', moveData); - dispatch('updateIssueOrder', { moveData }); - }, - - moveIssueCard: ({ commit }, moveData) => { - const { - reordering, - shouldClone, - itemNotInToList, - originalIndex, - itemId, - fromListId, - toListId, - moveBeforeId, - moveAfterId, - positionInList, - allItemsLoadedInList, - } = moveData; - - commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); - - if (reordering && !allItemsLoadedInList && positionInList === -1) { - return; - } - - if (reordering) { - commit(types.ADD_BOARD_ITEM_TO_LIST, { - itemId, - listId: toListId, - moveBeforeId, - moveAfterId, - positionInList, - atIndex: originalIndex, - allItemsLoadedInList, - }); - - return; - } - - if (itemNotInToList) { - commit(types.ADD_BOARD_ITEM_TO_LIST, { - itemId, - listId: toListId, - moveBeforeId, - moveAfterId, - positionInList, - }); - } - - if (shouldClone) { - commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); - } - }, - - updateMovedIssue: ( - { commit, state: { boardItems, boardLists } }, - { itemId, fromListId, toListId }, - ) => { - const updatedIssue = moveItemListHelper( - boardItems[itemId], - boardLists[fromListId], - boardLists[toListId], - ); - - commit(types.UPDATE_BOARD_ITEM, updatedIssue); - }, - - undoMoveIssueCard: ({ commit }, moveData) => { - const { - reordering, - shouldClone, - itemNotInToList, - itemId, - fromListId, - toListId, - originalIssue, - originalIndex, - } = moveData; - - commit(types.UPDATE_BOARD_ITEM, originalIssue); - - if (reordering) { - commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); - commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); - return; - } - - if (shouldClone) { - commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); - } - if (itemNotInToList) { - commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: toListId }); - } - - commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); - }, - - updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => { - try { - const { - itemId, - fromListId, - toListId, - moveBeforeId, - moveAfterId, - itemNotInToList, - positionInList, - } = moveData; - const { - fullBoardId, - filterParams, - boardItems: { - [itemId]: { iid, referencePath }, - }, - } = state; - - commit(types.MUTATE_ISSUE_IN_PROGRESS, true); - - const { data } = await gqlClient.mutate({ - mutation: issueMoveListMutation, - variables: { - iid, - projectPath: referencePath.split(/[#]/)[0], - boardId: fullBoardId, - fromListId: getIdFromGraphQLId(fromListId), - toListId: getIdFromGraphQLId(toListId), - moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined, - moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined, - positionInList, - // 'mutationVariables' allows EE code to pass in extra parameters. - ...mutationVariables, - }, - update( - cache, - { - data: { - issuableMoveList: { - issuable: { weight }, - }, - }, - }, - ) { - if (fromListId === toListId) return; - - const updateFromList = () => { - const fromList = cache.readQuery({ - query: totalCountAndWeightQuery, - variables: { id: fromListId, filters: filterParams }, - }); - - const updatedFromList = { - boardList: { - __typename: 'BoardList', - id: fromList.boardList.id, - issuesCount: fromList.boardList.issuesCount - 1, - totalIssueWeight: fromList.boardList.totalIssueWeight - Number(weight), - }, - }; - - cache.writeQuery({ - query: totalCountAndWeightQuery, - variables: { id: fromListId, filters: filterParams }, - data: updatedFromList, - }); - }; - - const updateToList = () => { - if (!itemNotInToList) return; - - const toList = cache.readQuery({ - query: totalCountAndWeightQuery, - variables: { id: toListId, filters: filterParams }, - }); - - const updatedToList = { - boardList: { - __typename: 'BoardList', - id: toList.boardList.id, - issuesCount: toList.boardList.issuesCount + 1, - totalIssueWeight: toList.boardList.totalIssueWeight + Number(weight), - }, - }; - - cache.writeQuery({ - query: totalCountAndWeightQuery, - variables: { id: toListId, filters: filterParams }, - data: updatedToList, - }); - }; - - updateFromList(); - updateToList(); - }, - }); - - if (data?.issuableMoveList?.errors.length || !data.issuableMoveList) { - throw new Error('issueMoveList empty'); - } - - commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issuableMoveList.issuable }); - commit(types.MUTATE_ISSUE_IN_PROGRESS, false); - } catch { - commit(types.MUTATE_ISSUE_IN_PROGRESS, false); - commit( - types.SET_ERROR, - s__('Boards|An error occurred while moving the issue. Please try again.'), - ); - dispatch('undoMoveIssueCard', moveData); - } - }, - - setAssignees: ({ commit }, { id, assignees }) => { - commit('UPDATE_BOARD_ITEM_BY_ID', { - itemId: id, - prop: 'assignees', - value: assignees, - }); - }, - - addListItem: ({ commit, dispatch }, { list, item, position, inProgress = false }) => { - commit(types.ADD_BOARD_ITEM_TO_LIST, { - listId: list.id, - itemId: item.id, - atIndex: position, - inProgress, - }); - commit(types.UPDATE_BOARD_ITEM, item); - if (!inProgress) { - dispatch('setActiveId', { id: item.id, sidebarType: ISSUABLE }); - } - }, - - removeListItem: ({ commit }, { listId, itemId }) => { - commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { listId, itemId }); - commit(types.REMOVE_BOARD_ITEM, itemId); - }, - - addListNewIssue: ( - { state: { boardConfig, boardType, fullPath, filterParams }, dispatch, commit }, - { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` }, - ) => { - const input = formatIssueInput(issueInput, boardConfig); - - if (boardType === WORKSPACE_PROJECT) { - input.projectPath = fullPath; - } - - const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId, isLoading: true }); - dispatch('addListItem', { list, item: placeholderIssue, position: 0, inProgress: true }); - - gqlClient - .mutate({ - mutation: issueCreateMutation, - variables: { input }, - update(cache) { - const fromList = cache.readQuery({ - query: totalCountAndWeightQuery, - variables: { id: list.id, filters: filterParams }, - }); - - const updatedList = { - boardList: { - __typename: 'BoardList', - id: fromList.boardList.id, - issuesCount: fromList.boardList.issuesCount + 1, - totalIssueWeight: fromList.boardList.totalIssueWeight, - }, - }; - - cache.writeQuery({ - query: totalCountAndWeightQuery, - variables: { id: list.id, filters: filterParams }, - data: updatedList, - }); - }, - }) - .then(({ data }) => { - if (data.createIssuable.errors.length) { - throw new Error(); - } - - const rawIssue = data.createIssuable?.issuable; - const formattedIssue = formatIssue(rawIssue); - dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); - dispatch('addListItem', { list, item: formattedIssue, position: 0 }); - }) - .catch(() => { - dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); - commit( - types.SET_ERROR, - s__('Boards|An error occurred while creating the issue. Please try again.'), - ); - }); - }, - - setActiveBoardItemLabels: ({ dispatch }, params) => { - dispatch('setActiveIssueLabels', params); - }, - - setActiveIssueLabels: async ({ commit, getters }, input) => { - const { activeBoardItem } = getters; - - let labels = input?.labels || []; - if (input.removeLabelIds) { - labels = activeBoardItem.labels.filter( - (label) => input.removeLabelIds[0] !== getIdFromGraphQLId(label.id), - ); - } - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: input.id || activeBoardItem.id, - prop: 'labels', - value: labels, - }); - }, - - setActiveItemSubscribed: async ({ commit, getters, state }, input) => { - const { activeBoardItem, isEpicBoard } = getters; - const { fullPath, issuableType } = state; - const workspacePath = isEpicBoard - ? { groupPath: fullPath } - : { projectPath: input.projectPath }; - const { data } = await gqlClient.mutate({ - mutation: subscriptionQueries[issuableType].mutation, - variables: { - input: { - ...workspacePath, - iid: String(activeBoardItem.iid), - subscribedState: input.subscribed, - }, - }, - }); - - if (data.updateIssuableSubscription?.errors?.length > 0) { - throw new Error(data.updateIssuableSubscription[issuableType].errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'subscribed', - value: data.updateIssuableSubscription[issuableType].subscribed, - }); - }, - - setActiveItemTitle: async ({ commit, getters, state }, input) => { - const { activeBoardItem, isEpicBoard } = getters; - const { fullPath, issuableType } = state; - const workspacePath = isEpicBoard - ? { groupPath: fullPath } - : { projectPath: input.projectPath }; - const { data } = await gqlClient.mutate({ - mutation: titleQueries[issuableType].mutation, - variables: { - input: { - ...workspacePath, - iid: String(activeBoardItem.iid), - title: input.title, - }, - }, - }); - - if (data.updateIssuableTitle?.errors?.length > 0) { - throw new Error(data.updateIssuableTitle.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'title', - value: data.updateIssuableTitle[issuableType].title, - }); - }, - - setActiveItemConfidential: ({ commit, getters }, confidential) => { - const { activeBoardItem } = getters; - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'confidential', - value: confidential, - }); - }, - - fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => { - commit(types.REQUEST_GROUP_PROJECTS, fetchNext); - - const { fullPath } = state; - - const variables = { - fullPath, - search: search !== '' ? search : undefined, - after: fetchNext ? state.groupProjectsFlags.pageInfo.endCursor : undefined, - }; - - return gqlClient - .query({ - query: groupProjectsQuery, - variables, - }) - .then(({ data }) => { - const { projects } = data.group; - commit(types.RECEIVE_GROUP_PROJECTS_SUCCESS, { - projects: projects.nodes, - pageInfo: projects.pageInfo, - fetchNext, - }); - }) - .catch(() => commit(types.RECEIVE_GROUP_PROJECTS_FAILURE)); - }, - - setSelectedProject: ({ commit }, project) => { - commit(types.SET_SELECTED_PROJECT, project); - }, - - toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => { - const { selectedBoardItems } = state; - const index = selectedBoardItems.indexOf(boardItem); - - // If user already selected an item (activeBoardItem) without using mult-select, - // include that item in the selection and unset state.ActiveId to hide the sidebar. - if (getters.activeBoardItem) { - commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeBoardItem); - dispatch('unsetActiveId'); - } - - if (index === -1) { - commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem); - } else { - commit(types.REMOVE_BOARD_ITEM_FROM_SELECTION, boardItem); - } - }, - - setAddColumnFormVisibility: ({ commit }, visible) => { - commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible); - }, - - resetBoardItemMultiSelection: ({ commit }) => { - commit(types.RESET_BOARD_ITEM_SELECTION); - }, - - toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => { - dispatch('resetBoardItemMultiSelection'); - - if (boardItem.id === state.activeId) { - dispatch('unsetActiveId'); - } else { - dispatch('setActiveId', { id: boardItem.id, sidebarType }); - } - }, - - setError: ({ commit }, { message, error, captureError = true }) => { - commit(types.SET_ERROR, message); - - if (captureError) { - Sentry.captureException(error); - } - }, - - unsetError: ({ commit }) => { - commit(types.SET_ERROR, undefined); - }, - - // EE action needs CE empty equivalent - setActiveItemWeight: () => {}, - setActiveItemHealthStatus: () => {}, -}; diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js deleted file mode 100644 index 0ad71165996..00000000000 --- a/app/assets/javascripts/boards/stores/getters.js +++ /dev/null @@ -1,67 +0,0 @@ -import { find } from 'lodash'; -import { TYPE_ISSUE } from '~/issues/constants'; -import { inactiveId } from '../constants'; - -export default { - isSidebarOpen: (state) => state.activeId !== inactiveId, - isSwimlanesOn: () => false, - getBoardItemById: (state) => (id) => { - return state.boardItems[id] || {}; - }, - - getBoardItemsByList: (state, getters) => (listId) => { - const listItemsIds = state.boardItemsByListId[listId] || []; - return listItemsIds.map((id) => getters.getBoardItemById(id)); - }, - - activeBoardItem: (state) => { - return state.boardItems[state.activeId] || { iid: '', id: '' }; - }, - - groupPathForActiveIssue: (_, getters) => { - const { referencePath = '' } = getters.activeBoardItem; - return referencePath.slice(0, referencePath.lastIndexOf('/')); - }, - - projectPathForActiveIssue: (_, getters) => { - const { referencePath = '' } = getters.activeBoardItem; - return referencePath.slice(0, referencePath.indexOf('#')); - }, - - activeGroupProjects: (state) => { - return state.groupProjects.filter((p) => !p.archived); - }, - - getListByLabelId: (state) => (labelId) => { - if (!labelId) { - return null; - } - return find(state.boardLists, (l) => l.label?.id === labelId); - }, - - getListByTitle: (state) => (title) => { - return find(state.boardLists, (l) => l.title === title); - }, - - isIssueBoard: (state) => { - return state.issuableType === TYPE_ISSUE; - }, - - isEpicBoard: () => { - return false; - }, - - hasScope: (state) => { - const { boardConfig } = state; - if (boardConfig.labels?.length > 0) { - return true; - } - let hasScope = false; - ['assigneeId', 'iterationCadenceId', 'iterationId', 'milestoneId', 'weight'].forEach((attr) => { - if (boardConfig[attr] !== null && boardConfig[attr] !== undefined) { - hasScope = true; - } - }); - return hasScope; - }, -}; diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js deleted file mode 100644 index fd562df1df7..00000000000 --- a/app/assets/javascripts/boards/stores/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import actions from 'ee_else_ce/boards/stores/actions'; -import getters from 'ee_else_ce/boards/stores/getters'; -import mutations from 'ee_else_ce/boards/stores/mutations'; -import state from 'ee_else_ce/boards/stores/state'; - -Vue.use(Vuex); - -export const storeOptions = { - state, - getters, - actions, - mutations, -}; - -export const createStore = (options = storeOptions) => new Vuex.Store(options); - -export default createStore(); diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js deleted file mode 100644 index 0e496677b7b..00000000000 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ /dev/null @@ -1,47 +0,0 @@ -export const REQUEST_CURRENT_BOARD = 'REQUEST_CURRENT_BOARD'; -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'; -export const RECEIVE_LABELS_REQUEST = 'RECEIVE_LABELS_REQUEST'; -export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; -export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; -export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE'; -export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; -export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; -export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; -export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; -export const MOVE_LISTS = 'MOVE_LISTS'; -export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; -export const REMOVE_LIST = 'REMOVE_LIST'; -export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; -export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; -export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; -export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; -export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST'; -export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; -export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE'; -export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM'; -export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM'; -export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS'; -export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; -export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; -export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; -export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; -export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; -export const RESET_ISSUES = 'RESET_ISSUES'; -export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; -export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS'; -export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE'; -export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT'; -export const ADD_BOARD_ITEM_TO_SELECTION = 'ADD_BOARD_ITEM_TO_SELECTION'; -export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTION'; -export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; -export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; -export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; -export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; -export const SET_ERROR = 'SET_ERROR'; -export const MUTATE_ISSUE_IN_PROGRESS = 'MUTATE_ISSUE_IN_PROGRESS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js deleted file mode 100644 index 505c011b034..00000000000 --- a/app/assets/javascripts/boards/stores/mutations.js +++ /dev/null @@ -1,319 +0,0 @@ -import { cloneDeep, pull, union } from 'lodash'; -import Vue from 'vue'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_EPIC } from '~/issues/constants'; -import { s__, __ } from '~/locale'; -import { formatIssue } from '../boards_util'; -import * as mutationTypes from './mutation_types'; - -const updateListItemsCount = ({ state, listId, value }) => { - const list = state.boardLists[listId]; - if (state.issuableType === TYPE_EPIC) { - const listItem = cloneDeep(state.boardLists[listId]); - listItem.metadataepicsCount += value; - Vue.set(state.boardLists[listId], listId, listItem); - } - Vue.set(state.boardLists, listId, { ...list }); -}; - -export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => { - Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId)); - if (!reordering) { - updateListItemsCount({ state, listId, value: -1 }); - } -}; - -export const addItemToList = ({ - state, - listId, - itemId, - moveBeforeId, - moveAfterId, - atIndex, - positionInList, - reordering = false, -}) => { - const listIssues = state.boardItemsByListId[listId]; - let newIndex = atIndex || 0; - const moveToStartOrLast = positionInList !== undefined; - if (moveBeforeId) { - newIndex = listIssues.indexOf(moveBeforeId) + 1; - } else if (moveAfterId) { - newIndex = listIssues.indexOf(moveAfterId); - } else if (moveToStartOrLast) { - newIndex = positionInList === -1 ? listIssues.length : 0; - } - listIssues.splice(newIndex, 0, itemId); - Vue.set(state.boardItemsByListId, listId, listIssues); - if (!reordering) { - updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 }); - } -}; - -export default { - [mutationTypes.REQUEST_CURRENT_BOARD]: (state) => { - state.isBoardLoading = true; - }, - - [mutationTypes.RECEIVE_BOARD_SUCCESS]: (state, board) => { - state.board = { - ...board, - labels: board?.labels?.nodes || [], - }; - state.fullBoardId = board.id; - state.boardId = getIdFromGraphQLId(board.id); - state.isBoardLoading = false; - }, - - [mutationTypes.RECEIVE_BOARD_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while fetching the board. Please reload the page.'); - state.isBoardLoading = false; - }, - - [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { - allowSubEpics, - boardId, - boardType, - disabled, - fullBoardId, - fullPath, - issuableType, - } = data; - state.allowSubEpics = allowSubEpics; - state.boardId = boardId; - state.boardType = boardType; - state.disabled = disabled; - state.fullBoardId = fullBoardId; - state.fullPath = fullPath; - state.issuableType = issuableType; - }, - - [mutationTypes.SET_BOARD_CONFIG](state, boardConfig) { - state.boardConfig = boardConfig; - }, - - [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { - state.boardLists = lists; - }, - - [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: (state) => { - state.error = s__( - 'Boards|An error occurred while fetching the board lists. Please reload the page.', - ); - }, - - [mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) { - state.activeId = id; - state.sidebarType = sidebarType; - }, - - [mutationTypes.SET_FILTERS](state, filterParams) { - state.filterParams = filterParams; - }, - - [mutationTypes.CREATE_LIST_FAILURE]: ( - state, - error = s__('Boards|An error occurred while creating the list. Please try again.'), - ) => { - state.error = error; - }, - - [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => { - state.labelsLoading = true; - }, - - [mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => { - state.labels = labels; - state.labelsLoading = false; - }, - - [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); - state.labelsLoading = false; - }, - - [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while generating lists. Please reload the page.'); - }, - - [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => { - Vue.set(state.boardLists, list.id, list); - }, - - [mutationTypes.MOVE_LISTS]: (state, movedLists) => { - const updatedBoardList = movedLists.reduce((acc, { listId, position }) => { - acc[listId].position = position; - return acc; - }, cloneDeep(state.boardLists)); - Vue.set(state, 'boardLists', updatedBoardList); - }, - - [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => { - Vue.set(state.boardLists[listId], 'collapsed', collapsed); - }, - - [mutationTypes.REMOVE_LIST]: (state, listId) => { - Vue.delete(state.boardLists, listId); - }, - - [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) { - state.error = s__('Boards|An error occurred while removing the list. Please try again.'); - state.boardLists = listsBackup; - }, - - [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => { - Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); - }, - - [mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) { - state.milestones = milestones; - state.milestonesLoading = false; - }, - - [mutationTypes.RECEIVE_MILESTONES_REQUEST](state) { - state.milestonesLoading = true; - }, - - [mutationTypes.RECEIVE_MILESTONES_FAILURE](state) { - state.milestonesLoading = false; - state.error = __('Failed to load milestones.'); - }, - - [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => { - const { listData, boardItems } = listItems; - Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems }); - Vue.set( - state.boardItemsByListId, - listId, - union(state.boardItemsByListId[listId] || [], listData[listId]), - ); - Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); - Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); - }, - - [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => { - state.error = s__( - 'Boards|An error occurred while fetching the board issues. Please reload the page.', - ); - Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); - }, - - [mutationTypes.RESET_ISSUES]: (state) => { - Object.keys(state.boardItemsByListId).forEach((listId) => { - Vue.set(state.boardItemsByListId, listId, []); - }); - }, - - [mutationTypes.UPDATE_BOARD_ITEM_BY_ID]: (state, { itemId, prop, value }) => { - if (!state.boardItems[itemId]) { - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - throw new Error('No issue found.'); - } - - Vue.set(state.boardItems[itemId], prop, value); - }, - - [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { - state.isSettingAssignees = isLoading; - }, - - [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => { - Vue.set(state.boardItems, issue.id, formatIssue(issue)); - }, - - [mutationTypes.MUTATE_ISSUE_IN_PROGRESS](state, isLoading) { - state.isUpdateIssueOrderInProgress = isLoading; - }, - - [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( - state, - { - itemId, - listId, - moveBeforeId, - moveAfterId, - atIndex, - positionInList, - allItemsLoadedInList, - inProgress = false, - }, - ) => { - Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress }); - addItemToList({ - state, - listId, - itemId, - moveBeforeId, - moveAfterId, - atIndex, - positionInList, - allItemsLoadedInList, - }); - }, - - [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => { - removeItemFromList({ state, listId, itemId }); - }, - - [mutationTypes.UPDATE_BOARD_ITEM]: (state, item) => { - Vue.set(state.boardItems, item.id, item); - }, - - [mutationTypes.REMOVE_BOARD_ITEM]: (state, itemId) => { - Vue.delete(state.boardItems, itemId); - }, - - [mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => { - Vue.set(state, 'groupProjectsFlags', { - [fetchNext ? 'isLoadingMore' : 'isLoading']: true, - pageInfo: state.groupProjectsFlags.pageInfo, - }); - }, - - [mutationTypes.RECEIVE_GROUP_PROJECTS_SUCCESS]: (state, { projects, pageInfo, fetchNext }) => { - Vue.set(state, 'groupProjects', fetchNext ? [...state.groupProjects, ...projects] : projects); - Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false, pageInfo }); - }, - - [mutationTypes.RECEIVE_GROUP_PROJECTS_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while fetching group projects. Please try again.'); - Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false }); - }, - - [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => { - state.selectedProject = project; - }, - - [mutationTypes.ADD_BOARD_ITEM_TO_SELECTION]: (state, boardItem) => { - state.selectedBoardItems = [...state.selectedBoardItems, boardItem]; - }, - - [mutationTypes.REMOVE_BOARD_ITEM_FROM_SELECTION]: (state, boardItem) => { - Vue.set( - state, - 'selectedBoardItems', - state.selectedBoardItems.filter((obj) => obj !== boardItem), - ); - }, - - [mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => { - Vue.set(state.addColumnForm, 'visible', visible); - }, - - [mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => { - state.highlightedLists.push(listId); - }, - - [mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => { - state.highlightedLists = state.highlightedLists.filter((id) => id !== listId); - }, - - [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { - state.selectedBoardItems = []; - }, - - [mutationTypes.SET_ERROR]: (state, error) => { - state.error = error; - }, -}; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js deleted file mode 100644 index bf3f777ea7d..00000000000 --- a/app/assets/javascripts/boards/stores/state.js +++ /dev/null @@ -1,44 +0,0 @@ -import { inactiveId, ListType } from '~/boards/constants'; - -export default () => ({ - board: {}, - isBoardLoading: false, - boardType: null, - issuableType: null, - fullPath: null, - disabled: false, - isShowingLabels: true, - activeId: inactiveId, - sidebarType: '', - boardLists: {}, - listsFlags: {}, - boardItemsByListId: {}, - isSettingAssignees: false, - pageInfoByListId: {}, - boardItems: {}, - filterParams: {}, - boardConfig: {}, - labelsLoading: false, - labels: [], - milestones: [], - milestonesLoading: false, - highlightedLists: [], - selectedBoardItems: [], - groupProjects: [], - groupProjectsFlags: { - isLoading: false, - isLoadingMore: false, - pageInfo: {}, - }, - selectedProject: {}, - error: undefined, - iterations: [], - iterationsLoading: false, - addColumnForm: { - visible: false, - columnType: ListType.label, - }, - // TODO: remove after ce/ee split of board_content.vue - isShowingEpicsSwimlanes: false, - isUpdateIssueOrderInProgress: false, -}); diff --git a/app/assets/javascripts/branches/components/graph_bar.vue b/app/assets/javascripts/branches/components/graph_bar.vue index 21cbcac820a..885db7651a1 100644 --- a/app/assets/javascripts/branches/components/graph_bar.vue +++ b/app/assets/javascripts/branches/components/graph_bar.vue @@ -62,7 +62,7 @@ export default { :class="[roundedClass, positionSideClass]" class="position-absolute bar js-graph-bar" ></div> - <span :class="textAlignmentClass" class="d-block pt-1 pr-1 count js-graph-count"> + <span :class="textAlignmentClass" class="gl-display-block gl-pt-1 gl-px-1 count js-graph-count"> {{ label }} </span> </div> diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js index 0dacd5af5cc..b06a6f8b141 100644 --- a/app/assets/javascripts/breadcrumb.js +++ b/app/assets/javascripts/breadcrumb.js @@ -34,7 +34,7 @@ export default () => { $('li.expander').remove(); // set focus on first breadcrumb item - $('.breadcrumb-item-text').first().focus(); + $('.js-breadcrumb-item-text').first().focus(); }); } }; diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue index 36aa098d5ff..fd37e498699 100644 --- a/app/assets/javascripts/captcha/captcha_modal.vue +++ b/app/assets/javascripts/captcha/captcha_modal.vue @@ -26,10 +26,21 @@ export default { type: String, required: true, }, + showModal: { + type: Boolean, + required: false, + default: true, + }, + resetSession: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { modalId: uniqueId('captcha-modal-'), + captcha: null, }; }, watch: { @@ -37,29 +48,44 @@ export default { // If this is true, we need to present the captcha modal to the user. // When the modal is shown we will also initialize and render the form. if (newNeedsCaptchaResponse) { - this.$refs.modal.show(); + this.renderCaptcha(); } }, + resetSession: { + immediate: true, + handler(reset) { + if (reset && this.captcha) { + this.resetCaptcha(); + } + }, + }, }, mounted() { // If this is true, we need to present the captcha modal to the user. // When the modal is shown we will also initialize and render the form. if (this.needsCaptchaResponse) { - this.$refs.modal.show(); + this.renderCaptcha(); } }, methods: { emitReceivedCaptchaResponse(captchaResponse) { - this.$refs.modal.hide(); + if (this.showModal) this.$refs.modal.hide(); this.$emit('receivedCaptchaResponse', captchaResponse); }, emitNullReceivedCaptchaResponse() { this.emitReceivedCaptchaResponse(null); }, + renderCaptcha() { + if (this.showModal) { + this.$refs.modal.show(); + } else { + this.initCaptcha(); + } + }, /** * handler for when modal is shown */ - shown() { + initCaptcha() { const containerRef = this.$refs.captcha; // NOTE: This is the only bit that is specific to Google's reCAPTCHA captcha implementation. @@ -72,12 +98,13 @@ export default { // TODO: Also need to handle expired-callback and error-callback // See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs }); + + this.captcha = grecaptcha; }) .catch((e) => { // TODO: flash the error or notify the user some other way // See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs this.emitNullReceivedCaptchaResponse(); - this.$refs.modal.hide(); // eslint-disable-next-line no-console console.error( @@ -96,6 +123,9 @@ export default { this.emitNullReceivedCaptchaResponse(); } }, + resetCaptcha() { + this.captcha.reset(); + }, }, }; </script> @@ -104,17 +134,19 @@ export default { <!-- there must be at least one button or focusable element, or the gl-modal fails to render. --> <!-- We could modify gl-model to remove this requirement. --> <gl-modal + v-if="showModal" ref="modal" :modal-id="modalId" :title="__('Please solve the captcha')" :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: __('Cancel'), } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - @shown="shown" + @shown="initCaptcha" @hide="hide" @hidden="$emit('hidden')" > <div ref="captcha"></div> <p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p> </gl-modal> + <div v-else ref="captcha" class="gl-display-inline-block"></div> </template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue index 6d062d8b7f1..7085397c649 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue @@ -106,7 +106,7 @@ export default { <div class="gl-display-flex"> <pre class="gl-w-85p gl-py-4 gl-display-flex gl-justify-content-space-between gl-m-0 gl-border-r-none" - ><span>{{ generateSnippet(component.path) }}</span> + ><span>{{ generateSnippet(component.includePath) }}</span> </pre> <div class="gl--flex-center gl-bg-gray-10 gl-border gl-border-l-none"> <gl-button @@ -115,7 +115,7 @@ export default { icon="copy-to-clipboard" size="small" :title="$options.i18n.copyText" - :data-clipboard-text="generateSnippet(component.path)" + :data-clipboard-text="generateSnippet(component.includePath)" data-testid="copy-to-clipboard" :aria-label="$options.i18n.copyAriaText" /> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue index b9d6173a777..929175b964f 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue @@ -49,7 +49,7 @@ export default { return getIdFromGraphQLId(this.resource.id); }, hasLatestVersion() { - return this.latestVersion?.tagName; + return this.latestVersion?.name; }, hasPipelineStatus() { return this.pipelineStatus?.text; @@ -58,7 +58,7 @@ export default { return this.resource.latestVersion; }, versionBadgeText() { - return this.latestVersion.tagName; + return this.latestVersion.name; }, webPath() { return cleanLeadingSeparator(this.resource?.webPath); @@ -92,7 +92,7 @@ export default { v-if="hasLatestVersion" size="sm" class="gl-ml-3 gl-my-1" - :href="latestVersion.tagPath" + :href="latestVersion.path" > {{ versionBadgeText }} </gl-badge> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue index 343b555c4d8..ccef50e469d 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue @@ -50,6 +50,6 @@ export default { <template> <div> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> - <div v-else v-safe-html="readmeHtml"></div> + <div v-else v-safe-html="readmeHtml" class="md"></div> </div> </template> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue index 3a9ec341789..001e3ec3720 100644 --- a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue @@ -1,5 +1,6 @@ <script> import { GlBanner, GlLink } from '@gitlab/ui'; +import ChatBubbleSvg from '@gitlab/svgs/dist/illustrations/chat-sm.svg?url'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; @@ -44,6 +45,7 @@ export default { learnMore: __('Learn more'), }, learnMorePath: helpPagePath('ci/components/index'), + ChatBubbleSvg, }; </script> <template> @@ -54,6 +56,7 @@ export default { :title="$options.i18n.banner.title" :button-text="$options.i18n.banner.btnText" button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/407556" + :svg-path="$options.ChatBubbleSvg" @close="handleDismissBanner" > <p> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue index e074cfda6f7..1319f204573 100644 --- a/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue @@ -1,5 +1,5 @@ <script> -import { GlSearchBoxByClick, GlSorting, GlSortingItem } from '@gitlab/ui'; +import { GlSearchBoxByClick, GlSorting } from '@gitlab/ui'; import { __ } from '~/locale'; import { SORT_ASC, SORT_DESC, SORT_OPTION_CREATED } from '../../constants'; @@ -7,7 +7,6 @@ export default { components: { GlSearchBoxByClick, GlSorting, - GlSortingItem, }, data() { return { @@ -25,7 +24,7 @@ export default { }, currentSortText() { const currentSort = this.$options.sortOptions.find( - (sort) => sort.key === this.currentSortOption, + (sort) => sort.value === this.currentSortOption, ); return currentSort.text; }, @@ -36,9 +35,6 @@ export default { }, }, methods: { - isActiveSort(sortItem) { - return sortItem === this.currentSortOption; - }, onClear() { this.$emit('update-search-term', ''); }, @@ -49,10 +45,10 @@ export default { this.$emit('update-search-term', this.searchTerm); }, setSelectedSortOption(sortingItem) { - this.currentSortOption = sortingItem.key; + this.currentSortOption = sortingItem; }, }, - sortOptions: [{ key: SORT_OPTION_CREATED, text: __('Created at') }], + sortOptions: [{ value: SORT_OPTION_CREATED, text: __('Created at') }], }; </script> <template> @@ -66,16 +62,10 @@ export default { <gl-sorting :is-ascending="isAscending" :text="currentSortText" + :sort-options="$options.sortOptions" + :sort-by="currentSortOption" + @sortByChange="setSelectedSortOption" @sortDirectionChange="onSortDirectionChange" - > - <gl-sorting-item - v-for="sortingItem in $options.sortOptions" - :key="sortingItem.key" - :active="isActiveSort(sortingItem.key)" - @click="setSelectedSortOption(sortingItem)" - > - {{ sortingItem.text }} - </gl-sorting-item> - </gl-sorting> + /> </div> </template> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue new file mode 100644 index 00000000000..f43255ab76b --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue @@ -0,0 +1,67 @@ +<script> +import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { SCOPE } from '../../constants'; + +export default { + components: { + GlBadge, + GlTab, + GlTabs, + GlLoadingIcon, + }, + props: { + isLoading: { + type: Boolean, + required: true, + }, + resourceCounts: { + type: Object, + required: true, + }, + }, + computed: { + tabs() { + return [ + { + text: s__('CiCatalog|All'), + scope: SCOPE.all, + testId: 'resources-all-tab', + count: this.resourceCounts.all, + }, + { + text: s__('CiCatalog|Your resources'), + scope: SCOPE.namespaces, + testId: 'resources-your-tab', + count: this.resourceCounts.namespaces, + }, + ]; + }, + showLoadingIcon() { + return this.isLoading; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex align-items-lg-center"> + <gl-tabs content-class="gl-py-0" class="gl-w-full"> + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :data-testid="tab.testId" + @click="$emit('setScope', tab.scope)" + > + <template #title> + <span>{{ tab.text }}</span> + <gl-loading-icon v-if="showLoadingIcon" class="gl-ml-3" /> + + <gl-badge v-else size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue index 57d19af614f..42f8cea8727 100644 --- a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue +++ b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue @@ -67,8 +67,8 @@ export default { releasedAt() { return getTimeago().format(this.latestVersion?.releasedAt); }, - tagName() { - return this.latestVersion?.tagName || this.$options.i18n.unreleased; + name() { + return this.latestVersion?.name || this.$options.i18n.unreleased; }, webPath() { return cleanLeadingSeparator(this.resource?.webPath); @@ -117,7 +117,7 @@ export default { <b> {{ resource.name }}</b> </gl-link> <div class="gl-display-flex gl-flex-grow-1 gl-md-justify-content-space-between"> - <gl-badge size="sm" class="gl-h-5 gl-align-self-center">{{ tagName }}</gl-badge> + <gl-badge size="sm" class="gl-h-5 gl-align-self-center">{{ name }}</gl-badge> <span class="gl-display-flex gl-align-items-center gl-ml-5"> <span v-gl-tooltip.top diff --git a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue index e1c86f38d7e..08500d3093c 100644 --- a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue +++ b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue @@ -3,6 +3,7 @@ import { createAlert } from '~/alert'; import { s__ } from '~/locale'; import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings'; import CatalogSearch from '../list/catalog_search.vue'; +import CatalogTabs from '../list/catalog_tabs.vue'; import CiResourcesList from '../list/ci_resources_list.vue'; import CatalogListSkeletonLoader from '../list/catalog_list_skeleton_loader.vue'; import CatalogHeader from '../list/catalog_header.vue'; @@ -10,29 +11,60 @@ import EmptyState from '../list/empty_state.vue'; import getCatalogResources from '../../graphql/queries/get_ci_catalog_resources.query.graphql'; import getCurrentPage from '../../graphql/queries/client/get_current_page.query.graphql'; import updateCurrentPageMutation from '../../graphql/mutations/client/update_current_page.mutation.graphql'; +import getCatalogResourcesCount from '../../graphql/queries/get_ci_catalog_resources_count.query.graphql'; +import { DEFAULT_SORT_VALUE, SCOPE } from '../../constants'; export default { + i18n: { + fetchError: s__('CiCatalog|There was an error fetching CI/CD Catalog resources.'), + countFetchError: s__('CiCatalog|There was an error fetching the CI/CD Catalog resource count.'), + }, components: { CatalogHeader, CatalogListSkeletonLoader, CatalogSearch, + CatalogTabs, CiResourcesList, EmptyState, }, data() { return { catalogResources: [], + catalogResourcesCount: { all: 0, namespaces: 0 }, currentPage: 1, pageInfo: {}, - searchTerm: '', - totalCount: 0, + scope: SCOPE.all, + searchTerm: null, + sortValue: DEFAULT_SORT_VALUE, }; }, apollo: { + catalogResourcesCount: { + query: getCatalogResourcesCount, + variables() { + return { + searchTerm: this.searchTerm, + }; + }, + update({ namespaces, all }) { + return { + namespaces: namespaces.count, + all: all.count, + }; + }, + error(e) { + createAlert({ + message: e.message || this.$options.i18n.countFetchError, + }); + }, + }, catalogResources: { query: getCatalogResources, variables() { return { + scope: this.scope, + searchTerm: this.searchTerm, + sortValue: this.sortValue, first: ciCatalogResourcesItemsCount, }; }, @@ -42,10 +74,9 @@ export default { result({ data }) { const { pageInfo } = data?.ciCatalogResources || {}; this.pageInfo = pageInfo; - this.totalCount = data?.ciCatalogResources?.count || 0; }, error(e) { - createAlert({ message: e.message || this.$options.i18n.fetchError, variant: 'danger' }); + createAlert({ message: e.message || this.$options.i18n.fetchError }); }, }, currentPage: { @@ -62,11 +93,14 @@ export default { isLoading() { return this.$apollo.queries.catalogResources.loading; }, - isSearching() { - return this.searchTerm?.length > 0; + isLoadingCounts() { + return this.$apollo.queries.catalogResourcesCount.loading; + }, + namespacesCount() { + return this.catalogResourcesCount.namespaces; }, - showEmptyState() { - return !this.hasResources && !this.isSearching; + currentTabTotalCount() { + return this.catalogResourcesCount[this.scope.toLowerCase()]; }, }, methods: { @@ -103,6 +137,11 @@ export default { createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); } }, + handleSetScope(scope) { + if (this.scope === scope) return; + + this.scope = scope; + }, updatePageCount(pageNumber) { this.$apollo.mutate({ mutation: updateCurrentPageMutation, @@ -120,30 +159,28 @@ export default { onUpdateSearchTerm(searchTerm) { this.searchTerm = !searchTerm.length ? null : searchTerm; this.resetPageCount(); - this.$apollo.queries.catalogResources.refetch({ - searchTerm: this.searchTerm, - }); }, onUpdateSorting(sortValue) { + this.sortValue = sortValue; this.resetPageCount(); - this.$apollo.queries.catalogResources.refetch({ - sortValue, - }); }, resetPageCount() { this.updatePageCount(1); }, }, - i18n: { - fetchError: s__('CiCatalog|There was an error fetching CI/CD Catalog resources.'), - }, }; </script> <template> <div> <catalog-header /> + <catalog-tabs + :is-loading="isLoadingCounts" + :resource-counts="catalogResourcesCount" + class="gl-mb-3" + @setScope="handleSetScope" + /> <catalog-search - class="gl-py-4 gl-border-b-1 gl-border-gray-100 gl-border-b-solid gl-border-t-1 gl-border-t-solid" + class="gl-py-2" @update-search-term="onUpdateSearchTerm" @update-sorting="onUpdateSorting" /> @@ -156,7 +193,7 @@ export default { :prev-text="__('Prev')" :next-text="__('Next')" :resources="catalogResources" - :total-count="totalCount" + :total-count="currentTabTotalCount" @onPrevPage="handlePrevPage" @onNextPage="handleNextPage" /> diff --git a/app/assets/javascripts/ci/catalog/constants.js b/app/assets/javascripts/ci/catalog/constants.js index 34c0ac797c1..a180aa84344 100644 --- a/app/assets/javascripts/ci/catalog/constants.js +++ b/app/assets/javascripts/ci/catalog/constants.js @@ -2,8 +2,14 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export const CATALOG_FEEDBACK_DISMISSED_KEY = 'catalog_feedback_dismissed'; +export const SCOPE = { + all: 'ALL', + namespaces: 'NAMESPACES', +}; + export const SORT_OPTION_CREATED = 'CREATED'; export const SORT_ASC = 'ASC'; export const SORT_DESC = 'DESC'; +export const DEFAULT_SORT_VALUE = `${SORT_OPTION_CREATED}_${SORT_DESC}`; export const COMPONENTS_DOCS_URL = helpPagePath('ci/components/index'); diff --git a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql index b3a750e9604..316308e96d7 100644 --- a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql @@ -7,8 +7,8 @@ fragment CatalogResourceFields on CiCatalogResource { starCount latestVersion { id - tagName - tagPath + name + path releasedAt author { id diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql index c3b73ebf248..c3b73ebf248 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql index fa42b081a5f..fa42b081a5f 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql index 41ac72aa9de..bf1edf1af6e 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql @@ -8,7 +8,7 @@ query getCiCatalogResourceComponents($fullPath: ID!) { nodes { id name - path + includePath inputs { name required diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql index a77e8f12d03..efc8aa777d4 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql @@ -22,7 +22,7 @@ query getCiCatalogResourceDetails($fullPath: ID!) { } } } - tagName + name releasedAt } } diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql index 1cf213dec63..24789e9c4ed 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql @@ -1,6 +1,7 @@ #import "~/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql" query getCatalogResources( + $scope: CiCatalogResourceScope $searchTerm: String $sortValue: CiCatalogResourceSort $after: String @@ -9,6 +10,7 @@ query getCatalogResources( $last: Int ) { ciCatalogResources( + scope: $scope search: $searchTerm sort: $sortValue after: $after @@ -22,7 +24,6 @@ query getCatalogResources( hasNextPage hasPreviousPage } - count nodes { ...CatalogResourceFields } diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql new file mode 100644 index 00000000000..d4a298e7e09 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql @@ -0,0 +1,8 @@ +query getCatalogResourcesCount($searchTerm: String) { + all: ciCatalogResources(scope: ALL, search: $searchTerm) { + count + } + namespaces: ciCatalogResources(scope: NAMESPACES, search: $searchTerm) { + count + } +} diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql index 0de06028386..0de06028386 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql diff --git a/app/assets/javascripts/ci/catalog/graphql/settings.js b/app/assets/javascripts/ci/catalog/graphql/settings.js index 4038188a7ce..abc95592b14 100644 --- a/app/assets/javascripts/ci/catalog/graphql/settings.js +++ b/app/assets/javascripts/ci/catalog/graphql/settings.js @@ -15,7 +15,7 @@ export const cacheConfig = { }); }, ciCatalogResources: { - keyArgs: false, + keyArgs: ['scope', 'search', 'sort'], }, }, }, diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_environments_dropdown/ci_environments_dropdown.vue index 77af643cbb3..2d2e3e280c0 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_environments_dropdown/ci_environments_dropdown.vue @@ -3,8 +3,12 @@ import { debounce, uniq } from 'lodash'; import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, s__, sprintf } from '~/locale'; -import { convertEnvironmentScope } from '../utils'; -import { ENVIRONMENT_QUERY_LIMIT } from '../constants'; +import { convertEnvironmentScope } from './utils'; +import { + ALL_ENVIRONMENTS_OPTION, + ENVIRONMENT_QUERY_LIMIT, + NO_ENVIRONMENT_OPTION, +} from './constants'; export default { name: 'CiEnvironmentsDropdown', @@ -16,10 +20,20 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + isEnvironmentRequired: { + type: Boolean, + required: false, + default: true, + }, areEnvironmentsLoading: { type: Boolean, required: true, }, + canCreateWildcard: { + type: Boolean, + required: false, + default: true, + }, environments: { type: Array, required: true, @@ -51,22 +65,31 @@ export default { searchedEnvironments() { let filtered = this.environments; - // If there is no search term, make sure to include * - if (!this.searchTerm) { - filtered = uniq([...filtered, '*']); - } - // add custom env scope if it matches the search term if (this.customEnvScope && this.customEnvScope.startsWith(this.searchTerm)) { filtered = uniq([...filtered, this.customEnvScope]); } + // If there is no search term, make sure to include * + if (!this.searchTerm) { + filtered = uniq([...filtered, ALL_ENVIRONMENTS_OPTION.type]); + + // lastly, add Not Applicable (None) as the first option if isEnvironmentRequired is true + if (!this.isEnvironmentRequired) { + filtered = [NO_ENVIRONMENT_OPTION.type, ...filtered]; + } + } + return filtered.sort().map((environment) => ({ value: environment, text: environment, })); }, shouldRenderCreateButton() { + if (!this.canCreateWildcard) { + return false; + } + return ( this.searchTerm && ![...this.environments, this.customEnvScope].includes(this.searchTerm) ); diff --git a/app/assets/javascripts/ci/ci_environments_dropdown/constants.js b/app/assets/javascripts/ci/ci_environments_dropdown/constants.js new file mode 100644 index 00000000000..98e543a75d0 --- /dev/null +++ b/app/assets/javascripts/ci/ci_environments_dropdown/constants.js @@ -0,0 +1,14 @@ +import { __ } from '~/locale'; + +export const ENVIRONMENT_QUERY_LIMIT = 30; + +export const ALL_ENVIRONMENTS_OPTION = { + type: '*', + text: __('All (default)'), +}; + +export const NO_ENVIRONMENT_OPTION = { + // TODO: This is a placeholder value. It will be replaced with the actual value used once it's implemented on the backend + type: 'Not applicable', + text: __('Not applicable'), +}; diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql index 5768d370474..5768d370474 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql +++ b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql index 26d1b6a3aaa..26d1b6a3aaa 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql +++ b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_environments_dropdown/utils.js index a7e020206ea..093b04dab0c 100644 --- a/app/assets/javascripts/ci/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci/ci_environments_dropdown/utils.js @@ -1,4 +1,4 @@ -import { allEnvironments } from './constants'; +import { ALL_ENVIRONMENTS_OPTION, NO_ENVIRONMENT_OPTION } from './constants'; /** * This function job is to convert the * wildcard to text when applicable @@ -10,11 +10,14 @@ import { allEnvironments } from './constants'; */ export const convertEnvironmentScope = (environmentScope = '') => { - if (environmentScope === allEnvironments.type || !environmentScope) { - return allEnvironments.text; + switch (environmentScope) { + case ALL_ENVIRONMENTS_OPTION.type || '': + return ALL_ENVIRONMENTS_OPTION.text; + case NO_ENVIRONMENT_OPTION.type: + return NO_ENVIRONMENT_OPTION.text; + default: + return environmentScope; } - - return environmentScope; }; /** diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 842d88e1267..b118d54d2b0 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue @@ -2,8 +2,8 @@ import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { getGroupEnvironments } from '~/ci/common/private/ci_environments_dropdown'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; -import getGroupEnvironments from '../graphql/queries/group_environments.query.graphql'; import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue index 43938e9b88f..822a2b01f24 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue @@ -2,8 +2,8 @@ import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { getProjectEnvironments } from '~/ci/common/private/ci_environments_dropdown'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; -import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql'; import getProjectVariables from '../graphql/queries/project_variables.query.graphql'; import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql'; import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql'; diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue index 2ad6c7c6578..ad4b7b790d0 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -20,8 +20,8 @@ import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import Tracking from '~/tracking'; +import CiEnvironmentsDropdown from '~/ci/common/private/ci_environments_dropdown'; import { - allEnvironments, defaultVariableState, DRAWER_EVENT_LABEL, EDIT_VARIABLE_ACTION, @@ -34,7 +34,6 @@ import { variableOptions, WHITESPACE_REG_EX, } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokenList } from './ci_variable_autocomplete_tokens'; const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL }); @@ -43,9 +42,10 @@ const KEY_REGEX = /^\w+$/; export const i18n = { addVariable: s__('CiVariables|Add variable'), cancel: __('Cancel'), - defaultScope: allEnvironments.text, + defaultScope: __('All (default)'), deleteVariable: s__('CiVariables|Delete variable'), editVariable: s__('CiVariables|Edit variable'), + saveVariable: __('Save changes'), environments: __('Environments'), environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, expandedField: s__('CiVariables|Expand variable reference'), @@ -259,9 +259,12 @@ export default { return validationIssuesText.trim(); }, - modalActionText() { + modalTitle() { return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable; }, + modalActionText() { + return this.isEditing ? this.$options.i18n.saveVariable : this.$options.i18n.addVariable; + }, removeVariableMessage() { return sprintf(this.$options.i18n.modalDeleteMessage, { key: this.variable.key }); }, @@ -359,7 +362,7 @@ export default { @close="close" > <template #title> - <h2 class="gl-m-0">{{ modalActionText }}</h2> + <h2 class="gl-m-0">{{ modalTitle }}</h2> </template> <gl-form-group :label="$options.i18n.type" @@ -493,8 +496,8 @@ export default { v-model="variable.value" :spellcheck="false" class="gl-border-none gl-font-monospace!" - rows="3" - max-rows="10" + rows="5" + :no-resize="false" data-testid="ci-variable-value" /> <p @@ -515,9 +518,15 @@ export default { > {{ $options.i18n.variableReferenceDescription }} </gl-alert> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" - >{{ $options.i18n.cancel }} + <div class="gl-display-flex"> + <gl-button + category="primary" + class="gl-mr-3" + variant="confirm" + :disabled="!canSubmit" + data-testid="ci-variable-confirm-button" + @click="submit" + >{{ modalActionText }} </gl-button> <gl-button v-if="isEditing" @@ -528,13 +537,8 @@ export default { data-testid="ci-variable-delete-button" >{{ $options.i18n.deleteVariable }}</gl-button > - <gl-button - category="primary" - variant="confirm" - :disabled="!canSubmit" - data-testid="ci-variable-confirm-button" - @click="submit" - >{{ modalActionText }} + <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" + >{{ $options.i18n.cancel }} </gl-button> </div> </gl-drawer> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 011a424b6c2..609b8523612 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -3,11 +3,13 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { reportToSentry } from '~/ci/utils'; -import { mapEnvironmentNames } from '../utils'; +import { + ENVIRONMENT_QUERY_LIMIT, + mapEnvironmentNames, +} from '~/ci/common/private/ci_environments_dropdown'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, - ENVIRONMENT_QUERY_LIMIT, SORT_DIRECTIONS, UPDATE_MUTATION_ACTION, mapMutationActionToToast, diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue index 86287d586ec..901bd39930a 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue @@ -15,13 +15,13 @@ import { } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { convertEnvironmentScope } from '~/ci/common/private/ci_environments_dropdown'; import { DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT, EXCEEDS_VARIABLE_LIMIT_TEXT, MAXIMUM_VARIABLE_LIMIT_REACHED, variableTypes, } from '../constants'; -import { convertEnvironmentScope } from '../utils'; export default { defaultFields: [ diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index 4ec7333f465..c4f92fed829 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -1,7 +1,5 @@ import { __, s__, sprintf } from '~/locale'; -export const ENVIRONMENT_QUERY_LIMIT = 30; - export const MASKED_VALUE_MIN_LENGTH = 8; export const WHITESPACE_REG_EX = /\s/; @@ -15,18 +13,13 @@ export const variableTypes = { fileType: 'FILE', }; -export const allEnvironments = { - type: '*', - text: __('All (default)'), -}; - export const variableOptions = [ { value: variableTypes.envType, text: __('Variable (default)') }, { value: variableTypes.fileType, text: __('File') }, ]; export const defaultVariableState = { - environmentScope: allEnvironments.type, + environmentScope: '*', key: '', masked: false, protected: false, diff --git a/app/assets/javascripts/ci/common/private/ci_environments_dropdown.js b/app/assets/javascripts/ci/common/private/ci_environments_dropdown.js new file mode 100644 index 00000000000..f8958f9600c --- /dev/null +++ b/app/assets/javascripts/ci/common/private/ci_environments_dropdown.js @@ -0,0 +1,9 @@ +import CiEnvironmentsDropdown from '~/ci/ci_environments_dropdown/ci_environments_dropdown.vue'; + +export default CiEnvironmentsDropdown; + +export { getGroupEnvironments } from '~/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql'; +export { getProjectEnvironments } from '~/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql'; + +export { ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_environments_dropdown/constants'; +export * from '~/ci/ci_environments_dropdown/utils'; diff --git a/app/assets/javascripts/ci/pipeline_details/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js index 51d0e980e78..90e723ea442 100644 --- a/app/assets/javascripts/ci/pipeline_details/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/constants.js @@ -5,7 +5,7 @@ export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source export const NEEDS_PROPERTY = 'needs'; export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; -export const TestStatus = { +export const testStatus = { FAILED: 'failed', SKIPPED: 'skipped', SUCCESS: 'success', diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js index e3984685094..87081e61e48 100644 --- a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js @@ -1,6 +1,6 @@ import { __, sprintf } from '~/locale'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; -import { TestStatus } from '../../constants'; +import { testStatus } from '../../constants'; /** * Removes `./` from the beginning of a file path so it can be appended onto a blob path @@ -13,15 +13,15 @@ export function formatFilePath(file) { export function iconForTestStatus(status) { switch (status) { - case TestStatus.SUCCESS: + case testStatus.SUCCESS: return 'status_success'; - case TestStatus.FAILED: + case testStatus.FAILED: return 'status_failed'; - case TestStatus.ERROR: + case testStatus.ERROR: return 'status_warning'; - case TestStatus.SKIPPED: + case testStatus.SKIPPED: return 'status_skipped'; - case TestStatus.UNKNOWN: + case testStatus.UNKNOWN: default: return 'status_notfound'; } diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue index 6e9a705c046..5fd9f7cfd4f 100644 --- a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue @@ -2,7 +2,12 @@ import { GlLoadingIcon } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; -import { getParameterValues } from '~/lib/utils/url_utility'; +import { + getParameterValues, + updateHistory, + setUrlParams, + removeParams, +} from '~/lib/utils/url_utility'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; @@ -49,12 +54,28 @@ export default { ]), summaryBackClick() { this.removeSelectedSuiteIndex(); + + updateHistory({ + url: removeParams(['job_name']), + title: document.title, + replace: true, + }); }, summaryTableRowClick(index) { this.setSelectedSuiteIndex(index); // Fetch the test suite when the user clicks to see more details this.fetchTestSuite(index); + + const urlParams = { + job_name: this.getSelectedSuite.name, + }; + + updateHistory({ + url: setUrlParams(urlParams), + title: document.title, + replace: true, + }); }, beforeEnterTransition() { document.documentElement.style.overflowX = 'hidden'; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue index 1d152a63407..a6e679e6d4e 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue @@ -13,6 +13,7 @@ import { } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { pipelineEditorTrackingOptions } from '../../constants'; import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue'; import CiLintResults from '../lint/ci_lint_results.vue'; @@ -189,6 +190,7 @@ export default { }, i18n, BASE_CLASSES, + lintHref: helpPagePath('ci/lint.md'), }; </script> @@ -290,7 +292,7 @@ export default { <code>{{ content }}</code> </template> <template #link="{ content }"> - <gl-link target="_blank" href="#">{{ content }}</gl-link> + <gl-link target="_blank" :href="$options.lintHref">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index e287e4e17d1..63957d9b7fc 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -33,16 +33,13 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-flex-wrap gl-gap-2"> <runner-status-badge :contacted-at="contactedAt" :status="status" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - /> - <runner-paused-badge - v-if="paused" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" + class="gl-max-w-full gl-text-truncate" /> + <runner-paused-badge v-if="paused" class="gl-max-w-full gl-text-truncate" /> <slot :runner="runner" name="runner-job-status-badge"></slot> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue index bed592e3f30..0dc23882cdc 100644 --- a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -26,12 +26,12 @@ export default { switch (this.jobStatus) { case JOB_STATUS_RUNNING: return { - classes: 'gl-text-blue-600! gl-border gl-border-blue-600!', + classes: 'gl-text-blue-600! gl-inset-border-1-gray-400 gl-border-blue-600!', label: I18N_JOB_STATUS_RUNNING, }; case JOB_STATUS_IDLE: return { - classes: 'gl-text-gray-700! gl-border gl-border-gray-500!', + classes: 'gl-text-gray-700! gl-inset-border-1-gray-400 gl-border-gray-500!', label: I18N_JOB_STATUS_IDLE, }; default: @@ -45,7 +45,7 @@ export default { <gl-badge v-if="badge" v-bind="$attrs" - class="gl-display-inline-block gl-max-w-full gl-text-truncate gl-bg-transparent!" + class="gl-max-w-full gl-text-truncate gl-bg-transparent!" variant="muted" :class="badge.classes" > diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index 9d7d68ee31c..c1a6f7e0800 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -161,6 +161,8 @@ export default { </div> </div> </gl-tab> + + <slot name="ee-workspaces-tab" :agent-name="agentName" :project-path="projectPath"></slot> </gl-tabs> </template> diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue index 5a5d221591a..0e92f20f0c1 100644 --- a/app/assets/javascripts/comment_templates/components/form.vue +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -1,11 +1,12 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import { InternalEvents } from '~/tracking'; +import Api from '~/api'; import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql'; import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql'; @@ -16,7 +17,7 @@ export default { GlFormGroup, GlFormInput, GlAlert, - MarkdownField, + MarkdownEditor, }, mixins: [InternalEvents.mixin()], props: { @@ -45,6 +46,14 @@ export default { name: this.name, content: this.content, }, + formFieldProps: { + id: 'comment-template-content', + name: 'comment-template-content', + 'aria-label': __('Content'), + placeholder: __('Write comment template content here…'), + 'data-testid': 'comment-template-content-input', + class: 'note-textarea js-gfm-input js-autosize markdown-area', + }, }; }, computed: { @@ -61,6 +70,9 @@ export default { isValid() { return this.isNameValid && this.isContentValid; }, + markdownPath() { + return Api.buildUrl(Api.markdownPath); + }, }, methods: { onCancel() { @@ -116,7 +128,6 @@ export default { }); }, }, - restrictedToolbarItems: ['full-screen'], markdownDocsPath: helpPagePath('user/markdown'), }; </script> @@ -156,30 +167,19 @@ export default { data-testid="comment-template-content-form-group" class="gl-lg-max-w-80p" > - <markdown-field - :enable-preview="false" + <markdown-editor + v-model="updateCommentTemplate.content" + class="js-no-autosize" :is-submitting="saving" - :add-spacing-classes="false" - :textarea-value="updateCommentTemplate.content" + :disable-attachments="true" + :render-markdown-path="markdownPath" :markdown-docs-path="$options.markdownDocsPath" + :form-field-props="formFieldProps" :restricted-tool-bar-items="$options.restrictedToolbarItems" :force-autosize="false" - class="js-no-autosize gl-border-gray-400!" - > - <template #textarea> - <textarea - v-model="updateCommentTemplate.content" - dir="auto" - class="note-textarea js-gfm-input js-autosize markdown-area" - data-supports-quick-actions="false" - :aria-label="__('Content')" - :placeholder="__('Write comment template content here…')" - data-testid="comment-template-content-input" - @keydown.meta.enter="onSubmit" - @keydown.ctrl.enter="onSubmit" - ></textarea> - </template> - </markdown-field> + @keydown.meta.enter="onSubmit" + @keydown.ctrl.enter="onSubmit" + /> </gl-form-group> <gl-button variant="confirm" diff --git a/app/assets/javascripts/commit/components/signature_badge.vue b/app/assets/javascripts/commit/components/signature_badge.vue index edc7c9d2f96..dc6d2df22b7 100644 --- a/app/assets/javascripts/commit/components/signature_badge.vue +++ b/app/assets/javascripts/commit/components/signature_badge.vue @@ -1,7 +1,7 @@ <script> import { GlBadge, GlLink, GlPopover } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { typeConfig, statusConfig } from '../constants'; +import { typeConfig, statusConfig } from 'ee_else_ce/commit/constants'; import X509CertificateDetails from './x509_certificate_details.vue'; export default { diff --git a/app/assets/javascripts/commit/constants.js b/app/assets/javascripts/commit/constants.js index e28009ab996..9eba316b371 100644 --- a/app/assets/javascripts/commit/constants.js +++ b/app/assets/javascripts/commit/constants.js @@ -11,6 +11,7 @@ export const verificationStatuses = { SAME_USER_DIFFERENT_EMAIL: 'SAME_USER_DIFFERENT_EMAIL', MULTIPLE_SIGNATURES: 'MULTIPLE_SIGNATURES', REVOKED_KEY: 'REVOKED_KEY', + VERIFIED_SYSTEM: 'VERIFIED_SYSTEM', }; export const signatureTypes = { @@ -28,15 +29,25 @@ const UNVERIFIED_CONFIG = { description: __('This commit was signed with an unverified signature.'), }; +export const VERIFIED_CONFIG = { + variant: 'success', + label: __('Verified'), + title: __('Verified commit'), +}; + export const statusConfig = { [verificationStatuses.VERIFIED]: { - variant: 'success', - label: __('Verified'), - title: __('Verified commit'), + ...VERIFIED_CONFIG, description: __( 'This commit was signed with a verified signature and the committer email was verified to belong to the same user.', ), }, + [verificationStatuses.VERIFIED_SYSTEM]: { + ...VERIFIED_CONFIG, + description: __( + 'This commit was created in the GitLab UI, and signed with a GitLab-verified signature.', + ), + }, [verificationStatuses.UNVERIFIED]: { ...UNVERIFIED_CONFIG, }, diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue index 0b80802d993..b7031a4885c 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/image.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue @@ -1,5 +1,6 @@ <script> import { NodeViewWrapper } from '@tiptap/vue-2'; +import { uploadingStates } from '../../services/upload_helpers'; export default { name: 'ImageWrapper', @@ -30,6 +31,12 @@ export default { dragData: {}, }; }, + computed: { + isStaleUploadedImage() { + const { uploading } = this.node.attrs; + return uploading && uploadingStates[uploading]; + }, + }, mounted() { document.addEventListener('mousemove', this.onDrag); document.addEventListener('mouseup', this.onDragEnd); @@ -80,7 +87,11 @@ export default { }; </script> <template> - <node-view-wrapper as="span" class="gl-relative gl-display-inline-block"> + <node-view-wrapper + v-if="!isStaleUploadedImage" + as="span" + class="gl-relative gl-display-inline-block" + > <span v-for="handle in $options.resizeHandles" v-show="selected" diff --git a/app/assets/javascripts/content_editor/components/wrappers/playable.vue b/app/assets/javascripts/content_editor/components/wrappers/playable.vue new file mode 100644 index 00000000000..8a380f23a3f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/playable.vue @@ -0,0 +1,62 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { uploadingStates } from '../../services/upload_helpers'; + +export default { + name: 'PlayableWrapper', + components: { + NodeViewWrapper, + NodeViewContent, + GlLink, + }, + props: { + getPos: { + type: Function, + required: true, + }, + editor: { + type: Object, + required: true, + }, + node: { + type: Object, + required: true, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + dragData: {}, + }; + }, + computed: { + isStaleUploadedMedia() { + const { uploading } = this.node.attrs; + return uploading && uploadingStates[uploading]; + }, + }, +}; +</script> +<template> + <node-view-wrapper + v-if="!isStaleUploadedMedia" + as="span" + :class="`media-container ${node.type.name}-container`" + > + <node-view-content + :as="node.type.name" + :src="node.attrs.src" + controls="true" + data-setup="{}" + :data-title="node.attrs.title || node.attrs.alt" + /> + <gl-link :href="node.attrs.src" class="with-attachment-icon" target="_blank"> + {{ node.attrs.title || node.attrs.alt }} + </gl-link> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index e7a1b058341..11ac024b799 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -7,7 +7,7 @@ import { __, n__ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; const TABLE_CELL_BODY = 'td'; -function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) { +function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1, align = 'left' }) { const totalRows = selectedRect?.map.height; const totalCols = selectedRect?.map.width; const isTableBodyCell = cellType === TABLE_CELL_BODY; @@ -20,9 +20,21 @@ function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell; const showDeleteColumnOption = totalCols > selectedCols; + const isTableBodyHeader = cellType === TABLE_CELL_HEADER; + const showAlignLeftOption = isTableBodyHeader && (align === 'center' || align === 'right'); + const showAlignCenterOption = isTableBodyHeader && align !== 'center'; + const showAlignRightOption = isTableBodyHeader && align !== 'right'; + return [ { items: [ + showAlignLeftOption && { text: __('Align column left'), value: 'alignColumnLeft' }, + showAlignCenterOption && { text: __('Align column center'), value: 'alignColumnCenter' }, + showAlignRightOption && { text: __('Align column right'), value: 'alignColumnRight' }, + ].filter(Boolean), + }, + { + items: [ { text: __('Insert column before'), value: 'addColumnBefore' }, { text: __('Insert column after'), value: 'addColumnAfter' }, isTableBodyCell && { text: __('Insert row before'), value: 'addRowBefore' }, @@ -93,6 +105,7 @@ export default { cellType: this.cellType, rowspan: this.node.attrs.rowspan, colspan: this.node.attrs.colspan, + align: this.node.attrs.align, }); }, }, @@ -129,7 +142,7 @@ export default { runCommand({ value: command }) { this.hideDropdown(); - this.editor.chain()[command]().run(); + this.editor.chain()[command](this.getPos()).run(); }, hideDropdown() { @@ -143,6 +156,7 @@ export default { :as="cellType" :rowspan="node.attrs.rowspan || 1" :colspan="node.attrs.colspan || 1" + :align="node.attrs.align || 'left'" dir="auto" class="gl-m-0! gl-p-0! gl-relative" @click="hideDropdown" @@ -168,6 +182,10 @@ export default { @action="runCommand" /> </span> - <node-view-content as="div" class="gl-p-5 gl-min-w-10" /> + <node-view-content + as="div" + class="gl-p-5 gl-min-w-10" + :style="{ 'text-align': node.attrs.align || 'left' }" + /> </node-view-wrapper> </template> diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index 23f2da7bc28..1164e7ead33 100644 --- a/app/assets/javascripts/content_editor/extensions/copy_paste.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -172,6 +172,8 @@ export default Extension.create({ return true; } + if (!textContent) return false; + const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT); const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT); const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index 47766c966a1..7726e0b6572 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,4 +1,6 @@ import { Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import PlayableWrapper from '../components/wrappers/playable.vue'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -58,7 +60,6 @@ export default Node.create({ controls: true, 'data-setup': '{}', 'data-title': node.attrs.alt, - ...this.extraElementAttrs, }, ], [ @@ -68,4 +69,8 @@ export default Node.create({ ], ]; }, + + addNodeView() { + return VueNodeViewRenderer(PlayableWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js index 9f437ce066c..53dba4fd960 100644 --- a/app/assets/javascripts/content_editor/extensions/table_cell.js +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -5,6 +5,17 @@ import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue'; export default TableCell.extend({ content: 'block+', + addAttributes() { + return { + ...this.parent?.(), + align: { + default: 'left', + parseHTML: (element) => element.getAttribute('align') || element.style.textAlign || 'left', + renderHTML: () => '', + }, + }; + }, + addNodeView() { return VueNodeViewRenderer(TableCellBodyWrapper); }, diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js index 045fd03199b..ca2a0eb5cfd 100644 --- a/app/assets/javascripts/content_editor/extensions/table_header.js +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -1,9 +1,45 @@ import { TableHeader } from '@tiptap/extension-table-header'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { CellSelection } from '@tiptap/pm/tables'; import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue'; export default TableHeader.extend({ content: 'block+', + + addAttributes() { + return { + ...this.parent?.(), + align: { + default: 'left', + parseHTML: (element) => element.getAttribute('align') || element.style.textAlign || 'left', + renderHTML: () => '', + }, + }; + }, + + addCommands() { + return { + ...this.parent?.(), + alignColumn: (pos, align) => ({ commands }) => { + commands.selectColumn(pos); + commands.updateAttributes('tableHeader', { align }); + commands.updateAttributes('tableCell', { align }); + }, + alignColumnLeft: (pos) => ({ commands }) => commands.alignColumn(pos, 'left'), + alignColumnCenter: (pos) => ({ commands }) => commands.alignColumn(pos, 'center'), + alignColumnRight: (pos) => ({ commands }) => commands.alignColumn(pos, 'right'), + selectColumn: (pos) => ({ tr, dispatch }) => { + if (dispatch) { + const position = tr.doc.resolve(pos); + const colSelection = CellSelection.colSelection(position); + tr.setSelection(colSelection); + } + + return true; + }, + }; + }, + addNodeView() { return VueNodeViewRenderer(TableCellHeaderWrapper); }, diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 849fd55034e..1e19878be9b 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -19,9 +19,17 @@ export default TaskItem.extend({ return checkbox?.checked; }, - renderHTML: (attributes) => ({ - 'data-checked': attributes.checked, - }), + renderHTML: (attributes) => attributes.checked && { 'data-checked': true }, + keepOnSplit: false, + }, + inapplicable: { + default: false, + parseHTML: (element) => { + const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox'); + + return typeof checkbox?.dataset.inapplicable !== 'undefined'; + }, + renderHTML: (attributes) => attributes.inapplicable && { 'data-inapplicable': true }, keepOnSplit: false, }, }; @@ -33,6 +41,24 @@ export default TaskItem.extend({ tag: 'li.task-list-item', priority: PARSE_HTML_PRIORITY_HIGHEST, }, + { + tag: 'li.task-list-item.inapplicable s', + skip: true, + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, ]; }, + + addNodeView() { + const nodeView = this.parent?.(); + return ({ node, ...args }) => { + const nodeViewInstance = nodeView({ node, ...args }); + + if (node.attrs.inapplicable) { + nodeViewInstance.dom.querySelector('input[type=checkbox]').disabled = true; + } + + return nodeViewInstance; + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js index 312e8cd5ff6..fa9fa866137 100644 --- a/app/assets/javascripts/content_editor/extensions/video.js +++ b/app/assets/javascripts/content_editor/extensions/video.js @@ -6,7 +6,6 @@ export default Playable.extend({ return { ...this.parent?.(), mediaType: 'video', - extraElementAttrs: { width: '400' }, }; }, }); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 972b4acf523..3b759de57f2 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -228,7 +228,11 @@ const defaultSerializerConfig = { [TableHeader.name]: renderTableCell, [TableRow.name]: renderTableRow, [TaskItem.name]: preserveUnchanged((state, node) => { - state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + let symbol = ' '; + if (node.attrs.inapplicable) symbol = '~'; + else if (node.attrs.checked) symbol = 'x'; + + state.write(`[${symbol}] `); if (!node.textContent) state.write(' '); state.renderContent(node); }), diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index a4abb8dcf38..de230c370b1 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -24,19 +24,23 @@ const getRangeFromSourcePos = (sourcePos) => { export const getMarkdownSource = (element) => { if (!element.dataset.sourcepos) return undefined; - const source = getFullSource(element); - const range = getRangeFromSourcePos(element.dataset.sourcepos); - let elSource = ''; - - if (!source.length) return undefined; - - for (let i = range.start.row; i <= range.end.row; i += 1) { - if (i === range.start.row) { - elSource += source[i].substring(range.start.col); - } else { - elSource += `\n${source[i]}` || ''; + try { + const source = getFullSource(element); + const range = getRangeFromSourcePos(element.dataset.sourcepos); + let elSource = ''; + + if (!source.length) return undefined; + + for (let i = range.start.row; i <= range.end.row; i += 1) { + if (i === range.start.row) { + elSource += source[i].substring(range.start.col); + } else { + elSource += `\n${source[i]}` || ''; + } } - } - return elSource.trim(); + return elSource.trim(); + } catch { + return undefined; + } }; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 87959a44560..7a2fbf8fcab 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -2,8 +2,8 @@ import { uniq, isString, omit, isFunction } from 'lodash'; import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility'; const defaultAttrs = { - td: { colspan: 1, rowspan: 1, colwidth: null }, - th: { colspan: 1, rowspan: 1, colwidth: null }, + td: { colspan: 1, rowspan: 1, colwidth: null, align: 'left' }, + th: { colspan: 1, rowspan: 1, colwidth: null, align: 'left' }, }; const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey']; @@ -649,6 +649,9 @@ export const link = { const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + // eslint-disable-next-line @gitlab/require-i18n-strings + if (href.startsWith('data:') || href.startsWith('blob:')) return ''; + if (linkType(sourceMarkdown) === LINK_MARKDOWN) { return '['; } @@ -668,6 +671,9 @@ export const link = { const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; + // eslint-disable-next-line @gitlab/require-i18n-strings + if (href.startsWith('data:') || href.startsWith('blob:')) return ''; + if (isReference) { return `][${state.esc(canonicalSrc || href || '')}]`; } diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index f5785397bf0..960f28747b0 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -133,6 +133,8 @@ export const uploadFile = ({ uploadsPath, renderMarkdown, file }) => { }); }; +export const uploadingStates = {}; + const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => { // needed to avoid mismatched transaction error await Promise.resolve(); @@ -170,6 +172,8 @@ const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, ev // the position might have changed while uploading, so we need to find it again position = findUploadedFilePosition(editor, file.name); + uploadingStates[file.name] = true; + editor.view.dispatch( editor.state.tr.setMeta('preventAutolink', true).setNodeMarkup(position, undefined, { uploading: false, @@ -200,6 +204,8 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve const { selection } = editor.view.state; const currentNode = selection.$to.node(); + uploadingStates[file.name] = true; + let position = selection.to; let content = { type: 'text', diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 7bc1eb5d652..4a2da487e9b 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,6 +1,5 @@ <script> import { GlButton } from '@gitlab/ui'; -import eventHub from '../eventhub'; export default { components: { @@ -11,10 +10,6 @@ export default { type: Object, required: true, }, - type: { - type: String, - required: true, - }, category: { type: String, required: false, @@ -30,6 +25,10 @@ export default { required: false, default: '', }, + mutation: { + type: Object, + required: true, + }, }, data() { return { @@ -39,10 +38,15 @@ export default { methods: { doAction() { this.isLoading = true; - - eventHub.$emit(`${this.type}.key`, this.deployKey, () => { - this.isLoading = false; - }); + this.$apollo + .mutate({ + mutation: this.mutation, + variables: { id: this.deployKey.id }, + }) + .catch((error) => this.$emit('error', error)) + .finally(() => { + this.isLoading = false; + }); }, }, }; @@ -50,6 +54,7 @@ export default { <template> <gl-button + v-bind="$attrs" :category="category" :variant="variant" :icon="icon" diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index ec17bbea48f..7168a209b52 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,11 +1,18 @@ <script> -import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; +import { captureException } from '~/sentry/sentry_browser_wrapper'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; -import eventHub from '../eventhub'; -import DeployKeysService from '../service'; -import DeployKeysStore from '../store'; +import deployKeysQuery from '../graphql/queries/deploy_keys.query.graphql'; +import currentPageQuery from '../graphql/queries/current_page.query.graphql'; +import currentScopeQuery from '../graphql/queries/current_scope.query.graphql'; +import confirmRemoveKeyQuery from '../graphql/queries/confirm_remove_key.query.graphql'; +import updateCurrentScopeMutation from '../graphql/mutations/update_current_scope.mutation.graphql'; +import updateCurrentPageMutation from '../graphql/mutations/update_current_page.mutation.graphql'; +import confirmDisableMutation from '../graphql/mutations/confirm_action.mutation.graphql'; +import disableKeyMutation from '../graphql/mutations/disable_key.mutation.graphql'; import ConfirmModal from './confirm_modal.vue'; import KeysPanel from './keys_panel.vue'; @@ -17,120 +24,147 @@ export default { GlButton, GlIcon, GlLoadingIcon, + GlPagination, }, props: { - endpoint: { + projectId: { type: String, required: true, }, - projectId: { + projectPath: { type: String, required: true, }, }, + apollo: { + deployKeys: { + query: deployKeysQuery, + variables() { + return { + projectPath: this.projectPath, + scope: this.currentScope, + page: this.currentPage, + }; + }, + update(data) { + return data?.project?.deployKeys || []; + }, + error(error) { + createAlert({ + message: s__('DeployKeys|Error getting deploy keys'), + captureError: true, + error, + }); + }, + }, + pageInfo: { + query: pageInfoQuery, + variables() { + return { input: { page: this.currentPage, scope: this.currentScope } }; + }, + update({ pageInfo }) { + return pageInfo || {}; + }, + }, + currentPage: { + query: currentPageQuery, + }, + currentScope: { + query: currentScopeQuery, + }, + deployKeyToRemove: { + query: confirmRemoveKeyQuery, + }, + }, data() { return { - currentTab: 'enabled_keys', - isLoading: false, - store: new DeployKeysStore(), - removeKey: () => {}, - cancel: () => {}, - confirmModalVisible: false, + deployKeys: [], + pageInfo: {}, + deployKeyToRemove: null, }; }, scopes: { - enabled_keys: s__('DeployKeys|Enabled deploy keys'), - available_project_keys: s__('DeployKeys|Privately accessible deploy keys'), - public_keys: s__('DeployKeys|Publicly accessible deploy keys'), + enabledKeys: s__('DeployKeys|Enabled deploy keys'), + availableProjectKeys: s__('DeployKeys|Privately accessible deploy keys'), + availablePublicKeys: s__('DeployKeys|Publicly accessible deploy keys'), }, i18n: { loading: s__('DeployKeys|Loading deploy keys'), addButton: s__('DeployKeys|Add new key'), + prevPage: __('Go to previous page'), + nextPage: __('Go to next page'), + next: __('Next'), + prev: __('Prev'), + goto: (page) => sprintf(__('Go to page %{page}'), { page }), }, computed: { tabs() { - return Object.keys(this.$options.scopes).map((scope) => { - const count = Array.isArray(this.keys[scope]) ? this.keys[scope].length : null; - + return Object.entries(this.$options.scopes).map(([scope, name]) => { return { - name: this.$options.scopes[scope], + name, scope, - isActive: scope === this.currentTab, - count, + isActive: scope === this.currentScope, }; }); }, - hasKeys() { - return Object.keys(this.keys).length; - }, - keys() { - return this.store.keys; + confirmModalVisible() { + return Boolean(this.deployKeyToRemove); }, }, - created() { - this.service = new DeployKeysService(this.endpoint); - - eventHub.$on('enable.key', this.enableKey); - eventHub.$on('remove.key', this.confirmRemoveKey); - eventHub.$on('disable.key', this.confirmRemoveKey); - }, - mounted() { - this.fetchKeys(); - }, - beforeDestroy() { - eventHub.$off('enable.key', this.enableKey); - eventHub.$off('remove.key', this.confirmRemoveKey); - eventHub.$off('disable.key', this.confirmRemoveKey); - }, methods: { - onChangeTab(tab) { - this.currentTab = tab; + onChangeTab(scope) { + return this.$apollo + .mutate({ + mutation: updateCurrentScopeMutation, + variables: { scope }, + }) + .then(() => { + this.$apollo.queries.deployKeys.refetch(); + }) + .catch((error) => { + captureException(error, { + tags: { + deployKeyScope: scope, + }, + }); + }); }, - fetchKeys() { - this.isLoading = true; - - return this.service - .getKeys() - .then((data) => { - this.isLoading = false; - this.store.keys = data; + moveNext() { + return this.movePage('next'); + }, + movePrevious() { + return this.movePage('previous'); + }, + movePage(direction) { + return this.moveToPage(this.pageInfo[`${direction}Page`]); + }, + moveToPage(page) { + return this.$apollo.mutate({ mutation: updateCurrentPageMutation, variables: { page } }); + }, + removeKey() { + this.$apollo + .mutate({ + mutation: disableKeyMutation, + variables: { id: this.deployKeyToRemove.id }, + }) + .then(() => { + if (!this.deployKeys.length) { + return this.movePage('previous'); + } + return null; }) + .then(() => this.$apollo.queries.deployKeys.refetch()) .catch(() => { - this.isLoading = false; - this.store.keys = {}; - return createAlert({ - message: s__('DeployKeys|Error getting deploy keys'), + createAlert({ + message: s__('DeployKeys|Error removing deploy key'), }); }); }, - enableKey(deployKey) { - this.service - .enableKey(deployKey.id) - .then(this.fetchKeys) - .catch(() => - createAlert({ - message: s__('DeployKeys|Error enabling deploy key'), - }), - ); - }, - confirmRemoveKey(deployKey, callback) { - const hideModal = () => { - this.confirmModalVisible = false; - callback?.(); - }; - this.removeKey = () => { - this.service - .disableKey(deployKey.id) - .then(this.fetchKeys) - .then(hideModal) - .catch(() => - createAlert({ - message: s__('DeployKeys|Error removing deploy key'), - }), - ); - }; - this.cancel = hideModal; - this.confirmModalVisible = true; + cancel() { + this.$apollo.mutate({ + mutation: confirmDisableMutation, + variables: { id: null }, + }); }, }, }; @@ -139,47 +173,59 @@ export default { <template> <div class="deploy-keys"> <confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" /> + <div class="gl-new-card-header gl-align-items-center gl-py-0 gl-pl-0"> + <div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0"> + <div class="fade-left"> + <gl-icon name="chevron-lg-left" :size="12" /> + </div> + <div class="fade-right"> + <gl-icon name="chevron-lg-right" :size="12" /> + </div> + + <navigation-tabs + :tabs="tabs" + scope="deployKeys" + class="gl-rounded-lg" + @onChangeTab="onChangeTab" + /> + </div> + + <div class="gl-new-card-actions"> + <gl-button + size="small" + class="js-toggle-button js-toggle-content" + data-testid="add-new-deploy-key-button" + > + {{ $options.i18n.addButton }} + </gl-button> + </div> + </div> <gl-loading-icon - v-if="isLoading && !hasKeys" + v-if="$apollo.queries.deployKeys.loading" :label="$options.i18n.loading" - size="sm" + size="md" class="gl-m-5" /> - <template v-else-if="hasKeys"> - <div class="gl-new-card-header gl-align-items-center gl-pt-0 gl-pb-0 gl-pl-0"> - <div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0"> - <div class="fade-left"> - <gl-icon name="chevron-lg-left" :size="12" /> - </div> - <div class="fade-right"> - <gl-icon name="chevron-lg-right" :size="12" /> - </div> - - <navigation-tabs - :tabs="tabs" - scope="deployKeys" - class="gl-rounded-lg" - @onChangeTab="onChangeTab" - /> - </div> - - <div class="gl-new-card-actions"> - <gl-button - size="small" - class="js-toggle-button js-toggle-content" - data-testid="add-new-deploy-key-button" - > - {{ $options.i18n.addButton }} - </gl-button> - </div> - </div> + <template v-else> <keys-panel :project-id="projectId" - :keys="keys[currentTab]" - :store="store" - :endpoint="endpoint" + :keys="deployKeys" data-testid="project-deploy-keys-container" /> + <gl-pagination + align="center" + :total-items="pageInfo.total" + :per-page="pageInfo.perPage" + :value="currentPage" + :next="$options.i18n.next" + :prev="$options.i18n.prev" + :label-previous-page="$options.i18n.prevPage" + :label-next-page="$options.i18n.nextPage" + :label-page="$options.i18n.goto" + @next="moveNext()" + @previous="movePrevious()" + @input="moveToPage" + /> </template> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 16c745d8cff..d4b140f1adb 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -2,8 +2,12 @@ <script> import { GlBadge, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { head, tail } from 'lodash'; +import { createAlert } from '~/alert'; import { s__, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import currentScopeQuery from '../graphql/queries/current_scope.query.graphql'; +import enableKeyMutation from '../graphql/mutations/enable_key.mutation.graphql'; +import confirmDisableMutation from '../graphql/mutations/confirm_action.mutation.graphql'; import ActionBtn from './action_btn.vue'; @@ -23,48 +27,25 @@ export default { type: Object, required: true, }, - store: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, projectId: { type: String, required: false, default: null, }, }, + apollo: { + currentScope: { + query: currentScopeQuery, + }, + }, data() { return { projectsExpanded: false, }; }, computed: { - editDeployKeyPath() { - return `${this.endpoint}/${this.deployKey.id}/edit`; - }, projects() { - const projects = [...this.deployKey.deploy_keys_projects]; - - if (this.projectId !== null) { - const indexOfCurrentProject = projects.findIndex( - (project) => - project && - project.project && - project.project.id && - project.project.id.toString() === this.projectId, - ); - - if (indexOfCurrentProject > -1) { - const currentProject = projects.splice(indexOfCurrentProject, 1); - currentProject[0].project.full_name = s__('DeployKeys|Current project'); - return currentProject.concat(projects); - } - } - return projects; + return this.deployKey.deployKeysProjects; }, firstProject() { return head(this.projects); @@ -81,13 +62,11 @@ export default { return sprintf(s__('DeployKeys|+%{count} others'), { count: this.restProjects.length }); }, isEnabled() { - return this.store.isEnabled(this.deployKey.id); + return this.currentScope === 'enabledKeys'; }, isRemovable() { return ( - this.store.isEnabled(this.deployKey.id) && - this.deployKey.destroyed_when_orphaned && - this.deployKey.almost_orphaned + this.isEnabled && this.deployKey.destroyedWhenOrphaned && this.deployKey.almostOrphaned ); }, isExpandable() { @@ -99,14 +78,37 @@ export default { }, methods: { projectTooltipTitle(project) { - return project.can_push + return project.canPush ? s__('DeployKeys|Grant write permissions to this key') : s__('DeployKeys|Read access only'); }, toggleExpanded() { this.projectsExpanded = !this.projectsExpanded; }, + isCurrentProject({ project } = {}) { + if (this.projectId !== null) { + return Boolean(project?.id?.toString() === this.projectId); + } + + return false; + }, + projectName(project) { + if (this.isCurrentProject(project)) { + return s__('DeployKeys|Current project'); + } + + return project?.project?.fullName; + }, + onEnableError(error) { + createAlert({ + message: s__('DeployKeys|Error enabling deploy key'), + captureError: true, + error, + }); + }, }, + enableKeyMutation, + confirmDisableMutation, }; </script> @@ -128,7 +130,7 @@ export default { <dl class="gl-font-sm gl-mb-0"> <dt>{{ __('SHA256') }}</dt> <dd class="fingerprint" data-testid="key-sha256-fingerprint-content"> - {{ deployKey.fingerprint_sha256 }} + {{ deployKey.fingerprintSha256 }} </dd> <template v-if="deployKey.fingerprint"> <dt> @@ -150,10 +152,10 @@ export default { <gl-badge v-gl-tooltip :title="projectTooltipTitle(firstProject)" - :icon="firstProject.can_push ? 'lock-open' : 'lock'" + :icon="firstProject.canPush ? 'lock-open' : 'lock'" class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate" > - <span class="gl-text-truncate">{{ firstProject.project.full_name }}</span> + <span class="gl-text-truncate">{{ projectName(firstProject) }}</span> </gl-badge> <gl-badge @@ -170,14 +172,14 @@ export default { <gl-badge v-for="deployKeysProject in restProjects" v-else-if="isExpanded" - :key="deployKeysProject.project.full_path" + :key="deployKeysProject.project.fullPath" v-gl-tooltip - :href="deployKeysProject.project.full_path" + :href="deployKeysProject.project.fullPath" :title="projectTooltipTitle(deployKeysProject)" - :icon="deployKeysProject.can_push ? 'lock-open' : 'lock'" + :icon="deployKeysProject.canPush ? 'lock-open' : 'lock'" class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate" > - <span class="gl-text-truncate">{{ deployKeysProject.project.full_name }}</span> + <span class="gl-text-truncate">{{ projectName(deployKeysProject) }}</span> </gl-badge> </template> <span v-else class="gl-text-secondary">{{ __('None') }}</span> @@ -188,8 +190,8 @@ export default { {{ __('Created') }} </div> <div class="table-mobile-content gl-text-gray-700 key-created-at"> - <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)"> - <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span> + <span v-gl-tooltip :title="tooltipTitle(deployKey.createdAt)"> + <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.createdAt) }}</span> </span> </div> </div> @@ -199,12 +201,12 @@ export default { </div> <div class="table-mobile-content gl-text-gray-700 key-expires-at"> <span - v-if="deployKey.expires_at" + v-if="deployKey.expiresAt" v-gl-tooltip - :title="tooltipTitle(deployKey.expires_at)" + :title="tooltipTitle(deployKey.expiresAt)" data-testid="expires-at-tooltip" > - <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span> + <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expiresAt) }}</span> </span> <span v-else> <span data-testid="expires-never">{{ __('Never') }}</span> @@ -213,13 +215,19 @@ export default { </div> <div class="table-section section-10 table-button-footer deploy-key-actions"> <div class="btn-group table-action-buttons"> - <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary"> + <action-btn + v-if="!isEnabled" + :deploy-key="deployKey" + :mutation="$options.enableKeyMutation" + category="secondary" + @error="onEnableError" + > {{ __('Enable') }} </action-btn> <gl-button - v-if="deployKey.can_edit" + v-if="deployKey.editPath" v-gl-tooltip - :href="editDeployKeyPath" + :href="deployKey.editPath" :title="__('Edit')" :aria-label="__('Edit')" data-container="body" @@ -232,10 +240,10 @@ export default { :deploy-key="deployKey" :title="__('Remove')" :aria-label="__('Remove')" + :mutation="$options.confirmDisableMutation" category="secondary" variant="danger" icon="remove" - type="remove" data-container="body" /> <action-btn @@ -244,7 +252,7 @@ export default { :deploy-key="deployKey" :title="__('Disable')" :aria-label="__('Disable')" - type="disable" + :mutation="$options.confirmDisableMutation" data-container="body" icon="cancel" category="secondary" diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index dac63188aa5..088b85e6093 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -10,14 +10,6 @@ export default { type: Array, required: true, }, - store: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, projectId: { type: String, required: false, @@ -48,8 +40,6 @@ export default { v-for="deployKey in keys" :key="deployKey.id" :deploy-key="deployKey" - :store="store" - :endpoint="endpoint" :project-id="projectId" /> </template> diff --git a/app/assets/javascripts/deploy_keys/graphql/resolvers.js b/app/assets/javascripts/deploy_keys/graphql/resolvers.js index 1993801636e..a8693665b90 100644 --- a/app/assets/javascripts/deploy_keys/graphql/resolvers.js +++ b/app/assets/javascripts/deploy_keys/graphql/resolvers.js @@ -15,6 +15,8 @@ export const mapDeployKey = (deployKey) => ({ __typename: 'LocalDeployKey', }); +const DEFAULT_PAGE_SIZE = 5; + export const resolvers = (endpoints) => ({ Project: { deployKeys(_, { scope, page }, { client }) { @@ -25,19 +27,21 @@ export const resolvers = (endpoints) => ({ endpoint = endpoints.enabledKeysEndpoint; } - return axios.get(endpoint, { params: { page } }).then(({ headers, data }) => { - const normalizedHeaders = normalizeHeaders(headers); - const pageInfo = { - ...parseIntPagination(normalizedHeaders), - __typename: 'LocalPageInfo', - }; - client.writeQuery({ - query: pageInfoQuery, - variables: { input: { page, scope } }, - data: { pageInfo }, + return axios + .get(endpoint, { params: { page, per_page: DEFAULT_PAGE_SIZE } }) + .then(({ headers, data }) => { + const normalizedHeaders = normalizeHeaders(headers); + const pageInfo = { + ...parseIntPagination(normalizedHeaders), + __typename: 'LocalPageInfo', + }; + client.writeQuery({ + query: pageInfoQuery, + variables: { input: { page, scope } }, + data: { pageInfo }, + }); + return data?.keys?.map(mapDeployKey) || []; }); - return data?.keys?.map(mapDeployKey) || []; - }); }, }, Mutation: { @@ -48,6 +52,13 @@ export const resolvers = (endpoints) => ({ }); }, currentScope(_, { scope }, { client }) { + const key = `${scope}Endpoint`; + const { [key]: endpoint } = endpoints; + + if (!endpoint) { + throw new Error(`invalid deploy key scope selected: ${scope}`); + } + client.writeQuery({ query: currentPageQuery, data: { currentPage: 1 }, diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js index 83601d5b2e3..673462073f0 100644 --- a/app/assets/javascripts/deploy_keys/index.js +++ b/app/assets/javascripts/deploy_keys/index.js @@ -1,24 +1,26 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import DeployKeysApp from './components/app.vue'; +import { createApolloProvider } from './graphql/client'; -export default () => - new Vue({ - el: document.getElementById('js-deploy-keys'), - components: { - DeployKeysApp, - }, - data() { - return { - endpoint: this.$options.el.dataset.endpoint, - projectId: this.$options.el.dataset.projectId, - }; - }, +Vue.use(VueApollo); + +export default () => { + const el = document.getElementById('js-deploy-keys'); + return new Vue({ + el, + apolloProvider: createApolloProvider({ + enabledKeysEndpoint: el.dataset.enabledEndpoint, + availableProjectKeysEndpoint: el.dataset.availableProjectEndpoint, + availablePublicKeysEndpoint: el.dataset.availablePublicEndpoint, + }), render(createElement) { - return createElement('deploy-keys-app', { + return createElement(DeployKeysApp, { props: { - endpoint: this.endpoint, - projectId: this.projectId, + projectId: el.dataset.projectId, + projectPath: el.dataset.projectPath, }, }); }, }); +}; diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js deleted file mode 100644 index 2837fc8ed88..00000000000 --- a/app/assets/javascripts/deploy_keys/service/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export default class DeployKeysService { - constructor(endpoint) { - this.endpoint = endpoint; - } - - getKeys() { - return axios.get(this.endpoint).then((response) => response.data); - } - - enableKey(id) { - return axios.put(`${this.endpoint}/${id}/enable`).then((response) => response.data); - } - - disableKey(id) { - return axios.put(`${this.endpoint}/${id}/disable`).then((response) => response.data); - } -} diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js deleted file mode 100644 index dcd77e921cd..00000000000 --- a/app/assets/javascripts/deploy_keys/store/index.js +++ /dev/null @@ -1,9 +0,0 @@ -export default class DeployKeysStore { - constructor() { - this.keys = {}; - } - - isEnabled(id) { - return this.keys.enabled_keys.some((key) => key.id === id); - } -} diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 00fd9f43a4f..698fd3909ed 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -144,6 +144,11 @@ export default { required: false, default: '', }, + pinnedFileUrl: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -153,6 +158,7 @@ export default { autoScrolled: false, activeProject: undefined, hasScannerError: false, + pinnedFileStatus: '', }; }, apollo: { @@ -215,7 +221,6 @@ export default { ...mapState('findingsDrawer', ['activeDrawer']), ...mapState('diffs', [ 'isLoading', - 'diffFiles', 'diffViewType', 'commit', 'renderOverflowWarning', @@ -245,6 +250,7 @@ export default { 'isBatchLoading', 'isBatchLoadingError', 'flatBlobsList', + 'diffFiles', ]), ...mapGetters(['isNotesFetched', 'getNoteableData']), ...mapGetters('findingsDrawer', ['activeDrawer']), @@ -355,7 +361,7 @@ export default { const id = window?.location?.hash; if (id && id.indexOf('#note') !== 0) { - this.setHighlightedRow(id.split('diff-content').pop().slice(1)); + this.setHighlightedRow({ lineCode: id.split('diff-content').pop().slice(1) }); } const events = []; @@ -438,6 +444,7 @@ export default { 'setFileByFile', 'disableVirtualScroller', 'setGenerateTestFilePath', + 'fetchPinnedFile', ]), ...mapActions('findingsDrawer', ['setDrawer']), closeDrawer() { @@ -509,6 +516,20 @@ export default { return !this.diffFiles.length; }, fetchData({ toggleTree = true, fetchMeta = true } = {}) { + if (this.pinnedFileUrl && this.pinnedFileStatus !== 'loaded') { + this.pinnedFileStatus = 'loading'; + this.fetchPinnedFile(this.pinnedFileUrl) + .then(() => { + this.pinnedFileStatus = 'loaded'; + if (toggleTree) this.setTreeDisplay(); + }) + .catch(() => { + this.pinnedFileStatus = 'error'; + createAlert({ + message: __("Couldn't fetch the pinned file."), + }); + }); + } if (fetchMeta) { this.fetchDiffFilesMeta() .then((data) => { @@ -539,7 +560,7 @@ export default { } if (!this.viewDiffsFileByFile) { - this.fetchDiffFilesBatch() + this.fetchDiffFilesBatch(Boolean(this.pinnedFileUrl)) .then(() => { if (toggleTree) this.setTreeDisplay(); // Guarantee the discussions are assigned after the batch finishes. @@ -724,6 +745,9 @@ export default { <gl-loading-icon size="lg" /> </div> <template v-else-if="renderDiffFiles"> + <div v-if="pinnedFileStatus === 'loading'" class="loading"> + <gl-loading-icon size="lg" /> + </div> <dynamic-scroller v-if="isVirtualScrollingEnabled" :items="diffs" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 7493bd5fdf7..3545eb4ed73 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -94,7 +94,7 @@ export default { class="d-block d-sm-flex flex-row-reverse justify-content-between align-items-start flex-lg-row-reverse" > <div - class="commit-actions flex-row d-none d-sm-flex align-items-start flex-wrap justify-content-end" + class="commit-actions flex-row d-none d-sm-flex align-items-center flex-wrap justify-content-end" > <div v-if="commit.signature_html" @@ -105,7 +105,7 @@ export default { :endpoint="commit.pipeline_status_path" class="d-inline-flex mb-2" /> - <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group"> + <gl-button-group class="gl-ml-4" data-testid="commit-sha-group"> <gl-button label class="gl-font-monospace" data-testid="commit-sha-short-id">{{ commit.short_id }}</gl-button> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 82b721da493..39f642b0831 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -17,6 +17,7 @@ import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import NoteForm from '~/notes/components/note_form.vue'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import { fileContentsId } from '~/diffs/components/diff_row_utils'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE, @@ -110,7 +111,10 @@ export default { 'canMerge', ]), ...mapGetters(['isNotesFetched', 'getNoteableData', 'noteableType']), - ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']), + ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled', 'pinnedFile']), + isPinnedFile() { + return this.file === this.pinnedFile; + }, viewBlobHref() { return escape(this.file.view_path); }, @@ -206,6 +210,9 @@ export default { diffFileHash() { return this.file.file_hash; }, + fileId() { + return fileContentsId(this.file); + }, }, watch: { 'file.id': { @@ -293,7 +300,7 @@ export default { }, handleToggle({ viaUserInteraction = false } = {}) { const collapsingNow = !this.isCollapsed; - const contentElement = this.$el.querySelector(`#diff-content-${this.file.file_hash}`); + const contentElement = this.$el.querySelector(`#${fileContentsId(this.file)}`); this.setFileCollapsedByUser({ filePath: this.file.file_path, @@ -386,6 +393,7 @@ export default { 'comments-disabled': Boolean(file.brokenSymlink), 'has-body': showBody, 'is-virtual-scrolling': isVirtualScrollingEnabled, + 'pinned-file': isPinnedFile, }" :data-path="file.new_path" class="diff-file file-holder gl-border-none gl-mb-0! gl-pb-5" @@ -400,6 +408,7 @@ export default { :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" :show-local-file-reviews="showLocalFileReviews" + :pinned="isPinnedFile" class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" :class="hasBodyClasses.header" @toggleFile="handleToggle({ viaUserInteraction: true })" @@ -428,7 +437,7 @@ export default { </div> <template v-else> <div - :id="`diff-content-${file.file_hash}`" + :id="fileId" :class="hasBodyClasses.contentByHash" class="diff-content" data-testid="content-area" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index e45fd508a5b..97db0fc1c24 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -22,6 +22,7 @@ import { __, s__, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { fileContentsId, pinnedFileHref } from '~/diffs/components/diff_row_utils'; import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants'; import { DIFF_FILE_HEADER } from '../i18n'; import { collapsedType, isCollapsed } from '../utils/diff_file'; @@ -102,6 +103,11 @@ export default { required: false, default: false, }, + pinned: { + type: Boolean, + required: false, + default: false, + }, }, idState() { return { @@ -113,9 +119,8 @@ export default { ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), ...mapGetters(['getNoteableData']), diffContentIDSelector() { - return `#diff-content-${this.diffFile.file_hash}`; + return `${pinnedFileHref(this.diffFile)}#${fileContentsId(this.diffFile)}`; }, - titleLink() { if (this.diffFile.submodule) { return this.diffFile.submodule_tree_url || this.diffFile.submodule_link; @@ -222,6 +227,7 @@ export default { 'setFileForcedOpen', 'setGenerateTestFilePath', 'toggleFileCommentForm', + 'unpinFile', ]), handleToggleFile() { this.setFileForcedOpen({ @@ -295,7 +301,19 @@ export default { > <div class="file-header-content"> <gl-button - v-if="collapsible" + v-if="pinned" + v-gl-tooltip.hover.focus + :title="__('Unpin the file')" + :aria-label="__('Unpin the file')" + icon="thumbtack" + size="small" + class="btn-icon gl-mr-2" + category="tertiary" + data-testid="unpin-button" + @click="unpinFile" + /> + <gl-button + v-else-if="collapsible" ref="collapseButton" class="gl-mr-2" category="tertiary" @@ -305,10 +323,10 @@ export default { @click.stop="handleToggleFile" /> <a - ref="titleWrapper" :v-once="!viewDiffsFileByFile" class="gl-mr-2 gl-text-decoration-none! gl-word-break-all" :href="titleLink" + data-testid="file-title" @click="handleFileNameClick" > <span v-if="isFileRenamed"> @@ -354,7 +372,7 @@ export default { <small v-if="isModeChanged" ref="fileMode" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus class="mr-1" :title="$options.i18n.fileModeTooltip" > @@ -377,7 +395,7 @@ export default { /> <gl-form-checkbox v-if="isReviewable && showLocalFileReviews" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus data-testid="fileReviewCheckbox" class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center" :title="$options.i18n.fileReviewTooltip" @@ -388,7 +406,7 @@ export default { </gl-form-checkbox> <gl-button v-if="showCommentButton" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus :title="__('Comment on this file')" :aria-label="__('Comment on this file')" icon="comment" @@ -402,7 +420,7 @@ export default { <gl-button v-if="diffFile.external_url" ref="externalLink" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus :href="diffFile.external_url" :title="externalUrlLabel" :aria-label="externalUrlLabel" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 3dad7a1a8e4..a9da77104d4 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -292,7 +292,7 @@ export default { v-if="props.line.left.old_line && props.line.left.type !== $options.CONFLICT_THEIR" :data-linenumber="props.line.left.old_line" :href="props.line.lineHrefOld" - @click="listeners.setHighlightedRow(props.line.lineCode)" + @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })" > </a> <component @@ -318,7 +318,7 @@ export default { v-if="props.line.left.new_line && props.line.left.type !== $options.CONFLICT_OUR" :data-linenumber="props.line.left.new_line" :href="props.line.lineHrefOld" - @click="listeners.setHighlightedRow(props.line.lineCode)" + @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })" > </a> </div> @@ -446,7 +446,7 @@ export default { v-if="props.line.right.new_line" :data-linenumber="props.line.right.new_line" :href="props.line.lineHrefNew" - @click="listeners.setHighlightedRow(props.line.lineCode)" + @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })" > </a> <component diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index a489c96b0c9..5c62e0179ac 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -33,7 +33,19 @@ export const shouldRenderCommentButton = (isLoggedIn, isCommentButtonRendered) = export const hasDiscussions = (line) => line?.discussions?.length > 0; -export const lineHref = (line) => `#${line?.line_code || ''}`; +export const pinnedFileHref = (diffFile) => { + if (!window?.gon?.features?.pinnedFile) return ''; + return `?pin=${diffFile.file_hash}`; +}; + +export const lineHref = (line, content) => { + if (!line || !line.line_code) return ''; + return `${pinnedFileHref(content.diffFile)}#${line.line_code}`; +}; + +export const fileContentsId = (diffFile) => { + return `diff-content-${diffFile.file_hash}`; +}; export const lineCode = (line) => { if (!line) return undefined; @@ -179,8 +191,8 @@ export const mapParallel = (content) => (line) => { isContextLineRight: isContextLine(right?.type), hasDiscussionsLeft: hasDiscussions(left), hasDiscussionsRight: hasDiscussions(right), - lineHrefOld: lineHref(left), - lineHrefNew: lineHref(right), + lineHrefOld: lineHref(left, content), + lineHrefNew: lineHref(right, content), lineCode: lineCode(line), isMetaLineLeft: isMetaLine(left?.type), isMetaLineRight: isMetaLine(right?.type), diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 07984beb709..ab21391b364 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -34,7 +34,7 @@ export default { }, computed: { ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']), - ...mapGetters('diffs', ['allBlobs']), + ...mapGetters('diffs', ['allBlobs', 'pinnedFile']), filteredTreeList() { let search = this.search.toLowerCase().trim(); @@ -71,21 +71,59 @@ export default { // out: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'b' }, { path: 'c' }] flatFilteredTreeList() { const result = []; - const createFlatten = (level) => (item) => { + const createFlatten = (level, hidden) => (item) => { result.push({ ...item, + hidden, level: item.isHeader ? 0 : level, key: item.key || item.path, }); - if (item.opened || item.isHeader) { - item.tree.forEach(createFlatten(level + 1)); - } + const isHidden = hidden || (item.type === 'tree' && !item.opened); + item.tree.forEach(createFlatten(level + 1, isHidden)); }; this.filteredTreeList.forEach(createFlatten(0)); return result; }, + flatListWithPinnedFile() { + const result = [...this.flatFilteredTreeList]; + const pinnedIndex = result.findIndex((item) => item.path === this.pinnedFile.file_path); + const [pinnedItem] = result.splice(pinnedIndex, 1); + + if (pinnedItem.parentPath === '/') + return [{ ...pinnedItem, level: 0, pinned: true, hidden: false }, ...result]; + + // remove detached folder from the tree + const next = result[pinnedIndex]; + const prev = result[pinnedIndex - 1]; + const hasContainingFolder = + prev && prev.type === 'tree' && prev.level === pinnedItem.level - 1; + const hasSibling = next && next.type !== 'tree' && next.level === pinnedItem.level; + if (hasContainingFolder && !hasSibling) { + // folder tree is always condensed so we only need to remove the parent folder + result.splice(pinnedIndex - 1, 1); + } + + return [ + { + level: 0, + key: 'pinned-path', + isHeader: true, + opened: true, + path: pinnedItem.parentPath, + type: 'tree', + hidden: false, + }, + { ...pinnedItem, level: 1, pinned: true, hidden: false }, + ...result, + ]; + }, + treeList() { + const list = this.pinnedFile ? this.flatListWithPinnedFile : this.flatFilteredTreeList; + if (this.search) return list; + return list.filter((item) => !item.hidden); + }, }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), @@ -125,13 +163,13 @@ export default { </button> </div> </div> - <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="flatFilteredTreeList.length"> + <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="treeList.length"> <template #default="{ scrollerHeight, rowHeight }"> <div :class="{ 'tree-list-blobs': !renderTreeList || search }" class="mr-tree-list"> <recycle-scroller - v-if="flatFilteredTreeList.length" + v-if="treeList.length" :style="{ height: `${scrollerHeight}px` }" - :items="flatFilteredTreeList" + :items="treeList" :item-size="rowHeight" :buffer="100" key-field="key" diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 15e4225f062..b219b5499c9 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -93,6 +93,7 @@ export default function initDiffsApp(store = notesStore) { helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, + pinnedFileUrl: dataset.pinnedFileUrl, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 1c0e20183e2..e15d403bd11 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -10,7 +10,7 @@ import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; -import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; +import { mergeUrlParams, getLocationHash, getParameterValues } from '~/lib/utils/url_utility'; import notesEventHub from '~/notes/event_hub'; import { generateTreeList } from '~/diffs/utils/tree_worker_utils'; import { sortTree } from '~/ide/stores/utils'; @@ -54,7 +54,6 @@ import { } from '../constants'; import { DISCUSSION_SINGLE_DIFF_FAILED, - LOAD_SINGLE_DIFF_FAILED, BUILDING_YOUR_MR, SOMETHING_WENT_WRONG, ERROR_LOADING_FULL_DIFF, @@ -119,7 +118,9 @@ export const setBaseConfig = ({ commit }, options) => { }; export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry) => { - const versionPath = state.mergeRequestDiff?.version_path; + const url = new URL(state.endpointBatch, 'https://gitlab.com'); + const diffId = getParameterValues('diff_id', url)[0]; + const startSha = getParameterValues('start_sha', url)[0]; if ( treeEntry && @@ -133,12 +134,14 @@ export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry) w: state.showWhitespace ? '0' : '1', view: 'inline', commit_id: getters.commitId, + diff_head: true, }; - if (versionPath) { - const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath }); - + if (diffId) { urlParams.diff_id = diffId; + } + + if (startSha) { urlParams.start_sha = startSha; } @@ -161,7 +164,9 @@ export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry) export const fetchFileByFile = async ({ state, getters, commit }) => { const isNoteLink = isUrlHashNoteLink(window?.location?.hash); const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId); - const versionPath = state.mergeRequestDiff?.version_path; + const url = new URL(state.endpointBatch, 'https://gitlab.com'); + const diffId = getParameterValues('diff_id', url)[0]; + const startSha = getParameterValues('start_sha', url)[0]; const treeEntry = id ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id) : getters.flatBlobsList[0]; @@ -179,12 +184,14 @@ export const fetchFileByFile = async ({ state, getters, commit }) => { w: state.showWhitespace ? '0' : '1', view: 'inline', commit_id: getters.commitId, + diff_head: true, }; - if (versionPath) { - const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath }); - + if (diffId) { urlParams.diff_id = diffId; + } + + if (startSha) { urlParams.start_sha = startSha; } @@ -210,7 +217,7 @@ export const fetchFileByFile = async ({ state, getters, commit }) => { } }; -export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { +export const fetchDiffFilesBatch = ({ commit, state, dispatch }, pinnedFileLoading = false) => { let perPage = state.viewDiffsFileByFile ? 1 : state.perPage; let increaseAmount = 1.4; const startPage = 0; @@ -224,8 +231,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { let totalLoaded = 0; let scrolledVirtualScroller = hash === ''; - commit(types.SET_BATCH_LOADING_STATE, 'loading'); - commit(types.SET_RETRIEVING_BATCHES, true); + if (!pinnedFileLoading) { + commit(types.SET_BATCH_LOADING_STATE, 'loading'); + commit(types.SET_RETRIEVING_BATCHES, true); + } eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START); const getBatch = (page = startPage) => @@ -237,7 +246,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffFiles }); commit(types.SET_BATCH_LOADING_STATE, 'loaded'); - if (!scrolledVirtualScroller) { + if (!scrolledVirtualScroller && !pinnedFileLoading) { const index = state.diffFiles.findIndex( (f) => f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash), @@ -301,9 +310,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { return null; }) - .catch(() => { + .catch((error) => { commit(types.SET_RETRIEVING_BATCHES, false); commit(types.SET_BATCH_LOADING_STATE, 'error'); + throw error; }); return getBatch(); @@ -384,7 +394,11 @@ export const fetchCoverageFiles = ({ commit, state }) => { coveragePoll.makeRequest(); }; -export const setHighlightedRow = ({ commit }, lineCode) => { +export const setHighlightedRow = ({ commit }, { lineCode, event }) => { + if (event && event.target.href) { + event.preventDefault(); + window.history.replaceState(null, undefined, event.target.href); + } const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); commit(types.SET_CURRENT_DIFF_FILE, fileHash); @@ -657,6 +671,8 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => { } else { if (!state.treeEntries[path]) return; + dispatch('unpinFile'); + const { fileHash } = state.treeEntries[path]; commit(types.SET_CURRENT_DIFF_FILE, fileHash); @@ -667,11 +683,7 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => { scrollToElement('.diff-files-holder', { duration: 0 }); if (!getters.isTreePathLoaded(path)) { - dispatch('fetchFileByFile').catch(() => { - createAlert({ - message: LOAD_SINGLE_DIFF_FAILED, - }); - }); + dispatch('fetchFileByFile'); } } }; @@ -995,6 +1007,8 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n }; export const navigateToDiffFileIndex = ({ state, getters, commit, dispatch }, index) => { + dispatch('unpinFile'); + const { fileHash } = getters.flatBlobsList[index]; document.location.hash = fileHash; @@ -1055,3 +1069,50 @@ export const toggleFileCommentForm = ({ state, commit }, filePath) => { export const addDraftToFile = ({ commit }, { filePath, draft }) => commit(types.ADD_DRAFT_TO_FILE, { filePath, draft }); + +export const fetchPinnedFile = ({ state, commit }, pinnedFileUrl) => { + const isNoteLink = isUrlHashNoteLink(window?.location?.hash); + + commit(types.SET_BATCH_LOADING_STATE, 'loading'); + commit(types.SET_RETRIEVING_BATCHES, true); + + return axios + .get(pinnedFileUrl) + .then(({ data: diffData }) => { + const [{ file_hash }] = diffData.diff_files; + + // we must store pinned file in the `diffs`, otherwise collapsing and commenting on a file won't work + // once the same file arrives in a file batch we must only update its' position + // we also must not update file's position since it's loaded out of order + commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files, updatePosition: false }); + commit(types.SET_PINNED_FILE_HASH, file_hash); + + if (!isNoteLink && !state.currentDiffFileId) { + commit(types.SET_CURRENT_DIFF_FILE, file_hash); + } + + commit(types.SET_BATCH_LOADING_STATE, 'loaded'); + + setTimeout(() => { + handleLocationHash(); + }); + + eventHub.$emit('diffFilesModified'); + }) + .catch((error) => { + commit(types.SET_BATCH_LOADING_STATE, 'error'); + throw error; + }) + .finally(() => { + commit(types.SET_RETRIEVING_BATCHES, false); + }); +}; + +export const unpinFile = ({ getters, commit }) => { + if (!getters.pinnedFile) return; + commit(types.SET_PINNED_FILE_HASH, null); + const newUrl = new URL(window.location); + newUrl.searchParams.delete('pin'); + newUrl.hash = ''; + window.history.replaceState(null, undefined, newUrl); +}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index bfafb4d281d..f15c3d1693c 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -201,3 +201,18 @@ export const isVirtualScrollingEnabled = (state) => { export const isBatchLoading = (state) => state.batchLoadingState === 'loading'; export const isBatchLoadingError = (state) => state.batchLoadingState === 'error'; + +export const diffFiles = (state, getters) => { + const { pinnedFile } = getters; + if (pinnedFile) { + const diffs = state.diffFiles.slice(0); + diffs.splice(diffs.indexOf(pinnedFile), 1); + return [pinnedFile, ...diffs]; + } + return state.diffFiles; +}; + +export const pinnedFile = (state) => { + if (!state.pinnedFileHash) return null; + return state.diffFiles.find((file) => file.file_hash === state.pinnedFileHash); +}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index d5e1a05f4a5..3b6408e6d78 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -37,4 +37,5 @@ export default () => ({ mrReviews: {}, latestDiff: true, disableVirtualScroller: false, + pinnedFileHash: null, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index b155804c70c..7b19f96b151 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -6,6 +6,7 @@ export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES'; export const SET_DIFF_METADATA = 'SET_DIFF_METADATA'; export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_FILES = 'SET_DIFF_FILES'; +export const SET_DIFF_TREE_ENTRY = 'SET_DIFF_TREE_ENTRY'; export const SET_MR_FILE_REVIEWS = 'SET_MR_FILE_REVIEWS'; @@ -23,6 +24,7 @@ export const TREE_ENTRY_DIFF_LOADING = 'TREE_ENTRY_DIFF_LOADING'; export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST'; export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE'; export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED'; +export const SET_PINNED_FILE_HASH = 'SET_PINNED_FILE_HASH'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index bc5ed3c40df..3c750830baa 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -81,15 +81,27 @@ export default { }); }, - [types.SET_DIFF_DATA_BATCH](state, data) { + [types.SET_DIFF_DATA_BATCH](state, { diff_files: diffFiles, updatePosition = true }) { Object.assign(state, { diffFiles: prepareDiffData({ - diff: data, + diff: { diff_files: diffFiles }, priorFiles: state.diffFiles, + // when a pinned file is added to diffs its position may be incorrect since it's loaded out of order + // we need to ensure when we load it in batched request it updates it position + updatePosition, }), treeEntries: markTreeEntriesLoaded({ priorEntries: state.treeEntries, - loadedFiles: data.diff_files, + loadedFiles: diffFiles, + }), + }); + }, + + [types.SET_DIFF_TREE_ENTRY](state, diffFile) { + Object.assign(state, { + treeEntries: markTreeEntriesLoaded({ + priorEntries: state.treeEntries, + loadedFiles: [diffFile], }), }); }, @@ -404,4 +416,7 @@ export default { file?.drafts.push(draft); }, + [types.SET_PINNED_FILE_HASH](state, fileHash) { + state.pinnedFileHash = fileHash; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index fb467a606b9..ad8cacf8504 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -395,9 +395,15 @@ function finalizeDiffFile(file) { return file; } -function deduplicateFilesList(files) { +function deduplicateFilesList(files, updatePosition) { const dedupedFiles = files.reduce((newList, file) => { const id = diffFileUniqueId(file); + if (updatePosition && id in newList) { + // Object.values preserves key order but doesn't update order when writing to the same key + // In order to update position of the item we have to delete it first and then add it back + // eslint-disable-next-line no-param-reassign + delete newList[id]; + } return { ...newList, @@ -408,14 +414,20 @@ function deduplicateFilesList(files) { return Object.values(dedupedFiles); } -export function prepareDiffData({ diff, priorFiles = [], meta = false }) { - const cleanedFiles = (diff.diff_files || []) - .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta, index })) - .map(ensureBasicDiffFileLines) - .map(prepareDiffFileLines) - .map((file) => finalizeDiffFile(file)); +export function prepareDiffData({ diff, priorFiles = [], meta = false, updatePosition = false }) { + const transformersChain = [ + (file, index, allFiles) => prepareRawDiffFile({ file, index, allFiles, meta }), + ensureBasicDiffFileLines, + prepareDiffFileLines, + finalizeDiffFile, + ]; + const cleanedFiles = (diff.diff_files || []).map((file, index, allFiles) => { + return transformersChain.reduce((fileResult, transformer) => { + return transformer(fileResult, index, allFiles); + }, file); + }); - return deduplicateFilesList([...priorFiles, ...cleanedFiles]); + return deduplicateFilesList([...priorFiles, ...cleanedFiles], updatePosition); } export function getDiffPositionByLineCode(diffFiles) { diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 9ee4f7cf4aa..17744e2c6ab 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -46,7 +46,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { let hasPlainText; formTextarea.wrap('<div class="div-dropzone"></div>'); - formTextarea.on('paste', (event) => handlePaste(event)); // Add dropzone area to the form. const $mdArea = formTextarea.closest('.md-area'); @@ -60,6 +59,8 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { return null; } + formTextarea.on('paste', (event) => handlePaste(event)); + const dropzone = $formDropzone.dropzone({ url: uploadsPath, dictDefaultMessage: '', diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 1fb68394912..3fbd4728cfc 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -154,6 +154,9 @@ "always", "never" ] + }, + "auto_cancel": { + "$ref": "#/definitions/workflowAutoCancel" } }, "additionalProperties": false @@ -528,6 +531,12 @@ "type": "string", "minLength": 1, "description": "Image architecture to pull." + }, + "user": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Username or UID to use for the container." } } }, @@ -603,6 +612,12 @@ "type": "string", "minLength": 1, "description": "Image architecture to pull." + }, + "user": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Username or UID to use for the container." } } }, @@ -735,6 +750,30 @@ } ] }, + "gcp_secret_manager": { + "type": "object", + "markdownDescription": "Defines the secret version to be fetched from GCP Secret Manager. Name refers to the secret name in GCP secret manager. Version refers to the desired secret version (defaults to 'latest').", + "properties": { + "name": { + "type": "string" + }, + "version": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "default": "version" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, "azure_key_vault": { "type": "object", "properties": { @@ -757,7 +796,7 @@ }, "token": { "type": "string", - "description": "Specifies the JWT variable that should be used to authenticate with Hashicorp Vault." + "description": "Specifies the JWT variable that should be used to authenticate with the secret provider." } }, "anyOf": [ @@ -770,8 +809,18 @@ "required": [ "azure_key_vault" ] + }, + { + "required": [ + "gcp_secret_manager" + ] } ], + "dependencies": { + "gcp_secret_manager": [ + "token" + ] + }, "additionalProperties": false } } @@ -944,7 +993,8 @@ }, "workflowAutoCancel": { "type": "object", - "markdownDescription": "Define the rules for when pipeline should be automatically cancelled.", + "description": "Define the rules for when pipeline should be automatically cancelled.", + "additionalProperties": false, "properties": { "on_job_failure": { "markdownDescription": "Define which jobs to stop after a job fails.", @@ -954,6 +1004,15 @@ "none", "all" ] + }, + "on_new_commit": { + "markdownDescription": "Configure the behavior of the auto-cancel redundant pipelines feature. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowauto_cancelon_new_commit)", + "type": "string", + "enum": [ + "conservative", + "interruptible", + "none" + ] } } }, diff --git a/app/assets/javascripts/entrypoints/analytics.js b/app/assets/javascripts/entrypoints/analytics.js index e18c4bc8742..f5c61d82f64 100644 --- a/app/assets/javascripts/entrypoints/analytics.js +++ b/app/assets/javascripts/entrypoints/analytics.js @@ -13,6 +13,10 @@ if (appId && host) { performanceTiming: false, errorTracking: false, }, + pagePingTracking: { + minimumVisitLength: 10, + heartbeatDelay: 10, + }, }); const userId = window.gl?.snowplowStandardContext?.data?.user_id; diff --git a/app/assets/javascripts/entrypoints/sandboxed_swagger.js b/app/assets/javascripts/entrypoints/sandboxed_swagger.js new file mode 100644 index 00000000000..993896e6a2a --- /dev/null +++ b/app/assets/javascripts/entrypoints/sandboxed_swagger.js @@ -0,0 +1 @@ +import '../lib/swagger'; diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue index 20ed67f6bd9..8b305160b2b 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -6,6 +6,8 @@ import { SYNC_STATUS_BADGES, STATUS_TRUE, STATUS_FALSE, + STATUS_UNKNOWN, + REASON_PROGRESSING, HELM_RELEASES_RESOURCE_TYPE, KUSTOMIZATIONS_RESOURCE_TYPE, } from '../constants'; @@ -115,6 +117,15 @@ export default { return condition.status === STATUS_TRUE && condition.type === 'Stalled'; }); }, + fluxAnyReconcilingWithBadConfig() { + return this.fluxCRD.find((condition) => { + return ( + condition.status === STATUS_UNKNOWN && + condition.type === 'Ready' && + condition.reason === REASON_PROGRESSING + ); + }); + }, fluxAnyReconciling() { return this.fluxCRD.find((condition) => { return condition.status === STATUS_TRUE && condition.type === 'Reconciling'; @@ -143,6 +154,12 @@ export default { if (this.fluxAnyStalled) { return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message }; } + if (this.fluxAnyReconcilingWithBadConfig) { + return { + ...SYNC_STATUS_BADGES.reconciling, + popoverText: this.fluxAnyReconcilingWithBadConfig.message, + }; + } if (this.fluxAnyReconciling) { return SYNC_STATUS_BADGES.reconciling; } diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue index 0d80b1fd797..da37df3fae7 100644 --- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue +++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue @@ -1,9 +1,12 @@ <script> import { GlTabs, GlTab, GlLoadingIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { getAge } from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; +import { + getAge, + generateServicePortsString, +} from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; +import { SERVICES_TABLE_FIELDS } from '~/kubernetes_dashboard/constants'; import k8sServicesQuery from '../graphql/queries/k8s_services.query.graphql'; -import { generateServicePortsString } from '../helpers/k8s_integration_helper'; import { SERVICES_LIMIT_PER_PAGE } from '../constants'; import KubernetesSummary from './kubernetes_summary.vue'; @@ -82,6 +85,14 @@ export default { ? null : nextPage; }, + servicesFields() { + return SERVICES_TABLE_FIELDS.map((field) => { + return { + ...field, + thClass: tableHeadingClasses, + }; + }); + }, }, i18n: { servicesTitle: s__('Environment|Services'), @@ -94,43 +105,6 @@ export default { ports: s__('Environment|Ports'), age: s__('Environment|Age'), }, - servicesFields: [ - { - key: 'name', - label: __('Name'), - thClass: tableHeadingClasses, - }, - { - key: 'namespace', - label: __('Namespace'), - thClass: tableHeadingClasses, - }, - { - key: 'type', - label: __('Type'), - thClass: tableHeadingClasses, - }, - { - key: 'clusterIP', - label: s__('Environment|Cluster IP'), - thClass: tableHeadingClasses, - }, - { - key: 'externalIP', - label: s__('Environment|External IP'), - thClass: tableHeadingClasses, - }, - { - key: 'ports', - label: s__('Environment|Ports'), - thClass: tableHeadingClasses, - }, - { - key: 'age', - label: s__('Environment|Age'), - thClass: tableHeadingClasses, - }, - ], SERVICES_LIMIT_PER_PAGE, }; </script> @@ -154,7 +128,7 @@ export default { <gl-table v-else - :fields="$options.servicesFields" + :fields="servicesFields" :items="servicesItems" :per-page="$options.SERVICES_LIMIT_PER_PAGE" :current-page="currentPage" diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index aacb460a817..8bd55e697fa 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -368,6 +368,7 @@ export default { </div> <div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses"> <kubernetes-overview + :class="{ 'gl-ml-7': inFolder }" :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" :flux-resource-path="fluxResourcePath" diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index b583694e154..76fa0c9bcb2 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,13 +1,15 @@ <script> import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; import eventHub from '../event_hub'; import stopEnvironmentMutation from '../graphql/mutations/stop_environment.mutation.graphql'; export default { - yamlDocsLink: `${DOCS_URL_IN_EE_DIR}/ee/ci/yaml/`, - stoppingEnvironmentDocsLink: `${DOCS_URL_IN_EE_DIR}/environments/#stopping-an-environment`, + yamlDocsLink: helpPagePath('ci/yaml/index'), + stoppingEnvironmentDocsLink: helpPagePath('ci/environments/index', { + anchor: 'stopping-an-environment', + }), id: 'stop-environment-modal', name: 'StopEnvironmentModal', diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 2fe9008c042..c996d70af52 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -154,6 +154,9 @@ export const SYNC_STATUS_BADGES = { export const STATUS_TRUE = 'True'; export const STATUS_FALSE = 'False'; +export const STATUS_UNKNOWN = 'Unknown'; + +export const REASON_PROGRESSING = 'Progressing'; const ERROR_UNAUTHORIZED = 'unauthorized'; const ERROR_FORBIDDEN = 'forbidden'; diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 0201fb53f77..05a2eba6af3 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import Translate from '~/vue_shared/translate'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import { apolloProvider } from '../graphql/client'; import EnvironmentsFolderView from './environments_folder_view.vue'; import EnvironmentsFolderApp from './environments_folder_app.vue'; @@ -20,10 +21,9 @@ export default () => { if (gon.features.environmentsFolderNewLook) { Vue.use(VueRouter); - const folderName = environmentsData.environmentsDataFolderName; - const folderPath = environmentsData.environmentsDataEndpoint.replace('.json', ''); - const projectPath = environmentsData.environmentsDataProjectPath; - const helpPagePath = environmentsData.environmentsDataHelpPagePath; + const folderPath = environmentsData.endpoint.replace('.json', ''); + const kasTunnelUrl = removeLastSlashInUrlPath(environmentsData.kasTunnelUrl); + const { projectPath, folderName, helpPagePath } = environmentsData; const router = new VueRouter({ mode: 'history', @@ -54,6 +54,7 @@ export default () => { provide: { projectPath, helpPagePath, + kasTunnelUrl, }, apolloProvider, router, @@ -74,8 +75,8 @@ export default () => { }, data() { return { - endpoint: environmentsData.environmentsDataEndpoint, - folderName: environmentsData.environmentsDataFolderName, + endpoint: environmentsData.endpoint, + folderName: environmentsData.folderName, cssContainerClass: environmentsData.cssClass, }; }, diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 0eb12427914..de89934cd52 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -174,6 +174,8 @@ export const apolloProvider = (endpoint) => { cache.writeQuery({ query: fluxKustomizationStatusQuery, data: { + message: '', + reason: '', status: '', type: '', }, @@ -181,6 +183,8 @@ export const apolloProvider = (endpoint) => { cache.writeQuery({ query: fluxHelmReleaseStatusQuery, data: { + message: '', + reason: '', status: '', type: '', }, diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql index 042bdc1992d..35f7fe56b47 100644 --- a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql @@ -2,6 +2,7 @@ query getFluxHelmReleaseStatusQuery($configuration: LocalConfiguration, $fluxRes fluxHelmReleaseStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath) @client { message + reason status type } diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql index 458b8a4d9db..8564b306d5b 100644 --- a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql @@ -5,6 +5,7 @@ query getFluxHelmKustomizationStatusQuery( fluxKustomizationStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath) @client { message + reason status type } diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index bb5cab7c279..99d5ee44b6c 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -2,21 +2,17 @@ import { calculateDeploymentStatus, calculateStatefulSetStatus, calculateDaemonSetStatus, + calculateJobStatus, + calculateCronJobStatus, } from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; -import { STATUS_READY, STATUS_FAILED } from '~/kubernetes_dashboard/constants'; +import { + STATUS_READY, + STATUS_FAILED, + STATUS_COMPLETED, + STATUS_SUSPENDED, +} from '~/kubernetes_dashboard/constants'; import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants'; -export function generateServicePortsString(ports) { - if (!ports?.length) return ''; - - return ports - .map((port) => { - const nodePort = port.nodePort ? `:${port.nodePort}` : ''; - return `${port.port}${nodePort}/${port.protocol}`; - }) - .join(', '); -} - export function getDeploymentsStatuses(items) { const failed = []; const ready = []; @@ -45,13 +41,14 @@ export function getDeploymentsStatuses(items) { }; } +const isCompleted = (status) => status === STATUS_COMPLETED; +const isReady = (status) => status === STATUS_READY; +const isFailed = (status) => status === STATUS_FAILED; +const isSuspended = (status) => status === STATUS_SUSPENDED; + export function getDaemonSetStatuses(items) { - const failed = items.filter((item) => { - return calculateDaemonSetStatus(item) === STATUS_FAILED; - }); - const ready = items.filter((item) => { - return calculateDaemonSetStatus(item) === STATUS_READY; - }); + const failed = items.filter((item) => isFailed(calculateDaemonSetStatus(item))); + const ready = items.filter((item) => isReady(calculateDaemonSetStatus(item))); return { ...(failed.length && { failed }), @@ -60,12 +57,8 @@ export function getDaemonSetStatuses(items) { } export function getStatefulSetStatuses(items) { - const failed = items.filter((item) => { - return calculateStatefulSetStatus(item) === STATUS_FAILED; - }); - const ready = items.filter((item) => { - return calculateStatefulSetStatus(item) === STATUS_READY; - }); + const failed = items.filter((item) => isFailed(calculateStatefulSetStatus(item))); + const ready = items.filter((item) => isReady(calculateStatefulSetStatus(item))); return { ...(failed.length && { failed }), @@ -74,12 +67,8 @@ export function getStatefulSetStatuses(items) { } export function getReplicaSetStatuses(items) { - const failed = items.filter((item) => { - return calculateStatefulSetStatus(item) === STATUS_FAILED; - }); - const ready = items.filter((item) => { - return calculateStatefulSetStatus(item) === STATUS_READY; - }); + const failed = items.filter((item) => isFailed(calculateStatefulSetStatus(item))); + const ready = items.filter((item) => isReady(calculateStatefulSetStatus(item))); return { ...(failed.length && { failed }), @@ -88,12 +77,8 @@ export function getReplicaSetStatuses(items) { } export function getJobsStatuses(items) { - const failed = items.filter((item) => { - return item.status.failed > 0 || item.status?.succeeded !== item.spec?.completions; - }); - const completed = items.filter((item) => { - return item.status?.succeeded === item.spec?.completions; - }); + const failed = items.filter((item) => isFailed(calculateJobStatus(item))); + const completed = items.filter((item) => isCompleted(calculateJobStatus(item))); return { ...(failed.length && { failed }), @@ -107,11 +92,11 @@ export function getCronJobsStatuses(items) { const suspended = []; items.forEach((item) => { - if (item.status?.active > 0 && !item.status?.lastScheduleTime) { + if (isFailed(calculateCronJobStatus(item))) { failed.push(item); - } else if (item.spec?.suspend) { + } else if (isSuspended(calculateCronJobStatus(item))) { suspended.push(item); - } else if (item.status?.lastScheduleTime) { + } else if (isReady(calculateCronJobStatus(item))) { ready.push(item); } }); diff --git a/app/assets/javascripts/error_tracking/components/error_details_info.vue b/app/assets/javascripts/error_tracking/components/error_details_info.vue index 0b4eabe25d1..3db946acdfc 100644 --- a/app/assets/javascripts/error_tracking/components/error_details_info.vue +++ b/app/assets/javascripts/error_tracking/components/error_details_info.vue @@ -74,7 +74,9 @@ export default { </template> </gl-card> + <!-- user count is currently not supported for integrated error tracking https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2345 --> <gl-card + v-if="!error.integrated" :class="$options.CARD_CLASS" :body-class="$options.BODY_CLASS" :header-class="$options.HEADER_CLASS" diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 95ae5e5a92c..95b47a9e491 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -76,7 +76,7 @@ export default { { key: 'status', label: '', - tdClass: `${tableDataClass}`, + tdClass: `${tableDataClass} gl-text-center`, }, ], statusFilters: { @@ -182,6 +182,13 @@ export default { showIntegratedDisabledAlert() { return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert; }, + fields() { + if (this.integratedErrorTrackingEnabled) { + // user count is currently not supported for integrated error tracking https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2345 + return this.$options.fields.filter((field) => field.key !== 'users'); + } + return this.$options.fields; + }, }, watch: { pagination() { @@ -417,7 +424,7 @@ export default { <gl-table class="error-list-table gl-mt-5" :items="errors" - :fields="$options.fields" + :fields="fields" :show-empty="true" fixed stacked="md" diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b11f7b1ba76..f1e46262b2f 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 { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { s__, __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; @@ -26,6 +27,8 @@ export const CONTACT_STATE_ACTIVE = 'active'; export const CONTACTS_ADD_COMMAND = '/add_contacts'; export const CONTACTS_REMOVE_COMMAND = '/remove_contacts'; +const useMentionsBackendFiltering = window.gon.features?.mentionAutocompleteBackendFiltering; + /** * Escapes user input before we pass it to at.js, which * renders it as HTML in the autocomplete dropdown. @@ -62,6 +65,8 @@ export function showAndHideHelper($input, alias = '') { }); } +// This should be kept in sync with the backend filtering in +// `User#gfm_autocomplete_search` and `Namespace#gfm_autocomplete_search` function createMemberSearchString(member) { return `${member.name.replace(/ /g, '')} ${member.username}`; } @@ -344,6 +349,7 @@ class GfmAutoComplete { } setupMembers($input) { + const instance = this; const fetchData = this.fetchData.bind(this); const MEMBER_COMMAND = { ASSIGN: '/assign', @@ -383,6 +389,7 @@ class GfmAutoComplete { // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${username}', limit: 10, + delay: useMentionsBackendFiltering ? DEFAULT_DEBOUNCE_AND_THROTTLE_MS : null, searchKey: 'search', alwaysHighlightFirst: true, skipSpecialCharacterTest: true, @@ -409,16 +416,19 @@ class GfmAutoComplete { const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); return match && match.length ? match[1] : null; }, - filter(query, data, searchKey) { - if (GfmAutoComplete.isLoading(data)) { + filter(query, data) { + if (useMentionsBackendFiltering) { + if (GfmAutoComplete.isLoading(data) || instance.previousQuery !== query) { + instance.previousQuery = query; + + fetchData(this.$inputor, this.at, query); + return data; + } + } else if (GfmAutoComplete.isLoading(data)) { fetchData(this.$inputor, this.at); return data; } - if (data === GfmAutoComplete.defaultLoadingData) { - return $.fn.atwho.default.callbacks.filter(query, data, searchKey); - } - if (command === MEMBER_COMMAND.ASSIGN) { // Only include members which are not assigned to Issuable currently return data.filter((member) => !assignees.includes(member.search)); @@ -988,6 +998,11 @@ GfmAutoComplete.atTypeMap = { }; GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; + +if (useMentionsBackendFiltering) { + GfmAutoComplete.typesWithBackendFiltering.push('members'); +} + GfmAutoComplete.isTypeWithBackendFiltering = (type) => GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]); @@ -1040,6 +1055,8 @@ GfmAutoComplete.Members = { // `member.search` is a name:username string like `MargeSimpson msimpson` return member.search.toLowerCase().includes(query); }, + // This should be kept in sync with the backend sorting in + // `User#gfm_autocomplete_search` and `Namespace#gfm_autocomplete_search` sort(query, members) { const lowercaseQuery = query.toLowerCase(); const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index d0ba34b6127..8e19de9f7c2 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -1,6 +1,5 @@ import produce from 'immer'; import VueApollo from 'vue-apollo'; -import { defaultDataIdFromObject } from '@apollo/client/core'; import { concatPagination } from '@apollo/client/utilities'; import errorQuery from '~/boards/graphql/client/error.query.graphql'; import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql'; @@ -14,13 +13,6 @@ import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_ export const config = { typeDefs, cacheConfig: { - // included temporarily until Vuex is removed from boards app - dataIdFromObject: (object) => { - // eslint-disable-next-line no-underscore-dangle - return object.__typename === 'BoardList' && !window.gon?.features?.apolloBoards - ? object.iid - : defaultDataIdFromObject(object); - }, typePolicies: { Query: { fields: { @@ -34,6 +26,12 @@ export const config = { return currentState ?? []; }, }, + boardList: { + keyArgs: ['id'], + }, + epicBoardList: { + keyArgs: ['id'], + }, }, }, Project: { @@ -135,104 +133,80 @@ export const config = { nodes: concatPagination(), }, }, - ...(window.gon?.features?.apolloBoards - ? { - BoardList: { - fields: { - issues: { - keyArgs: ['filters'], - }, - }, - }, - IssueConnection: { - merge(existing = { nodes: [] }, incoming, { args }) { - if (!args?.after) { - return incoming; - } - return { - ...incoming, - nodes: [...existing.nodes, ...incoming.nodes], - }; - }, - }, - EpicList: { - fields: { - epics: { - keyArgs: ['filters'], - }, - }, - }, - EpicConnection: { - merge(existing = { nodes: [] }, incoming, { args }) { - if (!args.after) { - return incoming; - } - return { - ...incoming, - nodes: [...existing.nodes, ...incoming.nodes], - }; - }, - }, - Group: { - fields: { - projects: { - keyArgs: ['includeSubgroups', 'search'], - }, - descendantGroups: { - keyArgs: ['includeSubgroups', 'search'], - }, - }, - }, - ProjectConnection: { - fields: { - nodes: concatPagination(), - }, - }, - GroupConnection: { - fields: { - nodes: concatPagination(), - }, - }, - Board: { - fields: { - epics: { - keyArgs: ['boardId'], - }, - }, - }, - BoardEpicConnection: { - merge(existing = { nodes: [] }, incoming, { args }) { - if (!args.after) { - return incoming; - } - return { - ...incoming, - nodes: [...existing.nodes, ...incoming.nodes], - }; - }, - }, - Query: { - fields: { - boardList: { - keyArgs: ['id'], - }, - epicBoardList: { - keyArgs: ['id'], - }, - isShowingLabels: { - read(currentState) { - return currentState ?? true; - }, - }, - selectedBoardItems: { - read(currentState) { - return currentState ?? []; - }, - }, - }, - }, + BoardList: { + fields: { + issues: { + keyArgs: ['filters'], + }, + }, + }, + IssueConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args?.after) { + return incoming; } - : {}), + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + EpicList: { + fields: { + epics: { + keyArgs: ['filters'], + }, + }, + }, + EpicConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args?.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + Group: { + fields: { + projects: { + keyArgs: ['includeSubgroups', 'search'], + }, + descendantGroups: { + keyArgs: ['includeSubgroups', 'search'], + }, + }, + }, + ProjectConnection: { + fields: { + nodes: concatPagination(), + }, + }, + GroupConnection: { + fields: { + nodes: concatPagination(), + }, + }, + Board: { + fields: { + epics: { + keyArgs: ['boardId'], + }, + }, + }, + BoardEpicConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, }, }, }; diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 4edad63cc79..fe151c2b358 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -187,6 +187,7 @@ "WorkItemWidget": [ "WorkItemWidgetAssignees", "WorkItemWidgetAwardEmoji", + "WorkItemWidgetColor", "WorkItemWidgetCurrentUserTodos", "WorkItemWidgetDescription", "WorkItemWidgetHealthStatus", @@ -199,6 +200,7 @@ "WorkItemWidgetNotifications", "WorkItemWidgetProgress", "WorkItemWidgetRequirementLegacy", + "WorkItemWidgetRolledupDates", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetStatus", "WorkItemWidgetTestReports", diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 6a64e8a2fa8..828a18a240e 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -50,10 +50,10 @@ export const getTypeFromGraphQLId = (gid = '') => { return type || null; }; -export const MutationOperationMode = { - Append: 'APPEND', - Remove: 'REMOVE', - Replace: 'REPLACE', +export const mutationOperationMode = { + append: 'APPEND', + remove: 'REMOVE', + replace: 'REPLACE', }; /** diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 3440bd87e6b..4ede8fda01d 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -22,6 +22,7 @@ export default { GlLoadingIcon, GlEmptyState, }, + inject: ['emptySearchIllustration'], props: { action: { type: String, @@ -245,6 +246,7 @@ export default { <groups-component v-if="hasGroups" :groups="groups" :page-info="pageInfo" :action="action" /> <gl-empty-state v-else-if="fromSearch" + :svg-path="emptySearchIllustration" :title="$options.i18n.searchEmptyState.title" :description="$options.i18n.searchEmptyState.description" data-testid="search-empty-state" diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 3a08e3e546f..6e347a3c95b 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -20,6 +20,7 @@ import { VISIBILITY_LEVELS_STRING_TO_INTEGER, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, } from '~/visibility_level/constants'; import { ITEM_TYPE, ACTIVE_TAB_SHARED } from '../constants'; @@ -105,7 +106,8 @@ export default { return VISIBILITY_TYPE_ICON[this.group.visibility]; }, visibilityTooltip() { - return GROUP_VISIBILITY_TYPE[this.group.visibility]; + if (this.isGroup) return GROUP_VISIBILITY_TYPE[this.group.visibility]; + return PROJECT_VISIBILITY_TYPE[this.group.visibility]; }, microdata() { return this.group.microdata || {}; diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index 853fdd7c55e..83953feedbf 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -39,6 +39,9 @@ export default { 'Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.', ), invalidFeedback: s__('Groups|Enter a descriptive name for your group.'), + warningForUsingDotInName: s__( + 'Groups|Your group name must not contain a period if you intend to use SCIM integration, as it can lead to errors.', + ), }, path: { placeholder: __('my-awesome-group'), @@ -299,6 +302,14 @@ export default { @invalid="handleInvalidName" /> </gl-form-group> + <gl-alert + class="gl-mb-5" + :dismissible="false" + variant="warning" + data-testid="dot-in-path-alert" + > + {{ $options.i18n.inputs.name.warningForUsingDotInName }} + </gl-alert> <div :class="newSubgroup && 'row gl-mb-3'"> <gl-form-group v-if="newSubgroup" class="col-sm-6 gl-pr-0" :label="inputLabels.subgroupPath"> @@ -386,7 +397,12 @@ export default { </div> <template v-if="isEditingGroup"> - <gl-alert class="gl-mb-5" :dismissible="false" variant="warning"> + <gl-alert + class="gl-mb-5" + :dismissible="false" + variant="warning" + data-testid="changing-url-alert" + > {{ $options.i18n.changingUrlWarningMessage }} <gl-link :href="$options.changingGroupPathHelpPagePath" >{{ $options.i18n.learnMore }} diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 8781f03a412..82bd4765986 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -1,5 +1,5 @@ <script> -import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@gitlab/ui'; +import { GlTabs, GlTab, GlSearchBoxByType, GlSorting } from '@gitlab/ui'; import { isString, debounce } from 'lodash'; import { __ } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -30,7 +30,6 @@ export default { GroupsApp, GlSearchBoxByType, GlSorting, - GlSortingItem, SubgroupsAndProjectsEmptyState, SharedProjectsEmptyState, ArchivedProjectsEmptyState, @@ -84,6 +83,9 @@ export default { sortQueryStringValue() { return this.isAscending ? this.sort.asc : this.sort.desc; }, + activeTabSortOptions() { + return this.activeTab.sortingItems.map(({ label }) => ({ value: label, text: label })); + }, }, mounted() { this.search = this.$route.query?.filter || ''; @@ -178,12 +180,14 @@ export default { this.handleSearchOrSortChange(); }, - handleSortingItemClick(sortingItem) { - if (sortingItem === this.sort) { + handleSortingItemClick(value) { + const selectedSortingItem = this.activeTab.sortingItems.find((item) => item.label === value); + + if (selectedSortingItem === this.sort) { return; } - this.sort = sortingItem; + this.sort = selectedSortingItem; this.handleSearchOrSortChange(); }, @@ -239,16 +243,11 @@ export default { data-testid="group_sort_by_dropdown" :text="sort.label" :is-ascending="isAscending" + :sort-options="activeTabSortOptions" + :sort-by="sort.label" + @sortByChange="handleSortingItemClick" @sortDirectionChange="handleSortDirectionChange" - > - <gl-sorting-item - v-for="sortingItem in activeTab.sortingItems" - :key="sortingItem.label" - :active="sortingItem === sort" - @click="handleSortingItemClick(sortingItem)" - >{{ sortingItem.label }}</gl-sorting-item - > - </gl-sorting> + /> </div> </div> </li> diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index 80dd1d36734..2f03705b453 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -47,6 +47,7 @@ export const initGroupOverviewTabs = () => { newProjectIllustration, emptyProjectsIllustration, emptySubgroupIllustration, + emptySearchIllustration, canCreateSubgroups, canCreateProjects, currentGroupVisibility, @@ -67,6 +68,7 @@ export const initGroupOverviewTabs = () => { newProjectIllustration, emptyProjectsIllustration, emptySubgroupIllustration, + emptySearchIllustration, canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), currentGroupVisibility, diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js index 5560d10d179..37f95a7ab30 100644 --- a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js +++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js @@ -7,10 +7,15 @@ const buildUrl = (urlRoot, url) => { return joinPaths(urlRoot, url); }; -export const getSubGroups = () => { +const defaultOptions = { includeParentDescendants: false }; + +export const getSubGroups = (options = defaultOptions) => { + const { includeParentDescendants } = options; + return axios.get(buildUrl(gon.relative_url_root || '', GROUP_SUBGROUPS_PATH), { params: { group_id: gon.current_group_id, + include_parent_descendants: includeParentDescendants, }, }); }; diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue index 457a2db174c..fee1383c8e2 100644 --- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue @@ -93,7 +93,11 @@ export default { this.loading = true; if (this.hasLicense) { - Promise.all([this.groups.length ? Promise.resolve({ data: this.groups }) : getSubGroups()]) + Promise.all([ + this.groups.length + ? Promise.resolve({ data: this.groups }) + : getSubGroups({ includeParentDescendants: true }), + ]) .then(([groupsResponse]) => { this.consolidateData(groupsResponse.data); this.setSelected({ initial }); diff --git a/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue b/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue index 76b0b819698..238e382fabe 100644 --- a/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue +++ b/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue @@ -2,6 +2,7 @@ import { GlButton, GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlDisclosureDropdown, GlIcon, GlTooltipDirective, @@ -13,6 +14,7 @@ export default { components: { GlButton, GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlDisclosureDropdown, GlIcon, }, @@ -32,6 +34,9 @@ export default { namespaceType() { return this.isGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT; }, + hasPath() { + return this.leavePath || this.withdrawPath || this.requestAccessPath; + }, leaveTitle() { return this.isGroup ? this.$options.i18n.groupLeaveTitle @@ -143,12 +148,12 @@ export default { </div> </template> - <gl-disclosure-dropdown-item v-if="leavePath" ref="leaveItem" :item="leaveItem" /> - - <gl-disclosure-dropdown-item v-else-if="withdrawPath" :item="withdrawItem" /> - - <gl-disclosure-dropdown-item v-else-if="requestAccessPath" :item="requestAccessItem" /> - <gl-disclosure-dropdown-item v-if="id" :item="copyIdItem" :data-clipboard-text="id" /> + + <gl-disclosure-dropdown-group v-if="hasPath" bordered> + <gl-disclosure-dropdown-item v-if="leavePath" ref="leaveItem" :item="leaveItem" /> + <gl-disclosure-dropdown-item v-else-if="withdrawPath" :item="withdrawItem" /> + <gl-disclosure-dropdown-item v-else-if="requestAccessPath" :item="requestAccessItem" /> + </gl-disclosure-dropdown-group> </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/ide/components/file_alert.vue b/app/assets/javascripts/ide/components/file_alert.vue deleted file mode 100644 index 2a894596bf4..00000000000 --- a/app/assets/javascripts/ide/components/file_alert.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { GlAlert } from '@gitlab/ui'; -import { getAlert } from '../lib/alerts'; - -export default { - components: { - GlAlert, - }, - props: { - alertKey: { - type: Symbol, - required: true, - }, - }, - computed: { - alert() { - return getAlert(this.alertKey); - }, - }, -}; -</script> -<template> - <gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)"> - <component :is="alert.message" /> - </gl-alert> -</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 8f4f777d396..d2375078820 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,5 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; -import { debounce } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { @@ -29,7 +28,7 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { markRaw } from '~/lib/utils/vue3compat/mark_raw'; import { readFileAsDataURL } from '~/lib/utils/file_utility'; -import { isDefaultCiConfig, hasCiConfigExtension } from '~/lib/utils/common_utils'; +import { hasCiConfigExtension } from '~/lib/utils/common_utils'; import { leftSidebarViews, @@ -43,7 +42,6 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; import { getPathParent, registerSchema, isTextFile } from '../utils'; -import FileAlert from './file_alert.vue'; import FileTemplatesBar from './file_templates/bar.vue'; const MARKDOWN_FILE_TYPE = 'markdown'; @@ -53,7 +51,6 @@ export default { components: { GlTabs, GlTab, - FileAlert, ContentViewer, DiffViewer, FileTemplatesBar, @@ -93,7 +90,6 @@ export default { 'previewMarkdownPath', ]), ...mapGetters([ - 'getAlert', 'currentMergeRequest', 'getStagedFile', 'isEditModeActive', @@ -102,9 +98,6 @@ export default { 'getJsonSchemaForPath', ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), - alertKey() { - return this.getAlert(this.file); - }, fileEditor() { return getFileEditorOrDefault(this.fileEditors, this.file.path); }, @@ -159,16 +152,6 @@ export default { }, }, watch: { - 'file.name': { - handler() { - this.stopWatchingCiYaml(); - - if (isDefaultCiConfig(this.file.name)) { - this.startWatchingCiYaml(); - } - }, - immediate: true, - }, file(newVal, oldVal) { if (oldVal.pending) { this.removePendingTab(oldVal); @@ -235,7 +218,6 @@ export default { 'removePendingTab', 'triggerFilesChange', 'addTempImage', - 'detectGitlabCiFileAlerts', ]), ...mapActions('editor', ['updateFileEditor']), initEditor() { @@ -498,18 +480,6 @@ export default { this.updateFileEditor({ path: this.file.path, data }); }, - startWatchingCiYaml() { - this.unwatchCiYaml = this.$watch( - 'file.content', - debounce(this.detectGitlabCiFileAlerts, 500), - ); - }, - stopWatchingCiYaml() { - if (this.unwatchCiYaml) { - this.unwatchCiYaml(); - this.unwatchCiYaml = null; - } - }, }, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -531,7 +501,6 @@ export default { @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" /> </gl-tabs> - <file-alert v-if="alertKey" :alert-key="alertKey" /> <file-templates-bar v-else-if="showFileTemplatesBar(file.name)" /> <div v-show="showEditor" diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index b09cd7f6643..51c7e69449b 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -70,7 +70,6 @@ export const initLegacyWebIDE = (el, options = {}) => { this.init({ renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME, - environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, userPreferencesPath: el.dataset.userPreferencesPath, }); diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue deleted file mode 100644 index bfe101bc7e7..00000000000 --- a/app/assets/javascripts/ide/lib/alerts/environments.vue +++ /dev/null @@ -1,33 +0,0 @@ -<!-- eslint-disable vue/multi-word-component-names --> -<script> -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { __ } from '~/locale'; - -export default { - components: { GlSprintf, GlLink }, - message: __( - "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}", - ), - computed: { - helpLink() { - return helpPagePath('ci/environments/index.md'); - }, - }, -}; -</script> -<template> - <span> - <gl-sprintf :message="$options.message"> - <template #link="{ content }"> - <gl-link - :href="helpLink" - target="_blank" - data-track-action="click_link" - data-track-experiment="in_product_guidance_environments_webide" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </span> -</template> diff --git a/app/assets/javascripts/ide/lib/alerts/index.js b/app/assets/javascripts/ide/lib/alerts/index.js deleted file mode 100644 index ac4eeb0386f..00000000000 --- a/app/assets/javascripts/ide/lib/alerts/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import { isDefaultCiConfig } from '~/lib/utils/common_utils'; -import { leftSidebarViews } from '../../constants'; -import EnvironmentsMessage from './environments.vue'; - -const alerts = [ - { - key: Symbol('ALERT_ENVIRONMENT'), - show: (state, file) => - state.currentActivityView === leftSidebarViews.commit.name && - isDefaultCiConfig(file.path) && - state.environmentsGuidanceAlertDetected && - !state.environmentsGuidanceAlertDismissed, - props: { variant: 'tip' }, - dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'), - message: EnvironmentsMessage, - }, -]; - -export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key; - -export const getAlert = (key) => alerts.find((x) => x.key === key); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 1f9bc834140..01e1fd50bcc 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,11 +1,9 @@ import Api from '~/api'; import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; -import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql'; -import { query, mutate } from './gql'; +import { query } from './gql'; export default { getFileData(endpoint) { @@ -84,18 +82,6 @@ export default { const url = `${gon.relative_url_root}/${projectPath}/service_ping/web_ide_pipelines_count`; return axios.post(url); }, - getCiConfig(projectPath, content) { - return query({ - query: ciConfig, - variables: { projectPath, content }, - }).then(({ data }) => data.ciConfig); - }, - dismissUserCallout(name) { - return mutate({ - mutation: dismissUserCallout, - variables: { input: { featureName: name } }, - }).then(({ data }) => data); - }, getProjectPermissionsData(projectPath) { return query({ query: getIdeProject, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 0106eeae162..bb4b181c56d 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -311,4 +311,3 @@ export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; export * from './actions/merge_request'; -export * from './actions/alert'; diff --git a/app/assets/javascripts/ide/stores/actions/alert.js b/app/assets/javascripts/ide/stores/actions/alert.js deleted file mode 100644 index 4c33dc19520..00000000000 --- a/app/assets/javascripts/ide/stores/actions/alert.js +++ /dev/null @@ -1,18 +0,0 @@ -import service from '../../services'; -import { - DETECT_ENVIRONMENTS_GUIDANCE_ALERT, - DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, -} from '../mutation_types'; - -export const detectGitlabCiFileAlerts = ({ dispatch }, content) => - dispatch('detectEnvironmentsGuidance', content); - -export const detectEnvironmentsGuidance = ({ commit, state }, content) => - service.getCiConfig(state.currentProjectId, content).then((data) => { - commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages); - }); - -export const dismissEnvironmentsGuidance = ({ commit }) => - service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => { - commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT); - }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 74fe61b6e2f..0f4dbb56e04 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -260,5 +260,3 @@ export const getJsonSchemaForPath = (state, getters) => (path) => { fileMatch: [`*${path}`], }; }; - -export * from './getters/alert'; diff --git a/app/assets/javascripts/ide/stores/getters/alert.js b/app/assets/javascripts/ide/stores/getters/alert.js deleted file mode 100644 index 714e2d89b4f..00000000000 --- a/app/assets/javascripts/ide/stores/getters/alert.js +++ /dev/null @@ -1,3 +0,0 @@ -import { findAlertKeyToShow } from '../../lib/alerts'; - -export const getAlert = (state) => (file) => findAlertKeyToShow(state, file); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 13f338c4a48..ae6588f948f 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -71,8 +71,3 @@ export const RENAME_ENTRY = 'RENAME_ENTRY'; export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY'; export const RESTORE_TREE = 'RESTORE_TREE'; - -// Alert mutation types - -export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT'; -export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index d11fc388d5e..300d352f81c 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import * as types from './mutation_types'; -import alertMutations from './mutations/alert'; import branchMutations from './mutations/branch'; import fileMutations from './mutations/file'; import mergeRequestMutation from './mutations/merge_request'; @@ -247,5 +246,4 @@ export default { ...fileMutations, ...treeMutations, ...branchMutations, - ...alertMutations, }; diff --git a/app/assets/javascripts/ide/stores/mutations/alert.js b/app/assets/javascripts/ide/stores/mutations/alert.js deleted file mode 100644 index bb2d33a836b..00000000000 --- a/app/assets/javascripts/ide/stores/mutations/alert.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - DETECT_ENVIRONMENTS_GUIDANCE_ALERT, - DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, -} from '../mutation_types'; - -export default { - [DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) { - if (!stages) { - return; - } - const hasEnvironments = stages?.nodes?.some((stage) => - stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)), - ); - const hasParsedCi = Array.isArray(stages.nodes); - - state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi; - }, - [DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) { - state.environmentsGuidanceAlertDismissed = true; - }, -}; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 356bbf28a48..6297231e252 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -28,8 +28,6 @@ export default () => ({ }, renderWhitespaceInCode: false, editorTheme: DEFAULT_THEME, - environmentsGuidanceAlertDismissed: false, - environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', userPreferencesPath: '', }); diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_status.vue b/app/assets/javascripts/import_entities/import_groups/components/import_status.vue index cdb38cdf7f1..e2c5a4aadb8 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_status.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_status.vue @@ -31,11 +31,6 @@ export default { required: false, default: false, }, - showDetailsLink: { - type: Boolean, - required: false, - default: false, - }, status: { type: String, required: true, @@ -56,7 +51,7 @@ export default { }, showDetails() { - return this.showDetailsLink && Boolean(this.detailsPathWithId) && this.hasFailures; + return Boolean(this.detailsPathWithId) && this.hasFailures; }, detailsPathWithId() { diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index db354a01899..25d47f3ced3 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -18,7 +18,6 @@ import { createAlert } from '~/alert'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__, __, n__, sprintf } from '~/locale'; import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { getGroupPathAvailability } from '~/rest_api'; @@ -69,7 +68,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], props: { sourceUrl: { type: String, 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 dead90eeb71..4f6ca5c5638 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -7,11 +7,7 @@ import Api from '~/api'; import Tracking from '~/tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { n__, s__, sprintf } from '~/locale'; -import { - memberName, - triggerExternalAlert, - inviteMembersTrackingOptions, -} from 'ee_else_ce/invite_members/utils/member_utils'; +import { memberName, triggerExternalAlert } from 'ee_else_ce/invite_members/utils/member_utils'; import { captureException } from '~/ci/runner/sentry_utils'; import { USERS_FILTER_ALL, @@ -142,9 +138,6 @@ export default { isCelebration() { return this.mode === 'celebrate'; }, - baseTrackingDetails() { - return { label: this.source, celebrate: this.isCelebration }; - }, isTextForAdmin() { return this.isCurrentUserAdmin && Boolean(this.newUsersUrl); }, @@ -265,7 +258,7 @@ export default { this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); - this.track('render', inviteMembersTrackingOptions(this.baseTrackingDetails)); + this.track('render', { label: this.source }); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); @@ -335,10 +328,10 @@ export default { return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; }, onCancel() { - this.track('click_cancel', inviteMembersTrackingOptions(this.baseTrackingDetails)); + this.track('click_cancel', { label: this.source }); }, onClose() { - this.track('click_x', inviteMembersTrackingOptions(this.baseTrackingDetails)); + this.track('click_x', { label: this.source }); }, resetFields() { this.clearValidation(); @@ -347,7 +340,7 @@ export default { this.newUsersToInvite = []; }, onInviteSuccess() { - this.track('invite_successful', inviteMembersTrackingOptions(this.baseTrackingDetails)); + this.track('invite_successful', { label: this.source }); if (this.reloadPageOnSubmit) { reloadOnInvitationSuccess(); diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index 7f76b7ca1ac..5d9663abaf2 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -94,7 +94,6 @@ export default { <gl-dropdown-item v-else-if="isDropdownWithEmojiTrigger" v-bind="componentAttributes" - button-class="top-nav-menu-item" @click="openModal" > {{ displayText }} 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 00b7c3f4bdd..574bbacc498 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -215,6 +215,7 @@ export default { this.$emit('reset'); }, onShowModal() { + this.$emit('shown'); if (this.usersLimitDataset.reachedLimit) { this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL }); } diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js index 52fb5e98f27..7998cb69445 100644 --- a/app/assets/javascripts/invite_members/utils/member_utils.js +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -6,7 +6,3 @@ export function memberName(member) { export function triggerExternalAlert() { return false; } - -export function inviteMembersTrackingOptions(options) { - return { label: options.label }; -} 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 adc789a205b..8ac7990c28d 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -915,7 +915,7 @@ export default { v-if="issuesDrawerEnabled" :open="isIssuableSelected" header-height="calc(var(--top-bar-height) + var(--performance-bar-height))" - class="gl-w-full gl-sm-w-40p" + class="gl-w-full gl-sm-w-40p gl-reset-line-height" @close="activeIssuable = null" > <template #title> @@ -927,6 +927,7 @@ export default { <work-item-detail :key="activeIssuable.iid" :work-item-iid="activeIssuable.iid" + class="gl-pt-0!" @work-item-updated="updateIssuablesCache" @work-item-emoji-updated="updateIssuableEmojis" @addChild="refetchIssuables" diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 5a836e3e40a..ab6ff825554 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -137,6 +137,7 @@ export async function mountIssuesListApp() { hasOkrsFeature: parseBoolean(hasOkrsFeature), initialSort, isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), + isGroup: !parseBoolean(isProject), isProject: parseBoolean(isProject), isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), isSignedIn: parseBoolean(isSignedIn), diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index dcdfd06fbf1..fb7058d95dc 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -14,6 +14,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import { ISSUABLE_EDIT_DESCRIPTION } from '~/behaviors/shortcuts/keybindings'; import { STATUS_CLOSED, TYPE_ISSUE, issuableTypeText } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; @@ -196,6 +197,12 @@ export default { href: this.submitAsSpamPath, }; }, + editShortcutKey() { + return ISSUABLE_EDIT_DESCRIPTION.defaultKeys[0]; + }, + editTooltip() { + return `${this.$options.i18n.editTitleAndDescription} <kbd class="glat gl-ml-1" aria-hidden=true>${this.editShortcutKey}</kbd>`; + }, }, created() { eventHub.$on('toggle.issuable.state', this.toggleIssueState); @@ -395,9 +402,10 @@ export default { <gl-button v-if="canUpdateIssue" - v-gl-tooltip.bottom - :title="$options.i18n.editTitleAndDescription" + v-gl-tooltip.viewport.html + :title="editTooltip" :aria-label="$options.i18n.editTitleAndDescription" + :aria-keyshortcuts="editShortcutKey" class="js-issuable-edit gl-display-none! gl-md-display-block!" data-testid="edit-button" @click="edit" diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue index 52a12cc7771..e3fa0ce8073 100644 --- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -1,20 +1,20 @@ <script> import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; +import produce from 'immer'; import { __ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { PROJECTS_PER_PAGE } from '../constants'; import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; export default { - PROJECTS_PER_PAGE, - projectQueryPageInfo: { - endCursor: '', - }, + name: 'ProjectDropdown', + components: { GlAvatarLabeled, GlCollapsibleListbox, }, + props: { selectedProject: { type: Object, @@ -22,27 +22,29 @@ export default { default: null, }, }, + data() { return { initialProjectsLoading: true, + isLoadingMore: false, projectSearchQuery: '', selectedProjectId: this.selectedProject?.id, }; }, + apollo: { projects: { query: getProjectsQuery, variables() { return { - search: this.projectSearchQuery, - first: this.$options.PROJECTS_PER_PAGE, - after: this.$options.projectQueryPageInfo.endCursor, - searchNamespaces: true, - sort: 'similarity', + ...this.queryVariables, }; }, update(data) { - return data?.projects?.nodes.filter((project) => !project.repository?.empty) ?? []; + return { + nodes: data?.projects?.nodes.filter((project) => !project.repository?.empty) ?? [], + pageInfo: data?.projects?.pageInfo, + }; }, result() { this.initialProjectsLoading = false; @@ -52,24 +54,37 @@ export default { }, }, }, + computed: { - projectsLoading() { - return Boolean(this.$apollo.queries.projects.loading); + queryVariables() { + return { + search: this.projectSearchQuery, + first: PROJECTS_PER_PAGE, + searchNamespaces: true, + sort: 'similarity', + }; + }, + isLoading() { + return this.$apollo.queries.projects.loading && !this.isLoadingMore; }, projectDropdownText() { return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText; }, projectList() { - return (this.projects || []).map((project) => ({ + return (this.projects?.nodes || []).map((project) => ({ ...project, text: project.nameWithNamespace, value: String(project.id), })); }, + hasNextPage() { + return this.projects?.pageInfo?.hasNextPage; + }, }, + methods: { findProjectById(id) { - return this.projects.find((project) => id === project.id); + return this.projects?.nodes?.find((project) => id === project.id); }, onProjectSelect(projectId) { this.$emit('change', this.findProjectById(projectId)); @@ -77,13 +92,41 @@ export default { onError({ message } = {}) { this.$emit('error', { message }); }, + async onBottomReached() { + if (!this.hasNextPage) return; + + this.isLoadingMore = true; + + try { + await this.$apollo.queries.projects.fetchMore({ + variables: { + ...this.queryVariables, + after: this.projects.pageInfo?.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + return produce(fetchMoreResult, (draftData) => { + draftData.projects.nodes = [ + ...previousResult.projects.nodes, + ...draftData.projects.nodes, + ]; + }); + }, + }); + } catch (error) { + this.onError({ message: __('Failed to load projects') }); + } finally { + this.isLoadingMore = false; + } + }, onSearch: debounce(function debouncedSearch(query) { this.projectSearchQuery = query; }, 250), }, + i18n: { selectProjectText: __('Select a project'), }, + AVATAR_SHAPE_OPTION_RECT, }; </script> @@ -97,8 +140,11 @@ export default { :header-text="$options.i18n.selectProjectText" :loading="initialProjectsLoading" :searchable="true" - :searching="projectsLoading" + :searching="isLoading" fluid-width + infinite-scroll + :infinite-scroll-loading="isLoadingMore" + @bottom-reached="onBottomReached" @search="onSearch" @select="onProjectSelect" > diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue index 0d219f915c9..bcc0ddf824a 100644 --- a/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue @@ -14,8 +14,7 @@ export default { item: { type: Object, required: true, - validator: (item) => - ['name', 'kind', 'labels', 'annotations', 'status'].every((key) => item[key]), + validator: (item) => ['name', 'kind', 'labels', 'annotations'].every((key) => item[key]), }, }, computed: { @@ -51,7 +50,7 @@ export default { <template> <ul class="gl-list-style-none"> <workload-details-item :label="$options.i18n.name"> - {{ item.name }} + <span class="gl-word-break-word"> {{ item.name }}</span> </workload-details-item> <workload-details-item :label="$options.i18n.kind"> {{ item.kind }} @@ -63,7 +62,7 @@ export default { </gl-badge> </div> </workload-details-item> - <workload-details-item :label="$options.i18n.status"> + <workload-details-item v-if="item.status" :label="$options.i18n.status"> <gl-badge :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[item.status]">{{ item.status }}</gl-badge></workload-details-item diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue index 8c6a08ad504..6579e0229e6 100644 --- a/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlAlert, GlDrawer } from '@gitlab/ui'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import WorkloadStats from './workload_stats.vue'; import WorkloadTable from './workload_table.vue'; import WorkloadDetails from './workload_details.vue'; @@ -33,6 +34,11 @@ export default { type: Array, required: true, }, + fields: { + type: Array, + required: false, + default: undefined, + }, }, data() { return { @@ -40,6 +46,11 @@ export default { selectedItem: {}, }; }, + computed: { + getDrawerHeaderHeight() { + return getContentWrapperHeight(); + }, + }, methods: { closeDetailsDrawer() { this.showDetailsDrawer = false; @@ -59,16 +70,18 @@ export default { </gl-alert> <div v-else> <workload-stats :stats="stats" /> - <workload-table :items="items" @select-item="onItemSelect" /> + <workload-table :items="items" :fields="fields" @select-item="onItemSelect" /> <gl-drawer :open="showDetailsDrawer" - header-height="calc(var(--top-bar-height) + var(--performance-bar-height))" + :header-height="getDrawerHeaderHeight" :z-index="$options.DRAWER_Z_INDEX" @close="closeDetailsDrawer" > <template #title> - <h4 class="gl-font-weight-bold gl-font-size-h2 gl-m-0">{{ selectedItem.name }}</h4> + <h4 class="gl-font-weight-bold gl-font-size-h2 gl-m-0 gl-word-break-word"> + {{ selectedItem.name }} + </h4> </template> <template #default> <workload-details :item="selectedItem" /> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue index d3704863538..83940fb91c8 100644 --- a/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue @@ -1,9 +1,9 @@ <script> import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; +import { __ } from '~/locale'; import { WORKLOAD_STATUS_BADGE_VARIANTS, PAGE_SIZE, - TABLE_HEADING_CLASSES, DEFAULT_WORKLOAD_TABLE_FIELDS, } from '../constants'; @@ -34,7 +34,6 @@ export default { return this.fields.map((field) => { return { ...field, - thClass: TABLE_HEADING_CLASSES, sortable: true, }; }); @@ -45,6 +44,9 @@ export default { this.$emit('select-item', item); }, }, + i18n: { + emptyText: __('No results found'), + }, PAGE_SIZE, WORKLOAD_STATUS_BADGE_VARIANTS, TABLE_CELL_CLASSES: 'gl-p-2', @@ -58,9 +60,10 @@ export default { :fields="tableFields" :per-page="$options.PAGE_SIZE" :current-page="currentPage" + :empty-text="$options.i18n.emptyText" tbody-tr-class="gl-hover-cursor-pointer" + show-empty stacked="md" - bordered hover @row-clicked="selectItem" > diff --git a/app/assets/javascripts/kubernetes_dashboard/constants.js b/app/assets/javascripts/kubernetes_dashboard/constants.js index b93740aec90..458a79cbcb6 100644 --- a/app/assets/javascripts/kubernetes_dashboard/constants.js +++ b/app/assets/javascripts/kubernetes_dashboard/constants.js @@ -1,10 +1,12 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const STATUS_RUNNING = 'Running'; export const STATUS_PENDING = 'Pending'; export const STATUS_SUCCEEDED = 'Succeeded'; export const STATUS_FAILED = 'Failed'; export const STATUS_READY = 'Ready'; +export const STATUS_COMPLETED = 'Completed'; +export const STATUS_SUSPENDED = 'Suspended'; export const STATUS_LABELS = { [STATUS_RUNNING]: s__('KubernetesDashboard|Running'), @@ -12,6 +14,8 @@ export const STATUS_LABELS = { [STATUS_SUCCEEDED]: s__('KubernetesDashboard|Succeeded'), [STATUS_FAILED]: s__('KubernetesDashboard|Failed'), [STATUS_READY]: s__('KubernetesDashboard|Ready'), + [STATUS_COMPLETED]: s__('KubernetesDashboard|Completed'), + [STATUS_SUSPENDED]: s__('KubernetesDashboard|Suspended'), }; export const WORKLOAD_STATUS_BADGE_VARIANTS = { @@ -20,24 +24,27 @@ export const WORKLOAD_STATUS_BADGE_VARIANTS = { [STATUS_SUCCEEDED]: 'success', [STATUS_FAILED]: 'danger', [STATUS_READY]: 'success', + [STATUS_COMPLETED]: 'success', + [STATUS_SUSPENDED]: 'neutral', }; export const PAGE_SIZE = 20; -export const TABLE_HEADING_CLASSES = 'gl-bg-gray-50! gl-font-weight-bold gl-white-space-nowrap'; - export const DEFAULT_WORKLOAD_TABLE_FIELDS = [ { key: 'name', label: s__('KubernetesDashboard|Name'), + tdClass: 'gl-md-w-half gl-lg-w-40p gl-word-break-word', }, { key: 'status', label: s__('KubernetesDashboard|Status'), + tdClass: 'gl-md-w-15', }, { key: 'namespace', label: s__('KubernetesDashboard|Namespace'), + tdClass: 'gl-md-w-30p gl-lg-w-40p gl-word-break-word', }, { key: 'age', @@ -47,3 +54,34 @@ export const DEFAULT_WORKLOAD_TABLE_FIELDS = [ export const STATUS_TRUE = 'True'; export const STATUS_FALSE = 'False'; + +export const SERVICES_TABLE_FIELDS = [ + { + key: 'name', + label: __('Name'), + }, + { + key: 'namespace', + label: __('Namespace'), + }, + { + key: 'type', + label: __('Type'), + }, + { + key: 'clusterIP', + label: s__('Environment|Cluster IP'), + }, + { + key: 'externalIP', + label: s__('Environment|External IP'), + }, + { + key: 'ports', + label: s__('Environment|Ports'), + }, + { + key: 'age', + label: s__('Environment|Age'), + }, +]; diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js index 5894472d83b..9454465df9d 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js @@ -6,6 +6,9 @@ import k8sDeploymentsQuery from './queries/k8s_dashboard_deployments.query.graph import k8sStatefulSetsQuery from './queries/k8s_dashboard_stateful_sets.query.graphql'; import k8sReplicaSetsQuery from './queries/k8s_dashboard_replica_sets.query.graphql'; import k8sDaemonSetsQuery from './queries/k8s_dashboard_daemon_sets.query.graphql'; +import k8sJobsQuery from './queries/k8s_dashboard_jobs.query.graphql'; +import k8sCronJobsQuery from './queries/k8s_dashboard_cron_jobs.query.graphql'; +import k8sServicesQuery from './queries/k8s_dashboard_services.query.graphql'; import { resolvers } from './resolvers'; export const apolloProvider = () => { @@ -14,16 +17,18 @@ export const apolloProvider = () => { }); const { cache } = defaultClient; + const metadata = { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }; + cache.writeQuery({ query: k8sPodsQuery, data: { - metadata: { - name: null, - namespace: null, - creationTimestamp: null, - labels: null, - annotations: null, - }, + metadata, status: { phase: null, }, @@ -33,13 +38,7 @@ export const apolloProvider = () => { cache.writeQuery({ query: k8sDeploymentsQuery, data: { - metadata: { - name: null, - namespace: null, - creationTimestamp: null, - labels: null, - annotations: null, - }, + metadata, status: { conditions: null, }, @@ -49,13 +48,7 @@ export const apolloProvider = () => { cache.writeQuery({ query: k8sStatefulSetsQuery, data: { - metadata: { - name: null, - namespace: null, - creationTimestamp: null, - labels: null, - annotations: null, - }, + metadata, status: { readyReplicas: null, }, @@ -68,13 +61,7 @@ export const apolloProvider = () => { cache.writeQuery({ query: k8sReplicaSetsQuery, data: { - metadata: { - name: null, - namespace: null, - creationTimestamp: null, - labels: null, - annotations: null, - }, + metadata, status: { readyReplicas: null, }, @@ -87,13 +74,7 @@ export const apolloProvider = () => { cache.writeQuery({ query: k8sDaemonSetsQuery, data: { - metadata: { - name: null, - namespace: null, - creationTimestamp: null, - labels: null, - annotations: null, - }, + metadata, status: { numberMisscheduled: null, numberReady: null, @@ -102,6 +83,47 @@ export const apolloProvider = () => { }, }); + cache.writeQuery({ + query: k8sJobsQuery, + data: { + metadata, + status: { + failed: null, + succeeded: null, + }, + spec: { + completions: null, + }, + }, + }); + + cache.writeQuery({ + query: k8sCronJobsQuery, + data: { + metadata, + status: { + active: null, + lastScheduleTime: null, + }, + spec: { + suspend: null, + }, + }, + }); + + cache.writeQuery({ + query: k8sServicesQuery, + data: { + metadata, + spec: { + type: null, + clusterIP: null, + externalIP: null, + ports: null, + }, + }, + }); + return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js b/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js index 47c2f543357..b9c195d83d0 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js @@ -43,6 +43,62 @@ export const mapSetItem = (item) => { return { status, metadata, spec }; }; +export const mapJobItem = (item) => { + const metadata = { + ...item.metadata, + annotations: item.metadata?.annotations || {}, + labels: item.metadata?.labels || {}, + }; + + const status = { + failed: item.status?.failed || 0, + succeeded: item.status?.succeeded || 0, + }; + + return { + status, + metadata, + spec: item.spec, + }; +}; + +export const mapServicesItems = (item) => { + const { type, clusterIP, externalIP, ports } = item.spec; + + return { + metadata: { + ...item.metadata, + annotations: item.metadata?.annotations || {}, + labels: item.metadata?.labels || {}, + }, + spec: { + type, + clusterIP: clusterIP || '-', + externalIP: externalIP || '-', + ports, + }, + }; +}; + +export const mapCronJobItem = (item) => { + const metadata = { + ...item.metadata, + annotations: item.metadata?.annotations || {}, + labels: item.metadata?.labels || {}, + }; + + const status = { + active: item.status?.active || 0, + lastScheduleTime: item.status?.lastScheduleTime || null, + }; + + return { + status, + metadata, + spec: item.spec, + }; +}; + export const watchWorkloadItems = ({ client, query, diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql new file mode 100644 index 00000000000..fe20cd2e70e --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql @@ -0,0 +1,18 @@ +query getK8sDashboardCronJobs($configuration: LocalConfiguration) { + k8sCronJobs(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + active + lastScheduleTime + } + spec { + suspend + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql new file mode 100644 index 00000000000..86afb47f2f9 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql @@ -0,0 +1,18 @@ +query getK8sDashboardJobs($configuration: LocalConfiguration) { + k8sJobs(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + failed + succeeded + } + spec { + completions + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql new file mode 100644 index 00000000000..7d42d66183e --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql @@ -0,0 +1,17 @@ +query getK8sDashboardServices($configuration: LocalConfiguration) { + k8sServices(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + spec { + type + clusterIP + externalIP + ports + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js index e59bed5581b..75285ad2cca 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js @@ -1,4 +1,4 @@ -import { Configuration, AppsV1Api } from '@gitlab/cluster-client'; +import { Configuration, CoreV1Api, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; import { getK8sPods, @@ -7,12 +7,18 @@ import { mapSetItem, buildWatchPath, watchWorkloadItems, + mapJobItem, + mapCronJobItem, + mapServicesItems, } from '../helpers/resolver_helpers'; import k8sDashboardPodsQuery from '../queries/k8s_dashboard_pods.query.graphql'; import k8sDashboardDeploymentsQuery from '../queries/k8s_dashboard_deployments.query.graphql'; import k8sDashboardStatefulSetsQuery from '../queries/k8s_dashboard_stateful_sets.query.graphql'; import k8sDashboardReplicaSetsQuery from '../queries/k8s_dashboard_replica_sets.query.graphql'; import k8sDaemonSetsQuery from '../queries/k8s_dashboard_daemon_sets.query.graphql'; +import k8sJobsQuery from '../queries/k8s_dashboard_jobs.query.graphql'; +import k8sCronJobsQuery from '../queries/k8s_dashboard_cron_jobs.query.graphql'; +import k8sServicesQuery from '../queries/k8s_dashboard_services.query.graphql'; export default { k8sPods(_, { configuration }, { client }) { @@ -61,10 +67,10 @@ export default { const config = new Configuration(configuration); const appsV1api = new AppsV1Api(config); - const deploymentsApi = namespace + const statefulSetsApi = namespace ? appsV1api.listAppsV1NamespacedStatefulSet({ namespace }) : appsV1api.listAppsV1StatefulSetForAllNamespaces(); - return deploymentsApi + return statefulSetsApi .then((res) => { const watchPath = buildWatchPath({ resource: 'statefulsets', @@ -98,10 +104,10 @@ export default { const config = new Configuration(configuration); const appsV1api = new AppsV1Api(config); - const deploymentsApi = namespace + const replicaSetsApi = namespace ? appsV1api.listAppsV1NamespacedReplicaSet({ namespace }) : appsV1api.listAppsV1ReplicaSetForAllNamespaces(); - return deploymentsApi + return replicaSetsApi .then((res) => { const watchPath = buildWatchPath({ resource: 'replicasets', @@ -135,10 +141,10 @@ export default { const config = new Configuration(configuration); const appsV1api = new AppsV1Api(config); - const deploymentsApi = namespace + const daemonSetsApi = namespace ? appsV1api.listAppsV1NamespacedDaemonSet({ namespace }) : appsV1api.listAppsV1DaemonSetForAllNamespaces(); - return deploymentsApi + return daemonSetsApi .then((res) => { const watchPath = buildWatchPath({ resource: 'daemonsets', @@ -166,4 +172,114 @@ export default { } }); }, + + k8sJobs(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const batchV1api = new BatchV1Api(config); + const jobsApi = namespace + ? batchV1api.listBatchV1NamespacedJob({ namespace }) + : batchV1api.listBatchV1JobForAllNamespaces(); + return jobsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'jobs', + api: 'apis/batch/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sJobsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sJobs', + mapFn: mapJobItem, + }); + + const data = res?.items || []; + + return data.map(mapJobItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, + + k8sCronJobs(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const batchV1api = new BatchV1Api(config); + const cronJobsApi = namespace + ? batchV1api.listBatchV1NamespacedCronJob({ namespace }) + : batchV1api.listBatchV1CronJobForAllNamespaces(); + return cronJobsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'cronjobs', + api: 'apis/batch/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sCronJobsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sCronJobs', + mapFn: mapCronJobItem, + }); + + const data = res?.items || []; + + return data.map(mapCronJobItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, + + k8sServices(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const coreV1Api = new CoreV1Api(config); + const servicesApi = namespace + ? coreV1Api.listCoreV1NamespacedService({ namespace }) + : coreV1Api.listCoreV1ServiceForAllNamespaces(); + return servicesApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'services', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sServicesQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sServices', + mapFn: mapServicesItems, + }); + + const data = res?.items || []; + + return data.map(mapServicesItems); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, }; diff --git a/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js index 24f43e21506..d3116fd611a 100644 --- a/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js @@ -5,6 +5,8 @@ import { STATUS_PENDING, STATUS_READY, STATUS_FAILED, + STATUS_COMPLETED, + STATUS_SUSPENDED, } from '../constants'; export function getAge(creationTimestamp) { @@ -58,3 +60,31 @@ export function calculateDaemonSetStatus(item) { } return STATUS_FAILED; } + +export function calculateJobStatus(item) { + if (item.status.failed > 0 || item.status?.succeeded !== item.spec?.completions) { + return STATUS_FAILED; + } + return STATUS_COMPLETED; +} + +export function calculateCronJobStatus(item) { + if (item.status?.active > 0 && !item.status?.lastScheduleTime) { + return STATUS_FAILED; + } + if (item.spec?.suspend) { + return STATUS_SUSPENDED; + } + return STATUS_READY; +} + +export function generateServicePortsString(ports) { + if (!ports?.length) return ''; + + return ports + .map((port) => { + const nodePort = port.nodePort ? `:${port.nodePort}` : ''; + return `${port.port}${nodePort}/${port.protocol}`; + }) + .join(', '); +} diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue new file mode 100644 index 00000000000..2d57bfdc9fc --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue @@ -0,0 +1,84 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateCronJobStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sCronJobsQuery from '../graphql/queries/k8s_dashboard_cron_jobs.query.graphql'; +import { STATUS_FAILED, STATUS_READY, STATUS_SUSPENDED, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sCronJobs: { + query: k8sCronJobsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sCronJobs?.map((job) => { + return { + name: job.metadata?.name, + namespace: job.metadata?.namespace, + status: calculateCronJobStatus(job), + age: getAge(job.metadata?.creationTimestamp), + labels: job.metadata?.labels, + annotations: job.metadata?.annotations, + kind: s__('KubernetesDashboard|CronJob'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sCronJobs: [], + errorMessage: '', + }; + }, + computed: { + cronJobsStats() { + return [ + { + value: this.countJobsByStatus(STATUS_READY), + title: STATUS_LABELS[STATUS_READY], + }, + { + value: this.countJobsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + { + value: this.countJobsByStatus(STATUS_SUSPENDED), + title: STATUS_LABELS[STATUS_SUSPENDED], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sCronJobs.loading; + }, + }, + methods: { + countJobsByStatus(phase) { + const filteredJobs = this.k8sCronJobs.filter((item) => item.status === phase) || []; + + return filteredJobs.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="cronJobsStats" + :items="k8sCronJobs" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue new file mode 100644 index 00000000000..f9dbb53e8b4 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue @@ -0,0 +1,80 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateJobStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sJobsQuery from '../graphql/queries/k8s_dashboard_jobs.query.graphql'; +import { STATUS_FAILED, STATUS_COMPLETED, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sJobs: { + query: k8sJobsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sJobs?.map((job) => { + return { + name: job.metadata?.name, + namespace: job.metadata?.namespace, + status: calculateJobStatus(job), + age: getAge(job.metadata?.creationTimestamp), + labels: job.metadata?.labels, + annotations: job.metadata?.annotations, + kind: s__('KubernetesDashboard|Job'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sJobs: [], + errorMessage: '', + }; + }, + computed: { + jobsStats() { + return [ + { + value: this.countJobsByStatus(STATUS_COMPLETED), + title: STATUS_LABELS[STATUS_COMPLETED], + }, + { + value: this.countJobsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sJobs.loading; + }, + }, + methods: { + countJobsByStatus(phase) { + const filteredJobs = this.k8sJobs.filter((item) => item.status === phase) || []; + + return filteredJobs.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="jobsStats" + :items="k8sJobs" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue new file mode 100644 index 00000000000..4dc8fb6b6c0 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue @@ -0,0 +1,69 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, generateServicePortsString } from '../helpers/k8s_integration_helper'; +import { SERVICES_TABLE_FIELDS } from '../constants'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sServicesQuery from '../graphql/queries/k8s_dashboard_services.query.graphql'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sServices: { + query: k8sServicesQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sServices?.map((service) => { + return { + name: service.metadata?.name, + namespace: service.metadata?.namespace, + type: service.spec?.type, + clusterIP: service.spec?.clusterIP, + externalIP: service.spec?.externalIP, + ports: generateServicePortsString(service?.spec?.ports), + age: getAge(service.metadata?.creationTimestamp), + labels: service.metadata?.labels, + annotations: service.metadata?.annotations, + kind: s__('KubernetesDashboard|Service'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sServices: [], + errorMessage: '', + }; + }, + computed: { + loading() { + return this.$apollo.queries.k8sServices.loading; + }, + servicesStats() { + return []; + }, + }, + SERVICES_TABLE_FIELDS, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="servicesStats" + :items="k8sServices" + :fields="$options.SERVICES_TABLE_FIELDS" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/router/constants.js b/app/assets/javascripts/kubernetes_dashboard/router/constants.js index 700f501ade4..f02c01d7973 100644 --- a/app/assets/javascripts/kubernetes_dashboard/router/constants.js +++ b/app/assets/javascripts/kubernetes_dashboard/router/constants.js @@ -3,9 +3,15 @@ export const DEPLOYMENTS_ROUTE_NAME = 'deployments'; export const STATEFUL_SETS_ROUTE_NAME = 'statefulSets'; export const REPLICA_SETS_ROUTE_NAME = 'replicaSets'; export const DAEMON_SETS_ROUTE_NAME = 'daemonSets'; +export const JOBS_ROUTE_NAME = 'jobs'; +export const CRON_JOBS_ROUTE_NAME = 'cronJobs'; +export const SERVICES_ROUTE_NAME = 'services'; export const PODS_ROUTE_PATH = '/pods'; export const DEPLOYMENTS_ROUTE_PATH = '/deployments'; export const STATEFUL_SETS_ROUTE_PATH = '/statefulsets'; export const REPLICA_SETS_ROUTE_PATH = '/replicasets'; export const DAEMON_SETS_ROUTE_PATH = '/daemonsets'; +export const JOBS_ROUTE_PATH = '/jobs'; +export const CRON_JOBS_ROUTE_PATH = '/cronjobs'; +export const SERVICES_ROUTE_PATH = '/services'; diff --git a/app/assets/javascripts/kubernetes_dashboard/router/routes.js b/app/assets/javascripts/kubernetes_dashboard/router/routes.js index a1684a62ca4..7448508de8a 100644 --- a/app/assets/javascripts/kubernetes_dashboard/router/routes.js +++ b/app/assets/javascripts/kubernetes_dashboard/router/routes.js @@ -4,6 +4,10 @@ import DeploymentsPage from '../pages/deployments_page.vue'; import StatefulSetsPage from '../pages/stateful_sets_page.vue'; import ReplicaSetsPage from '../pages/replica_sets_page.vue'; import DaemonSetsPage from '../pages/daemon_sets_page.vue'; +import JobsPage from '../pages/jobs_page.vue'; +import CronJobsPage from '../pages/cron_jobs_page.vue'; +import ServicesPage from '../pages/services_page.vue'; + import { PODS_ROUTE_NAME, PODS_ROUTE_PATH, @@ -15,6 +19,12 @@ import { REPLICA_SETS_ROUTE_PATH, DAEMON_SETS_ROUTE_NAME, DAEMON_SETS_ROUTE_PATH, + JOBS_ROUTE_NAME, + JOBS_ROUTE_PATH, + CRON_JOBS_ROUTE_NAME, + CRON_JOBS_ROUTE_PATH, + SERVICES_ROUTE_NAME, + SERVICES_ROUTE_PATH, } from './constants'; export default [ @@ -58,4 +68,28 @@ export default [ title: s__('KubernetesDashboard|DaemonSets'), }, }, + { + name: JOBS_ROUTE_NAME, + path: JOBS_ROUTE_PATH, + component: JobsPage, + meta: { + title: s__('KubernetesDashboard|Jobs'), + }, + }, + { + name: CRON_JOBS_ROUTE_NAME, + path: CRON_JOBS_ROUTE_PATH, + component: CronJobsPage, + meta: { + title: s__('KubernetesDashboard|CronJobs'), + }, + }, + { + name: SERVICES_ROUTE_NAME, + path: SERVICES_ROUTE_PATH, + component: ServicesPage, + meta: { + title: s__('KubernetesDashboard|Services'), + }, + }, ]; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 674a901aebc..7dc776a1446 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -634,7 +634,7 @@ export const roundDownFloat = (number, precision = 0) => { * Represents navigation type constants of the Performance Navigation API. * Detailed explanation see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation. */ -export const NavigationType = { +export const navigationType = { TYPE_NAVIGATE: 0, TYPE_RELOAD: 1, TYPE_BACK_FORWARD: 2, diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index d9ac0abf7b3..77986539403 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -19,10 +19,5 @@ export const DRAWER_Z_INDEX = 252; export const MIN_USERNAME_LENGTH = 2; -export const BYTES_FORMAT_BYTES = 'B'; -export const BYTES_FORMAT_KIB = 'KiB'; -export const BYTES_FORMAT_MIB = 'MiB'; -export const BYTES_FORMAT_GIB = 'GiB'; - export const DEFAULT_CI_CONFIG_PATH = '.gitlab-ci.yml'; export const CI_CONFIG_PATH_EXTENSION = /(\.gitlab-ci\.yml)/; diff --git a/app/assets/javascripts/lib/utils/headers.js b/app/assets/javascripts/lib/utils/headers.js index 80ae3fb146f..fb635a124d6 100644 --- a/app/assets/javascripts/lib/utils/headers.js +++ b/app/assets/javascripts/lib/utils/headers.js @@ -1,3 +1,3 @@ -export const ContentTypeMultipartFormData = { +export const contentTypeMultipartFormData = { 'Content-Type': 'multipart/form-data', }; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index d17719c0bc0..01c5bc1f1fc 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,12 +1,5 @@ import { sprintf, __ } from '~/locale'; -import { - BYTES_IN_KIB, - THOUSAND, - BYTES_FORMAT_BYTES, - BYTES_FORMAT_KIB, - BYTES_FORMAT_MIB, - BYTES_FORMAT_GIB, -} from './constants'; +import { BYTES_IN_KIB, THOUSAND } from './constants'; /** * Function that allows a number with an X amount of decimals @@ -73,47 +66,47 @@ export function bytesToGiB(number) { /** * Formats the bytes in number into a more understandable * representation. Returns an array with the first value being the human size - * and the second value being the format (e.g., [1.5, 'KiB']). + * and the second value being the label (e.g., [1.5, 'KiB']). * - * @param {Number} size - * @param {Number} digits - The number of digits to appear after the decimal point - * @returns {String} + * @param {number} size + * @param {number} [digits=2] - The number of digits to appear after the decimal point + * @returns {string[]} */ export function numberToHumanSizeSplit(size, digits = 2) { const abs = Math.abs(size); if (abs < BYTES_IN_KIB) { - return [size.toString(), BYTES_FORMAT_BYTES]; + return [size.toString(), __('B')]; } if (abs < BYTES_IN_KIB ** 2) { - return [bytesToKiB(size).toFixed(digits), BYTES_FORMAT_KIB]; + return [bytesToKiB(size).toFixed(digits), __('KiB')]; } if (abs < BYTES_IN_KIB ** 3) { - return [bytesToMiB(size).toFixed(digits), BYTES_FORMAT_MIB]; + return [bytesToMiB(size).toFixed(digits), __('MiB')]; } - return [bytesToGiB(size).toFixed(digits), BYTES_FORMAT_GIB]; + return [bytesToGiB(size).toFixed(digits), __('GiB')]; } /** * Port of rails number_to_human_size * Formats the bytes in number into a more understandable - * representation (e.g., giving it 1500 yields 1.5 KB). + * representation (e.g., giving it 1536 yields 1.5 KiB). * - * @param {Number} size - * @param {Number} digits - The number of digits to appear after the decimal point - * @returns {String} + * @param {number} size + * @param {number} [digits=2] - The number of digits to appear after the decimal point + * @returns {string} */ export function numberToHumanSize(size, digits = 2) { - const [humanSize, format] = numberToHumanSizeSplit(size, digits); + const [humanSize, label] = numberToHumanSizeSplit(size, digits); - switch (format) { - case BYTES_FORMAT_BYTES: + switch (label) { + case __('B'): return sprintf(__('%{size} B'), { size: humanSize }); - case BYTES_FORMAT_KIB: + case __('KiB'): return sprintf(__('%{size} KiB'), { size: humanSize }); - case BYTES_FORMAT_MIB: + case __('MiB'): return sprintf(__('%{size} MiB'), { size: humanSize }); - case BYTES_FORMAT_GIB: + case __('GiB'): return sprintf(__('%{size} GiB'), { size: humanSize }); default: return ''; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js index 4d8612aeeff..dad4af004cc 100644 --- a/app/assets/javascripts/lib/utils/secret_detection.js +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -32,6 +32,14 @@ export const containsSensitiveToken = (message) => { name: 'GitLab Deploy Token', regex: `gldt-[0-9a-zA-Z_-]{20}`, }, + { + name: 'GitLab SCIM OAuth Access Token', + regex: `glsoat-[0-9a-zA-Z_-]{20}`, + }, + { + name: 'GitLab CI Build (Job) Token', + regex: `glcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}`, + }, ]; for (const rule of sensitiveDataPatterns) { diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 6c30294cbbb..b30eba25aa8 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -173,7 +173,7 @@ export const truncateSha = (sha) => sha.substring(0, 8); * @return {String} */ export function capitalizeFirstCharacter(text) { - return `${text[0].toUpperCase()}${text.slice(1)}`; + return text?.length ? `${text[0].toUpperCase()}${text.slice(1)}` : ''; } /** diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js index c76e44a196d..b4933376d4e 100644 --- a/app/assets/javascripts/logo.js +++ b/app/assets/javascripts/logo.js @@ -13,7 +13,7 @@ export function initPortraitLogoDetection() { const isPortrait = img.height > img.width; if (isPortrait) { // Limit the width when the logo has portrait format - img.classList.replace('gl-h-9', 'gl-w-10'); + img.classList.replace('gl-h-10', 'gl-w-10'); } img.classList.remove('gl-visibility-hidden'); }, diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue index 538aa090aa8..a753641ffd2 100644 --- a/app/assets/javascripts/merge_requests/components/compare_app.vue +++ b/app/assets/javascripts/merge_requests/components/compare_app.vue @@ -42,6 +42,11 @@ export default { required: false, default: () => ({}), }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -120,6 +125,7 @@ export default { :default="currentBranch" :toggle-class="toggleClass.branch" :data-qa-compare-side="compareSide" + :disabled="disabled" data-testid="compare-dropdown" @selected="selectBranch" /> diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue index 20989206a51..35fbf4bc4e6 100644 --- a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue +++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue @@ -46,6 +46,11 @@ export default { required: false, default: '', }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -131,6 +136,7 @@ export default { :toggle-text="current.text || dropdownHeader" :header-text="dropdownHeader" :searching="isLoading" + :disabled="disabled" searchable class="gl-w-full dropdown-target-project" :toggle-class="[ diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index 877e6142bae..e405a534cdc 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -70,6 +70,7 @@ export default { title: { default: '' }, tabs: { default: () => [] }, isFluidLayout: { default: false }, + blocksMerge: { default: false }, }, data() { return { @@ -226,7 +227,7 @@ export default { </li> </ul> <div class="gl-display-none gl-lg-display-flex gl-align-items-center gl-ml-auto"> - <discussion-counter blocks-merge hide-options /> + <discussion-counter :blocks-merge="blocksMerge" hide-options /> <div v-if="isSignedIn" :class="{ 'gl-display-flex gl-gap-3': isNotificationsTodosButtons }" diff --git a/app/assets/javascripts/ml/model_registry/apps/index.js b/app/assets/javascripts/ml/model_registry/apps/index.js index 92d159f68be..60fe5dfa804 100644 --- a/app/assets/javascripts/ml/model_registry/apps/index.js +++ b/app/assets/javascripts/ml/model_registry/apps/index.js @@ -1,5 +1,6 @@ import ShowMlModel from './show_ml_model.vue'; import ShowMlModelVersion from './show_ml_model_version.vue'; import IndexMlModels from './index_ml_models.vue'; +import NewMlModel from './new_ml_model.vue'; -export { ShowMlModel, ShowMlModelVersion, IndexMlModels }; +export { ShowMlModel, ShowMlModelVersion, IndexMlModels, NewMlModel }; diff --git a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue index e5e093db5ca..59b68fc0063 100644 --- a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue +++ b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue @@ -1,6 +1,6 @@ <script> import { isEmpty } from 'lodash'; -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui'; import Pagination from '~/vue_shared/components/incubation/pagination.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -10,6 +10,7 @@ import * as i18n from '../translations'; import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '../constants'; import SearchBar from '../components/search_bar.vue'; import ModelRow from '../components/model_row.vue'; +import ActionsDropdown from '../components/actions_dropdown.vue'; export default { name: 'IndexMlModels', @@ -21,6 +22,16 @@ export default { TitleArea, GlBadge, EmptyState, + GlButton, + ActionsDropdown, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + provide() { + return { + mlflowTrackingUrl: this.mlflowTrackingUrl, + }; }, props: { models: { @@ -31,11 +42,25 @@ export default { type: Object, required: true, }, + createModelPath: { + type: String, + required: true, + }, modelCount: { type: Number, required: false, default: 0, }, + canWriteModelRegistry: { + type: Boolean, + required: false, + default: false, + }, + mlflowTrackingUrl: { + type: String, + required: false, + default: '', + }, }, computed: { hasModels() { @@ -63,6 +88,13 @@ export default { <template #metadata-models-count> <metadata-item icon="machine-learning" :text="$options.i18n.modelsCountLabel(modelCount)" /> </template> + <template #right-actions> + <gl-button v-if="canWriteModelRegistry" :href="createModelPath">{{ + $options.i18n.CREATE_MODEL_LABEL + }}</gl-button> + + <actions-dropdown /> + </template> </title-area> <template v-if="hasModels"> <search-bar :sortable-fields="$options.sortableFields" /> diff --git a/app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue new file mode 100644 index 00000000000..618d4cea1a5 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue @@ -0,0 +1,127 @@ +<script> +import { + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlAlert, + GlButton, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import createModelMutation from '../graphql/mutations/create_model.mutation.graphql'; +import { + NEW_MODEL_LABEL, + ERROR_CREATING_MODEL_LABEL, + CREATE_MODEL_WITH_CLIENT_LABEL, + NAME_LABEL, + DESCRIPTION_LABEL, + CREATE_MODEL_LABEL, +} from '../translations'; + +export default { + name: 'NewMlModel', + components: { + TitleArea, + GlForm, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlAlert, + GlButton, + GlLink, + GlSprintf, + }, + props: { + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + errorMessage: '', + modelName: '', + modelDescription: '', + }; + }, + methods: { + async createModel() { + this.errorMessage = ''; + try { + const variables = { + projectPath: this.projectPath, + name: this.modelName, + description: this.modelDescription, + }; + + const { data } = await this.$apollo.mutate({ + mutation: createModelMutation, + variables, + }); + + const [error] = data?.mlModelCreate?.errors || []; + + if (error) { + this.errorMessage = data.mlModelCreate.errors.join(', '); + } else { + visitUrl(data?.mlModelCreate?.model?._links?.showPath); + } + } catch (error) { + Sentry.captureException(error); + this.errorMessage = ERROR_CREATING_MODEL_LABEL; + } + }, + }, + i18n: { + NEW_MODEL_LABEL, + CREATE_MODEL_WITH_CLIENT_LABEL, + NAME_LABEL, + DESCRIPTION_LABEL, + CREATE_MODEL_LABEL, + }, + docHref: helpPagePath('user/project/ml/model_registry/index.md'), +}; +</script> + +<template> + <div> + <title-area :title="$options.i18n.NEW_MODEL_LABEL" /> + + <gl-alert variant="tip" icon="bulb" class="gl-mb-3" :dismissible="false"> + <gl-sprintf :message="$options.i18n.CREATE_MODEL_WITH_CLIENT_LABEL"> + <template #link="{ content }"> + <gl-link :href="$options.docHref" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + + <gl-alert + v-if="errorMessage" + :dismissible="false" + variant="danger" + class="gl-mb-3" + data-testid="new-model-errors" + > + {{ errorMessage }} + </gl-alert> + + <gl-form @submit.prevent="createModel"> + <gl-form-group :label="$options.i18n.NAME_LABEL"> + <gl-form-input v-model="modelName" /> + </gl-form-group> + + <gl-form-group :label="$options.i18n.DESCRIPTION_LABEL" optional> + <gl-form-textarea v-model="modelDescription" /> + </gl-form-group> + + <gl-button type="submit" variant="confirm" class="js-no-auto-disable">{{ + $options.i18n.CREATE_MODEL_LABEL + }}</gl-button> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue index 51b8fca6511..e771639dade 100644 --- a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue @@ -1,5 +1,5 @@ <script> -import { GlTab, GlTabs, GlBadge } from '@gitlab/ui'; +import { GlTab, GlTabs, GlBadge, GlLink } from '@gitlab/ui'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue'; @@ -19,6 +19,7 @@ export default { GlBadge, MetadataItem, ModelVersionDetail, + GlLink, }, props: { model: { @@ -33,9 +34,6 @@ export default { candidateCount() { return this.model.candidateCount || 0; }, - latestVersionTitle() { - return `${i18n.LATEST_VERSION_LABEL}: ${this.model.latestVersion.version}`; - }, }, i18n, modelVersionEntity: MODEL_ENTITIES.modelVersion, @@ -60,7 +58,14 @@ export default { <gl-tabs class="gl-mt-4"> <gl-tab :title="$options.i18n.MODEL_DETAILS_TAB_LABEL"> <template v-if="model.latestVersion"> - <h3 class="gl-font-lg">{{ latestVersionTitle }}</h3> + <h3 class="gl-font-lg"> + {{ $options.i18n.LATEST_VERSION_LABEL }}: + + <gl-link :href="model.latestVersion.path" data-testid="model-version-link"> + {{ model.latestVersion.version }} + </gl-link> + </h3> + <model-version-detail :model-version="model.latestVersion" /> </template> diff --git a/app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue b/app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue new file mode 100644 index 00000000000..5b4f9e27437 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue @@ -0,0 +1,34 @@ +<script> +import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlDisclosureDropdownItem, + GlDisclosureDropdown, + }, + inject: ['mlflowTrackingUrl'], + computed: { + copyIdItem() { + return { + text: s__('MlModelRegistry|Copy MLflow tracking URL'), + action: () => { + this.$toast.show(s__('MlModelRegistry|Copied MLflow tracking URL to clipboard')); + }, + }; + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + placement="right" + category="tertiary" + :aria-label="__('More actions')" + icon="ellipsis_v" + no-caret + > + <gl-disclosure-dropdown-item :item="copyIdItem" :data-clipboard-text="mlflowTrackingUrl" /> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/ml/model_registry/components/candidate_list.vue b/app/assets/javascripts/ml/model_registry/components/candidate_list.vue index fc24a538293..fca4462d7d2 100644 --- a/app/assets/javascripts/ml/model_registry/components/candidate_list.vue +++ b/app/assets/javascripts/ml/model_registry/components/candidate_list.vue @@ -1,22 +1,17 @@ <script> -import { GlAlert } from '@gitlab/ui'; -import { n__ } from '~/locale'; -import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; -import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue'; import { makeLoadCandidatesErrorMessage, NO_CANDIDATES_LABEL } from '../translations'; import getModelCandidatesQuery from '../graphql/queries/get_model_candidates.query.graphql'; import { GRAPHQL_PAGE_SIZE } from '../constants'; +import SearchableList from './searchable_list.vue'; +import CandidateListRow from './candidate_list_row.vue'; export default { name: 'MlCandidateList', components: { - GlAlert, CandidateListRow, - PackagesListLoader, - RegistryList, + SearchableList, }, props: { modelId: { @@ -26,7 +21,7 @@ export default { }, data() { return { - modelVersions: {}, + candidates: {}, errorMessage: undefined, }; }, @@ -49,18 +44,12 @@ export default { gid() { return convertToGraphQLId('Ml::Model', this.modelId); }, - isListEmpty() { - return this.count === 0; - }, isLoading() { return this.$apollo.queries.candidates.loading; }, pageInfo() { return this.candidates?.pageInfo ?? {}; }, - listTitle() { - return n__('%d candidate', '%d candidates', this.count); - }, queryVariables() { return { id: this.gid, @@ -70,18 +59,12 @@ export default { items() { return this.candidates?.nodes ?? []; }, - count() { - return this.candidates?.count ?? 0; - }, }, methods: { - fetchPage({ first = null, last = null, before = null, after = null } = {}) { + fetchPage(newPageInfo) { const variables = { ...this.queryVariables, - first, - last, - before, - after, + ...newPageInfo, }; this.$apollo.queries.candidates.fetchMore({ @@ -91,18 +74,6 @@ export default { }, }); }, - fetchPreviousCandidatesPage() { - this.fetchPage({ - last: GRAPHQL_PAGE_SIZE, - before: this.pageInfo?.startCursor, - }); - }, - fetchNextCandidatesPage() { - this.fetchPage({ - first: GRAPHQL_PAGE_SIZE, - after: this.pageInfo?.endCursor, - }); - }, }, i18n: { NO_CANDIDATES_LABEL, @@ -111,29 +82,19 @@ export default { </script> <template> <div> - <div v-if="isLoading"> - <packages-list-loader /> - </div> - <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">{{ - errorMessage - }}</gl-alert> - <div v-else-if="isListEmpty" class="gl-text-secondary"> - {{ $options.i18n.NO_CANDIDATES_LABEL }} - </div> - <div v-else> - <registry-list - :hidden-delete="true" - :is-loading="isLoading" - :items="items" - :pagination="pageInfo" - :title="listTitle" - @prev-page="fetchPreviousCandidatesPage" - @next-page="fetchNextCandidatesPage" - > - <template #default="{ item }"> - <candidate-list-row :candidate="item" /> - </template> - </registry-list> - </div> + <searchable-list + :page-info="pageInfo" + :items="items" + :error-message="errorMessage" + @fetch-page="fetchPage" + > + <template #empty-state> + {{ $options.i18n.NO_CANDIDATES_LABEL }} + </template> + + <template #item="{ item }"> + <candidate-list-row :candidate="item" /> + </template> + </searchable-list> </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/components/model_version_list.vue b/app/assets/javascripts/ml/model_registry/components/model_version_list.vue index 6b44cb2f613..5a649a9596a 100644 --- a/app/assets/javascripts/ml/model_registry/components/model_version_list.vue +++ b/app/assets/javascripts/ml/model_registry/components/model_version_list.vue @@ -1,23 +1,18 @@ <script> -import { GlAlert } from '@gitlab/ui'; -import { n__ } from '~/locale'; -import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; -import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { makeLoadVersionsErrorMessage } from '~/ml/model_registry/translations'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import getModelVersionsQuery from '../graphql/queries/get_model_versions.query.graphql'; import { GRAPHQL_PAGE_SIZE, MODEL_ENTITIES } from '../constants'; +import SearchableList from './searchable_list.vue'; import EmptyState from './empty_state.vue'; import ModelVersionRow from './model_version_row.vue'; export default { components: { EmptyState, - GlAlert, ModelVersionRow, - PackagesListLoader, - RegistryList, + SearchableList, }, props: { modelId: { @@ -50,18 +45,12 @@ export default { gid() { return convertToGraphQLId('Ml::Model', this.modelId); }, - isListEmpty() { - return this.count === 0; - }, isLoading() { return this.$apollo.queries.modelVersions.loading; }, pageInfo() { return this.modelVersions?.pageInfo ?? {}; }, - listTitle() { - return n__('%d version', '%d versions', this.versions.length); - }, queryVariables() { return { id: this.gid, @@ -71,31 +60,12 @@ export default { versions() { return this.modelVersions?.nodes ?? []; }, - count() { - return this.modelVersions?.count ?? 0; - }, }, methods: { - fetchPreviousVersionsPage() { + fetchPage(pageInfo) { const variables = { ...this.queryVariables, - first: null, - last: GRAPHQL_PAGE_SIZE, - before: this.pageInfo?.startCursor, - }; - this.$apollo.queries.modelVersions.fetchMore({ - variables, - updateQuery: (previousResult, { fetchMoreResult }) => { - return fetchMoreResult; - }, - }); - }, - fetchNextVersionsPage() { - const variables = { - ...this.queryVariables, - first: GRAPHQL_PAGE_SIZE, - last: null, - after: this.pageInfo?.endCursor, + ...pageInfo, }; this.$apollo.queries.modelVersions.fetchMore({ @@ -110,28 +80,18 @@ export default { }; </script> <template> - <div> - <div v-if="isLoading"> - <packages-list-loader /> - </div> - <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">{{ - errorMessage - }}</gl-alert> - <empty-state v-else-if="isListEmpty" :entity-type="$options.modelVersionEntity" /> - <div v-else> - <registry-list - :hidden-delete="true" - :is-loading="isLoading" - :items="versions" - :pagination="pageInfo" - :title="listTitle" - @prev-page="fetchPreviousVersionsPage" - @next-page="fetchNextVersionsPage" - > - <template #default="{ item }"> - <model-version-row :model-version="item" /> - </template> - </registry-list> - </div> - </div> + <searchable-list + :page-info="pageInfo" + :items="versions" + :error-message="errorMessage" + @fetch-page="fetchPage" + > + <template #empty-state> + <empty-state :entity-type="$options.modelVersionEntity" /> + </template> + + <template #item="{ item }"> + <model-version-row :model-version="item" /> + </template> + </searchable-list> </template> diff --git a/app/assets/javascripts/ml/model_registry/components/searchable_list.vue b/app/assets/javascripts/ml/model_registry/components/searchable_list.vue new file mode 100644 index 00000000000..05062ae6fbf --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/searchable_list.vue @@ -0,0 +1,79 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants'; + +export default { + name: 'SearchableList', + components: { PackagesListLoader, RegistryList, GlAlert }, + props: { + items: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + isListEmpty() { + return this.items.length === 0; + }, + }, + methods: { + prevPage() { + const pageInfo = { + first: null, + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo.startCursor, + }; + + this.$emit('fetch-page', pageInfo); + }, + nextPage() { + const pageInfo = { + first: GRAPHQL_PAGE_SIZE, + last: null, + after: this.pageInfo.endCursor, + }; + + this.$emit('fetch-page', pageInfo); + }, + }, +}; +</script> + +<template> + <div> + <packages-list-loader v-if="isLoading" /> + <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> + <slot v-else-if="isListEmpty" name="empty-state"></slot> + <registry-list + v-else + :hidden-delete="true" + :is-loading="isLoading" + :items="items" + :pagination="pageInfo" + @prev-page="prevPage" + @next-page="nextPage" + > + <template #default="{ item }"> + <slot name="item" :item="item"></slot> + </template> + </registry-list> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql b/app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql new file mode 100644 index 00000000000..af801474e80 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql @@ -0,0 +1,11 @@ +mutation createModel($projectPath: ID!, $name: String!, $description: String) { + mlModelCreate(input: { projectPath: $projectPath, name: $name, description: $description }) { + model { + id + _links { + showPath + } + } + errors + } +} diff --git a/app/assets/javascripts/ml/model_registry/translations.js b/app/assets/javascripts/ml/model_registry/translations.js index 968ec83434d..006142979e2 100644 --- a/app/assets/javascripts/ml/model_registry/translations.js +++ b/app/assets/javascripts/ml/model_registry/translations.js @@ -32,6 +32,15 @@ export const CI_SECTION_LABEL = s__('MlModelRegistry|CI Info'); export const JOB_LABEL = __('Job'); export const CI_USER_LABEL = s__('MlModelRegistry|Triggered by'); export const CI_MR_LABEL = __('Merge request'); +export const NEW_MODEL_LABEL = s__('MlModelRegistry|New model'); +export const CREATE_MODEL_LABEL = s__('MlModelRegistry|Create model'); +export const ERROR_CREATING_MODEL_LABEL = s__( + 'MlModelRegistry|An error has occurred when saving the model.', +); +export const CREATE_MODEL_WITH_CLIENT_LABEL = s__( + 'MlModelRegistry|Creating models is also possible through the MLflow client. %{linkStart}Follow the documentation to learn more.%{linkEnd}', +); +export const NAME_LABEL = __('Name'); export const makeLoadVersionsErrorMessage = (message) => sprintf(s__('MlModelRegistry|Failed to load model versions with error: %{message}'), { diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 9aaae960b6f..77ce5ea5910 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -352,7 +352,7 @@ export default { </template> </gl-sprintf> </div> - <div class="flash-container timeline-content"></div> + <div class="flash-container"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> <comment-field-layout :noteable-data="getNoteableData" :is-internal-note="isInternalNote"> <markdown-editor diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 2524b9efdb6..86f93ee425e 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,6 +1,7 @@ <script> // eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; +import { v4 as uuidv4 } from 'uuid'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; @@ -39,6 +40,11 @@ export default { AiSummary: () => import('ee_component/notes/components/ai_summary.vue'), }, mixins: [glFeatureFlagsMixin()], + provide() { + return { + summarizeClientSubscriptionId: uuidv4(), + }; + }, props: { noteableData: { type: Object, diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index 3a793c9dc14..b7e4ae8e8ea 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -1,3 +1,4 @@ +import { isValidDate } from '~/lib/utils/datetime_utility'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; import { logError } from '~/lib/logger'; @@ -128,6 +129,24 @@ function handleAttributeFilter(filterValue, filterOperator, searchParams) { } } +function handlePeriodFilter(rawValue, filterName, filterParams) { + if (rawValue.trim().indexOf(' ') < 0) { + filterParams.append(filterName, rawValue.trim()); + return; + } + + const dateParts = rawValue.split(' - '); + if (dateParts.length === 2) { + const [start, end] = dateParts; + const startDate = new Date(start); + const endDate = new Date(end); + if (isValidDate(startDate) && isValidDate(endDate)) { + filterParams.append('start_time', startDate.toISOString()); + filterParams.append('end_time', endDate.toISOString()); + } + } +} + /** * Builds URLSearchParams from a filter object of type { [filterName]: undefined | null | Array<{operator: String, value: any} } * e.g: @@ -154,6 +173,8 @@ function filterObjToQueryParams(filterObj) { validFilters.forEach(({ operator, value: rawValue }) => { if (filterName === 'attribute') { handleAttributeFilter(rawValue, operator, filterParams); + } else if (filterName === 'period') { + handlePeriodFilter(rawValue, filterName, filterParams); } else { const paramName = getFilterParamName(filterName, operator); let value = rawValue; diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue index dba738de5e1..ebe69925491 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue +++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue @@ -172,6 +172,6 @@ export default { </div> </div> </div> - <component :is="routerView" /> + <component :is="routerView" list-item-class="gl-px-5" /> </div> </template> diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index 0c363cf7c7f..92381087917 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -39,14 +39,14 @@ export const organizationProjects = { id: 'gid://gitlab/Project/8', nameWithNamespace: 'Twitter / Typeahead.Js', webUrl: 'http://127.0.0.1:3000/twitter/Typeahead.Js', - topics: ['JavaScript', 'Vue.js'], + topics: ['JavaScript', 'Vue.js', 'GraphQL', 'Jest', 'CSS', 'HTML'], forksCount: 4, avatarUrl: null, starCount: 0, visibility: 'public', openIssuesCount: 48, descriptionHtml: - '<p data-sourcepos="1:1-1:59" dir="auto">Optio et reprehenderit enim doloremque deserunt et commodi.</p>', + '<p data-sourcepos="1:1-1:59" dir="auto">Optio et reprehenderit enim doloremque deserunt et commodi. Sed sit amet iaculis neque. Morbi vel convallis elit. Aliquam vitae arcu orci. Aenean sem velit, dapibus eget enim id, tempor lobortis orci. Pellentesque dignissim nec velit eget sagittis. Maecenas lectus sapien, tincidunt ac cursus a, aliquam eu ipsum. Aliquam posuere maximus augue, ut vehicula elit vulputate condimentum. In libero leo, vehicula nec risus in, ullamcorper convallis risus. Phasellus sit amet lectus sit amet sem volutpat cursus. Nullam facilisis nulla nec lacus pretium, in pretium ex aliquam.</p>', issuesAccessLevel: 'enabled', forkingAccessLevel: 'enabled', isForked: true, @@ -141,7 +141,7 @@ export const organizationGroups = { parent: null, webUrl: 'http://127.0.0.1:3000/groups/Commit451', descriptionHtml: - '<p data-sourcepos="1:1-1:52" dir="auto">Autem praesentium vel ut ratione itaque ullam culpa.</p>', + '<p data-sourcepos="1:1-1:52" dir="auto">Autem praesentium vel ut ratione itaque ullam culpa. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse libero sem, congue ut sem id, semper pharetra ante. Sed at dui ac nunc pellentesque congue. Phasellus posuere, nisl non pellentesque dignissim, lacus nisi molestie ex, vel placerat neque ligula non libero. Curabitur ipsum enim, pretium eu dignissim vitae, euismod nec nibh. Praesent eget ipsum eleifend, pellentesque tortor vel, consequat neque. Etiam suscipit dolor massa, sed consectetur nunc tincidunt ut. Suspendisse posuere malesuada finibus. Maecenas iaculis et diam eu iaculis. Proin at nulla sit amet erat sollicitudin suscipit sit amet a libero.</p>', avatarUrl: null, descendantGroupsCount: 0, projectsCount: 3, diff --git a/app/assets/javascripts/organizations/new/components/app.vue b/app/assets/javascripts/organizations/new/components/app.vue index f7f7b79d52b..3a2b786dbae 100644 --- a/app/assets/javascripts/organizations/new/components/app.vue +++ b/app/assets/javascripts/organizations/new/components/app.vue @@ -42,7 +42,15 @@ export default { } = await this.$apollo.mutate({ mutation: organizationCreateMutation, variables: { - input: { name: formValues.name, path: formValues.path }, + input: { + name: formValues.name, + path: formValues.path, + description: formValues.description, + avatar: formValues.avatar, + }, + }, + context: { + hasUpload: formValues.avatar instanceof File, }, }); diff --git a/app/assets/javascripts/organizations/new/index.js b/app/assets/javascripts/organizations/new/index.js index 9c7e5344800..563e366b2c6 100644 --- a/app/assets/javascripts/organizations/new/index.js +++ b/app/assets/javascripts/organizations/new/index.js @@ -13,7 +13,9 @@ export const initOrganizationsNew = () => { const { dataset: { appData }, } = el; - const { organizationsPath, rootUrl } = convertObjectPropsToCamelCase(JSON.parse(appData)); + const { organizationsPath, rootUrl, previewMarkdownPath } = convertObjectPropsToCamelCase( + JSON.parse(appData), + ); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -26,6 +28,7 @@ export const initOrganizationsNew = () => { provide: { organizationsPath, rootUrl, + previewMarkdownPath, }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue index 1acc4c54f75..1cea2ceeb90 100644 --- a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue +++ b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue @@ -3,7 +3,12 @@ import { s__, __ } from '~/locale'; import { createAlert } from '~/alert'; import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; -import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants'; +import { + FORM_FIELD_NAME, + FORM_FIELD_ID, + FORM_FIELD_DESCRIPTION, + FORM_FIELD_AVATAR, +} from '~/organizations/shared/constants'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ORGANIZATION } from '~/graphql_shared/constants'; @@ -25,7 +30,7 @@ export default { ), successMessage: s__('Organization|Organization was successfully updated.'), }, - fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID], + fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_DESCRIPTION, FORM_FIELD_AVATAR], data() { return { loading: false, @@ -33,9 +38,24 @@ export default { }; }, methods: { + avatarInput(formValues) { + // Organization has an avatar and it is been explicitly removed. + if (this.organization.avatar && formValues.avatar === null) { + return { avatar: null }; + } + + // Avatar has been set or changed. + if (formValues.avatar instanceof File) { + return { avatar: formValues.avatar }; + } + + // Avatar has not been changed at all, do not include the `avatar` key in input. + return {}; + }, async onSubmit(formValues) { this.errors = []; this.loading = true; + try { const { data: { @@ -47,8 +67,13 @@ export default { input: { id: convertToGraphQLId(TYPE_ORGANIZATION, this.organization.id), name: formValues.name, + description: formValues.description, + ...this.avatarInput(formValues), }, }, + context: { + hasUpload: formValues.avatar instanceof File, + }, }); if (errors.length) { diff --git a/app/assets/javascripts/organizations/settings/general/index.js b/app/assets/javascripts/organizations/settings/general/index.js index 138606a0aab..3ac1243ff0f 100644 --- a/app/assets/javascripts/organizations/settings/general/index.js +++ b/app/assets/javascripts/organizations/settings/general/index.js @@ -13,9 +13,12 @@ export const initOrganizationsSettingsGeneral = () => { const { dataset: { appData }, } = el; - const { organization, organizationsPath, rootUrl } = convertObjectPropsToCamelCase( - JSON.parse(appData), - ); + const { + organization, + organizationsPath, + rootUrl, + previewMarkdownPath, + } = convertObjectPropsToCamelCase(JSON.parse(appData)); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -29,6 +32,7 @@ export const initOrganizationsSettingsGeneral = () => { organization, organizationsPath, rootUrl, + previewMarkdownPath, }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/organizations/shared/components/groups_view.vue b/app/assets/javascripts/organizations/shared/components/groups_view.vue index eaa3017ef97..87bc79a5405 100644 --- a/app/assets/javascripts/organizations/shared/components/groups_view.vue +++ b/app/assets/javascripts/organizations/shared/components/groups_view.vue @@ -32,6 +32,11 @@ export default { required: false, default: false, }, + listItemClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, data() { return { @@ -77,6 +82,11 @@ export default { <template> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <groups-list v-else-if="groups.length" :groups="groups" show-group-icon /> + <groups-list + v-else-if="groups.length" + :groups="groups" + show-group-icon + :list-item-class="listItemClass" + /> <gl-empty-state v-else v-bind="emptyStateProps" /> </template> diff --git a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue index c5bb16b944a..49519369e9a 100644 --- a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue +++ b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue @@ -3,10 +3,15 @@ import { GlForm, GlFormFields, GlButton } from '@gitlab/ui'; import { formValidators } from '@gitlab/ui/dist/utils'; import { s__, __ } from '~/locale'; import { slugify } from '~/lib/utils/text_utility'; +import AvatarUploadDropzone from '~/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_PATH, + FORM_FIELD_DESCRIPTION, + FORM_FIELD_AVATAR, FORM_FIELD_PATH_VALIDATORS, } from '../constants'; import OrganizationUrlField from './organization_url_field.vue'; @@ -18,12 +23,28 @@ export default { GlFormFields, GlButton, OrganizationUrlField, + AvatarUploadDropzone, + MarkdownField, }, i18n: { cancel: __('Cancel'), }, formId: 'new-organization-form', - inject: ['organizationsPath'], + markdownDocsPath: helpPagePath('user/organization/index', { + anchor: 'organization-description-supported-markdown', + }), + restrictedToolBarItems: [ + 'code', + 'quote', + 'bullet-list', + 'numbered-list', + 'task-list', + 'collapsible-section', + 'table', + 'attach-file', + 'full-screen', + ], + inject: ['organizationsPath', 'previewMarkdownPath'], props: { loading: { type: Boolean, @@ -36,6 +57,8 @@ export default { return { [FORM_FIELD_NAME]: '', [FORM_FIELD_PATH]: '', + [FORM_FIELD_DESCRIPTION]: '', + [FORM_FIELD_AVATAR]: null, }; }, }, @@ -43,7 +66,7 @@ export default { type: Array, required: false, default() { - return [FORM_FIELD_NAME, FORM_FIELD_PATH]; + return [FORM_FIELD_NAME, FORM_FIELD_PATH, FORM_FIELD_DESCRIPTION, FORM_FIELD_AVATAR]; }, }, submitButtonText: { @@ -98,6 +121,19 @@ export default { class: 'gl-w-full', }, }, + [FORM_FIELD_DESCRIPTION]: { + label: s__('Organization|Organization description (optional)'), + groupAttrs: { + class: 'gl-w-full common-note-form', + }, + }, + [FORM_FIELD_AVATAR]: { + label: s__('Organization|Organization avatar'), + groupAttrs: { + class: 'gl-w-full', + labelSrOnly: true, + }, + }, }; return Object.entries(fields).reduce((accumulator, [fieldKey, fieldDefinition]) => { @@ -126,6 +162,7 @@ export default { formFieldsInputEvent(event); this.hasPathBeenManuallySet = true; }, + helpPagePath, }, }; </script> @@ -148,11 +185,46 @@ export default { @blur="blur" /> </template> + <template #input(description)="{ id, value, input, blur }"> + <div class="gl-md-form-input-xl"> + <markdown-field + :can-attach-file="false" + :markdown-preview-path="previewMarkdownPath" + :markdown-docs-path="$options.markdownDocsPath" + :textarea-value="value" + :restricted-tool-bar-items="$options.restrictedToolBarItems" + > + <template #textarea> + <textarea + :id="id" + :value="value" + class="note-textarea js-gfm-input markdown-area" + maxlength="1024" + @input="input($event.target.value)" + @blur="blur" + ></textarea> + </template> + </markdown-field> + </div> + </template> + <template #input(avatar)="{ input, value }"> + <avatar-upload-dropzone + :value="value" + :entity="formValues" + :label="fields.avatar.label" + @input="input" + /> + </template> </gl-form-fields> <div class="gl-display-flex gl-gap-3"> - <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="loading">{{ - submitButtonText - }}</gl-button> + <gl-button + type="submit" + variant="confirm" + class="js-no-auto-disable" + :loading="loading" + data-testid="submit-button" + >{{ submitButtonText }}</gl-button + > <gl-button v-if="showCancelButton" :href="organizationsPath">{{ $options.i18n.cancel }}</gl-button> diff --git a/app/assets/javascripts/organizations/shared/components/organization_url_field.vue b/app/assets/javascripts/organizations/shared/components/organization_url_field.vue index d36f62477e6..c4b31e6b8a6 100644 --- a/app/assets/javascripts/organizations/shared/components/organization_url_field.vue +++ b/app/assets/javascripts/organizations/shared/components/organization_url_field.vue @@ -39,7 +39,7 @@ export default { </script> <template> - <gl-form-input-group> + <gl-form-input-group class="gl-md-form-input-xl"> <template #prepend> <gl-input-group-text class="organization-root-path"> <gl-truncate :text="baseUrl" position="middle" /> @@ -50,7 +50,7 @@ export default { :id="id" :value="value" :placeholder="$options.i18n.pathPlaceholder" - class="gl-h-auto! gl-md-form-input-lg" + class="gl-h-auto!" @input="$emit('input', $event)" @blur="$emit('blur', $event)" /> diff --git a/app/assets/javascripts/organizations/shared/components/projects_view.vue b/app/assets/javascripts/organizations/shared/components/projects_view.vue index 9bf4e597884..323a8895821 100644 --- a/app/assets/javascripts/organizations/shared/components/projects_view.vue +++ b/app/assets/javascripts/organizations/shared/components/projects_view.vue @@ -36,6 +36,11 @@ export default { required: false, default: false, }, + listItemClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, data() { return { @@ -81,6 +86,11 @@ export default { <template> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <projects-list v-else-if="projects.length" :projects="projects" show-project-icon /> + <projects-list + v-else-if="projects.length" + :projects="projects" + show-project-icon + :list-item-class="listItemClass" + /> <gl-empty-state v-else v-bind="emptyStateProps" /> </template> diff --git a/app/assets/javascripts/organizations/shared/constants.js b/app/assets/javascripts/organizations/shared/constants.js index 7287d84f99f..c4f4f348ff2 100644 --- a/app/assets/javascripts/organizations/shared/constants.js +++ b/app/assets/javascripts/organizations/shared/constants.js @@ -4,6 +4,8 @@ import { s__ } from '~/locale'; export const FORM_FIELD_NAME = 'name'; export const FORM_FIELD_ID = 'id'; export const FORM_FIELD_PATH = 'path'; +export const FORM_FIELD_DESCRIPTION = 'description'; +export const FORM_FIELD_AVATAR = 'avatar'; export const FORM_FIELD_PATH_VALIDATORS = [ formValidators.required(s__('Organization|Organization URL is required.')), diff --git a/app/assets/javascripts/organizations/show/components/app.vue b/app/assets/javascripts/organizations/show/components/app.vue index 47264d80454..4cd5ada1d66 100644 --- a/app/assets/javascripts/organizations/show/components/app.vue +++ b/app/assets/javascripts/organizations/show/components/app.vue @@ -1,11 +1,12 @@ <script> import OrganizationAvatar from './organization_avatar.vue'; +import OrganizationDescription from './organization_description.vue'; import GroupsAndProjects from './groups_and_projects.vue'; import AssociationCounts from './association_counts.vue'; export default { name: 'OrganizationShowApp', - components: { OrganizationAvatar, GroupsAndProjects, AssociationCounts }, + components: { OrganizationAvatar, OrganizationDescription, GroupsAndProjects, AssociationCounts }, props: { organization: { type: Object, @@ -26,6 +27,7 @@ export default { <template> <div class="gl-py-6"> <organization-avatar :organization="organization" /> + <organization-description :organization="organization" /> <association-counts :association-counts="associationCounts" :groups-and-projects-organization-path="groupsAndProjectsOrganizationPath" diff --git a/app/assets/javascripts/organizations/show/components/organization_avatar.vue b/app/assets/javascripts/organizations/show/components/organization_avatar.vue index c57ee0ea5b5..d569af3e9b4 100644 --- a/app/assets/javascripts/organizations/show/components/organization_avatar.vue +++ b/app/assets/javascripts/organizations/show/components/organization_avatar.vue @@ -44,6 +44,7 @@ export default { :entity-name="organization.name" :shape="$options.AVATAR_SHAPE_OPTION_RECT" :size="64" + :src="organization.avatar_url" /> <div class="gl-ml-3"> <div class="gl-display-flex gl-align-items-center"> diff --git a/app/assets/javascripts/organizations/show/components/organization_description.vue b/app/assets/javascripts/organizations/show/components/organization_description.vue new file mode 100644 index 00000000000..6222bdcd082 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/organization_description.vue @@ -0,0 +1,24 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + name: 'OrganizationDescription', + directives: { + SafeHtml, + }, + props: { + organization: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div + v-if="organization.description_html" + v-safe-html="organization.description_html" + class="gl-mt-5 md" + ></div> +</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 75af0286e12..7fd7c0fe542 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 @@ -142,7 +142,12 @@ export default { </template> <template v-if="formattedSize" #metadata-size> - <metadata-item icon="disk" :text="formattedSize" data-testid="image-size" /> + <metadata-item + icon="disk" + :text="formattedSize" + :text-tooltip="s__('ContainerRegistry|Includes both tagged and untagged images')" + data-testid="image-size" + /> </template> <template #metadata-cleanup> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 6ff7d58fd9e..89ab184d808 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { n__ } from '~/locale'; +import { __ } from '~/locale'; import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; import PackagesForwardingSettings from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; @@ -50,21 +50,13 @@ export default { dismissAlert() { this.alertMessage = null; }, - handleSuccess(amount = 1) { - const successMessage = n__( - 'Setting saved successfully', - 'Settings saved successfully', - amount, - ); + handleSuccess() { + const successMessage = __('Settings saved successfully.'); this.$toast.show(successMessage); this.dismissAlert(); }, - handleError(amount = 1) { - const errorMessage = n__( - 'An error occurred while saving the setting', - 'An error occurred while saving the settings', - amount, - ); + handleError() { + const errorMessage = __('An error occurred while saving the settings.'); this.alertMessage = errorMessage; }, }, @@ -81,14 +73,14 @@ export default { class="settings-section-no-bottom" :package-settings="packageSettings" :is-loading="isLoading" - @success="handleSuccess(2)" - @error="handleError(2)" + @success="handleSuccess" + @error="handleError" /> <packages-forwarding-settings :forward-settings="packageSettings" - @success="handleSuccess(2)" - @error="handleError(2)" + @success="handleSuccess" + @error="handleError" /> <dependency-proxy-settings diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue new file mode 100644 index 00000000000..d9177778803 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue @@ -0,0 +1,115 @@ +<script> +import { GlCard, GlTable, GlLoadingIcon } from '@gitlab/ui'; +import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; +import { s__ } from '~/locale'; + +const PAGINATION_DEFAULT_PER_PAGE = 10; + +export default { + components: { + SettingsBlock, + GlCard, + GlTable, + GlLoadingIcon, + }, + inject: ['projectPath'], + i18n: { + settingBlockTitle: s__('PackageRegistry|Protected packages'), + settingBlockDescription: s__( + 'PackageRegistry|When a package is protected then only certain user roles are able to update and delete the protected package. This helps to avoid tampering with the package.', + ), + }, + data() { + return { + fetchSettingsError: false, + packageProtectionRules: [], + }; + }, + computed: { + tableItems() { + return this.packageProtectionRules.map((packagesProtectionRule) => { + return { + col_1_package_name_pattern: packagesProtectionRule.packageNamePattern, + col_2_package_type: packagesProtectionRule.packageType, + col_3_push_protected_up_to_access_level: + packagesProtectionRule.pushProtectedUpToAccessLevel, + }; + }); + }, + totalItems() { + return this.packageProtectionRules.length; + }, + isLoadingPackageProtectionRules() { + return this.$apollo.queries.packageProtectionRules.loading; + }, + }, + apollo: { + packageProtectionRules: { + query: packagesProtectionRuleQuery, + variables() { + return { + projectPath: this.projectPath, + first: PAGINATION_DEFAULT_PER_PAGE, + }; + }, + update: (data) => { + return data.project?.packagesProtectionRules?.nodes || []; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, + fields: [ + { + key: 'col_1_package_name_pattern', + label: s__('PackageRegistry|Package name pattern'), + }, + { key: 'col_2_package_type', label: s__('PackageRegistry|Package type') }, + { + key: 'col_3_push_protected_up_to_access_level', + label: s__('PackageRegistry|Push protected up to access level'), + }, + ], +}; +</script> + +<template> + <settings-block> + <template #title>{{ $options.i18n.settingBlockTitle }}</template> + + <template #description> + {{ $options.i18n.settingBlockDescription }} + </template> + + <template #default> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper gl-justify-content-space-between"> + <h3 class="gl-new-card-title">{{ $options.i18n.settingBlockTitle }}</h3> + </div> + </template> + + <template #default> + <gl-table + :items="tableItems" + :fields="$options.fields" + show-empty + stacked="md" + class="mb-3" + :busy="isLoadingPackageProtectionRules" + > + <template #table-busy> + <gl-loading-icon size="sm" class="gl-my-5" /> + </template> + </gl-table> + </template> + </gl-card> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 06af69ff250..8e4c50b199b 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -8,6 +8,7 @@ import { } from '~/packages_and_registries/settings/project/constants'; import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -18,7 +19,10 @@ export default { ), GlAlert, PackagesCleanupPolicy, + PackagesProtectionRules: () => + import('~/packages_and_registries/settings/project/components/packages_protection_rules.vue'), }, + mixins: [glFeatureFlagsMixin()], inject: [ 'showContainerRegistrySettings', 'showPackageRegistrySettings', @@ -32,6 +36,11 @@ export default { showAlert: false, }; }, + computed: { + showProtectedPackagesSettings() { + return this.showPackageRegistrySettings && this.glFeatures.packagesProtectedPackages; + }, + }, mounted() { this.checkAlert(); }, @@ -60,6 +69,7 @@ export default { > {{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }} </gl-alert> + <packages-protection-rules v-if="showProtectedPackagesSettings" /> <packages-cleanup-policy v-if="showPackageRegistrySettings" /> <container-expiration-policy v-if="showContainerRegistrySettings" /> <dependency-proxy-packages-settings v-if="showDependencyProxySettings" /> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql new file mode 100644 index 00000000000..e0a072b93e4 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql @@ -0,0 +1,13 @@ +query getProjectPackageProtectionRules($projectPath: ID!, $first: Int) { + project(fullPath: $projectPath) { + id + packagesProtectionRules(first: $first) { + nodes { + id + packageNamePattern + packageType + pushProtectedUpToAccessLevel + } + } + } +} diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 1d54dad43a9..e66040c5a99 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -1,6 +1,5 @@ <script> import { - GlButton, GlEmptyState, GlIcon, GlLink, @@ -22,7 +21,6 @@ import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isImporting } from '../utils'; import { DEFAULT_ERROR } from '../utils/error_messages'; @@ -43,7 +41,6 @@ const tableCell = (config) => ({ export default { components: { - GlButton, GlEmptyState, GlIcon, GlLink, @@ -59,8 +56,6 @@ export default { GlTooltip, }, - mixins: [glFeatureFlagMixin()], - inject: ['realtimeChangesPath'], data() { @@ -107,10 +102,6 @@ export default { .map((item) => item.bulk_import_id); }, - showDetailsLink() { - return this.glFeatures.bulkImportDetailsPage; - }, - paginationConfigCopy() { return { ...this.paginationConfig }; }, @@ -265,7 +256,7 @@ export default { <template #cell(created_at)="{ value }"> <time-ago :time="value" /> </template> - <template #cell(status)="{ value, item, toggleDetails, detailsShowing }"> + <template #cell(status)="{ value, item }"> <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-flex-start gl-justify-content-space-between gl-gap-3" > @@ -273,20 +264,10 @@ export default { :id="item.bulk_import_id" :entity-id="item.id" :has-failures="item.has_failures" - :show-details-link="showDetailsLink" :status="value" /> - <gl-button - v-if="!showDetailsLink && item.failures.length" - :selected="detailsShowing" - @click="toggleDetails" - >{{ __('Details') }}</gl-button - > </div> </template> - <template #row-details="{ item }"> - <pre><code>{{ item.failures }}</code></pre> - </template> </gl-table-lite> <pagination-bar :page-info="pageInfo" diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 8cb1462c883..3d877bb3abb 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -16,8 +16,9 @@ if (mrNewCompareNode) { const sourceCompareEl = document.getElementById('js-source-project-dropdown'); const compareEl = document.querySelector('.js-merge-request-new-compare'); const targetBranch = Vue.observable({ name: '' }); - const currentSourceBranch = JSON.parse(sourceCompareEl.dataset.currentBranch); + const sourceBranch = Vue.observable(currentSourceBranch); + // eslint-disable-next-line no-new new Vue({ el: sourceCompareEl, @@ -52,6 +53,9 @@ if (mrNewCompareNode) { if (targetBranchName) { targetBranch.name = targetBranchName; } + + sourceBranch.value = branchName; + sourceBranch.text = branchName; }, }, render(h) { @@ -102,9 +106,14 @@ if (mrNewCompareNode) { return currentTargetBranch; }, + isDisabled() { + return !sourceBranch.value; + }, }, render(h) { - return h(CompareApp, { props: { currentBranch: this.currentBranch } }); + return h(CompareApp, { + props: { currentBranch: this.currentBranch, disabled: this.isDisabled }, + }); }, }); } else { diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js index a9d281fc899..69032455fe3 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/page.js +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -28,7 +28,15 @@ requestIdleCallback(() => { if (el) { const { data } = el.dataset; - const { iid, projectPath, title, tabs, isFluidLayout, sourceProjectPath } = JSON.parse(data); + const { + iid, + projectPath, + title, + tabs, + isFluidLayout, + sourceProjectPath, + blocksMerge, + } = JSON.parse(data); // eslint-disable-next-line no-new new Vue({ @@ -42,6 +50,7 @@ requestIdleCallback(() => { title, tabs, isFluidLayout: parseBoolean(isFluidLayout), + blocksMerge: parseBoolean(blocksMerge), sourceProjectPath, }, render(h) { diff --git a/app/assets/javascripts/pages/projects/ml/models/index/index.js b/app/assets/javascripts/pages/projects/ml/models/index/index.js index 3f8ef4910a7..54dcf28164f 100644 --- a/app/assets/javascripts/pages/projects/ml/models/index/index.js +++ b/app/assets/javascripts/pages/projects/ml/models/index/index.js @@ -1,4 +1,4 @@ import { initSimpleApp } from '~/helpers/init_simple_app_helper'; import { IndexMlModels } from '~/ml/model_registry/apps'; -initSimpleApp('#js-index-ml-models', IndexMlModels); +initSimpleApp('#js-index-ml-models', IndexMlModels, { withApolloProvider: true }); diff --git a/app/assets/javascripts/pages/projects/ml/models/new/index.js b/app/assets/javascripts/pages/projects/ml/models/new/index.js new file mode 100644 index 00000000000..8dc5c5aea4e --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/models/new/index.js @@ -0,0 +1,4 @@ +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; +import { NewMlModel } from '~/ml/model_registry/apps'; + +initSimpleApp('#js-mount-new-ml-model', NewMlModel, { withApolloProvider: true }); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index dce40c1f322..ad6f84fbc07 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,11 +1,9 @@ import initArtifactsSettings from '~/artifacts_settings'; -import SecretValues from '~/behaviors/secret_values'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initVariableList from '~/ci/ci_variable_list'; import initInheritedGroupCiVariables from '~/ci/inherited_ci_variables'; import initDeployFreeze from '~/deploy_freeze'; import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; -import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initRefSwitcherBadges from '~/projects/settings/mount_ref_switcher_badges'; import initSettingsPanels from '~/settings_panels'; @@ -18,14 +16,6 @@ import { initGeneralPipelinesOptions } from '~/ci_settings_general_pipeline'; // Initialize expandable settings panels initSettingsPanels(); -const runnerToken = document.querySelector('.js-secret-runner-token'); -if (runnerToken) { - const runnerTokenSecretValue = new SecretValues({ - container: runnerToken, - }); - runnerTokenSecretValue.init(); -} - initVariableList(); initInheritedGroupCiVariables(); @@ -47,7 +37,6 @@ initArtifactsSettings(); initProjectRunnersRegistrationDropdown(); initSharedRunnersToggle(); initRefSwitcherBadges(); -initInstallRunner(); initTokenAccess(); initCiSecureFiles(); initGeneralPipelinesOptions(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue index 32775ac553c..c35e10462a0 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue @@ -5,9 +5,9 @@ import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; -import getCiCatalogSettingsQuery from '../graphql/queries/get_ci_catalog_settings.query.graphql'; -import catalogResourcesCreate from '../graphql/mutations/catalog_resources_create.mutation.graphql'; -import catalogResourcesDestroy from '../graphql/mutations/catalog_resources_destroy.mutation.graphql'; +import getCiCatalogSettingsQuery from '~/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql'; +import catalogResourcesCreate from '~/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql'; +import catalogResourcesDestroy from '~/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql'; const i18n = { catalogResourceQueryError: s__( diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js index 4b4589dc46c..78dd456aad9 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/index.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js @@ -14,6 +14,9 @@ export default function initProjectPermissionsSettings() { const mountPoint = document.querySelector('.js-project-permissions-form'); const componentPropsEl = document.querySelector('.js-project-permissions-form-data'); + + if (!mountPoint) return null; + const componentProps = JSON.parse(componentPropsEl.innerHTML); const { diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 32df2911a48..ee1a7633a11 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -16,7 +16,7 @@ new SigninTabsMemoizer(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new new OAuthRememberMe({ - container: $('.omniauth-container'), + container: $('.js-oauth-login'), }).bindEvents(); // Save the URL fragment from the current window location. This will be present if the user was diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index bad8a7cedc6..3336b094560 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -20,8 +20,8 @@ export default class OAuthRememberMe { toggleRememberMe(event) { const rememberMe = $(event.target).is(':checked'); - $('.js-oauth-login', this.container).each((i, element) => { - const $form = $(element).parent('form'); + $('.js-oauth-login form', this.container).each((_, form) => { + const $form = $(form); const href = $form.attr('action'); if (rememberMe) { diff --git a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js index 70e5e336e78..54ec3c52f62 100644 --- a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js +++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js @@ -12,7 +12,7 @@ export default function preserveUrlFragment(fragment = '') { // Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is // eventually redirected back to the originally requested URL. - const forms = document.querySelectorAll('#signin-container .tab-content form'); + const forms = document.querySelectorAll('.js-non-oauth-login form'); Array.prototype.forEach.call(forms, (form) => { const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`); form.setAttribute('action', actionWithFragment); @@ -20,7 +20,7 @@ export default function preserveUrlFragment(fragment = '') { // Append a redirect_fragment query param to all oauth provider links. The redirect_fragment // query param will be available in the omniauth callback upon successful authentication - const oauthForms = document.querySelectorAll('#signin-container .omniauth-container form'); + const oauthForms = document.querySelectorAll('.js-oauth-login form'); Array.prototype.forEach.call(oauthForms, (oauthForm) => { const newHref = mergeUrlParams( { redirect_fragment: normalFragment }, diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js index aeb9f2fb8d3..9c92d4f8f1e 100644 --- a/app/assets/javascripts/pages/shared/mount_badge_settings.js +++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js @@ -5,6 +5,8 @@ import store from '~/badges/store'; export default (kind) => { const badgeSettingsElement = document.getElementById('badge-settings'); + if (!badgeSettingsElement) return null; + store.dispatch('loadBadges', { kind, apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl, diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js deleted file mode 100644 index e83c73edfde..00000000000 --- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js +++ /dev/null @@ -1,27 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; - -Vue.use(VueApollo); - -export function initInstallRunner(componentId = 'js-install-runner') { - const installRunnerEl = document.getElementById(componentId); - - if (installRunnerEl) { - const defaultClient = createDefaultClient(); - - const apolloProvider = new VueApollo({ - defaultClient, - }); - - // eslint-disable-next-line no-new - new Vue({ - el: installRunnerEl, - apolloProvider, - render(createElement) { - return createElement(RunnerInstructions); - }, - }); - } -} diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index d2c31314bba..134962721d2 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -8,7 +8,7 @@ import UserTabs from './user_tabs'; function initUserProfile(action) { // TODO: Remove both Vue and legacy JS tabs code/feature flag uses with the // removal of the old navigation. - // See https://gitlab.com/groups/gitlab-org/-/epics/11875. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/435899. if (gon.features?.profileTabsVue) { initProfileTabs(); diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 79eb3902116..f9e22808b0d 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -1,5 +1,5 @@ // TODO: Remove this with the removal of the old navigation. -// See https://gitlab.com/groups/gitlab-org/-/epics/11875. +// See https://gitlab.com/gitlab-org/gitlab/-/issues/435899. import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue index 96c11ea9e4e..c4e27d89f49 100644 --- a/app/assets/javascripts/performance_bar/components/request_warning.vue +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -37,6 +37,7 @@ export default { :id="htmlId" v-gl-tooltip.viewport="warningMessage" data-name="warning" + data-testid="warning" class="gl-ml-2" /> </span> diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 7420542a065..95ef04ceb30 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -24,7 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-branch-rules-info-callout', '.js-new-nav-for-everyone-callout', '.js-namespace-over-storage-users-combined-alert', - '.js-code-suggestions-ga-non-owner-alert', + '.js-code-suggestions-ga-alert', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 2fc1f99c183..8d20ef1989e 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/alert'; +import { createAlert, VARIANT_DANGER } from '~/alert'; import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants'; import IntegrationView from './integration_view.vue'; @@ -94,9 +94,8 @@ export default { return; } updateClasses(this.bodyClasses, this.getSelectedTheme().css_class, this.selectedLayout); - const { message = this.$options.i18n.defaultSuccess, variant = VARIANT_INFO } = - customEvent?.detail?.[0] || {}; - createAlert({ message, variant }); + const message = customEvent?.detail?.[0]?.message || this.$options.i18n.defaultSuccess || ''; + this.$toast.show(message); this.isSubmitEnabled = true; }, handleError(customEvent) { diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js index 8e4d42a42c6..3ba34078cd7 100644 --- a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js +++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { initListboxInputs } from '~/vue_shared/components/listbox_input/init_listbox_inputs'; import ProfilePreferences from './components/profile_preferences.vue'; @@ -21,6 +22,8 @@ export default () => { { formEl }, ); + Vue.use(GlToast); + return new Vue({ el, name: 'ProfilePreferencesApp', diff --git a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue deleted file mode 100644 index 67b5e1e512c..00000000000 --- a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { n__ } from '~/locale'; - -export default { - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlButton, - }, - props: { - commentsCount: { - type: Number, - required: true, - }, - }, - computed: { - tooltipText() { - return n__('%d comment on this commit', '%d comments on this commit', this.commentsCount); - }, - showCommentButton() { - return this.commentsCount > 0; - }, - }, -}; -</script> - -<template> - <span - v-if="showCommentButton" - v-gl-tooltip - class="gl-display-none gl-sm-display-inline-block" - tabindex="0" - :title="tooltipText" - data-testid="comment-button-wrapper" - > - <gl-button icon="comment" class="gl-mr-3" disabled> - {{ commentsCount }} - </gl-button> - </span> -</template> diff --git a/app/assets/javascripts/projects/commit/index.js b/app/assets/javascripts/projects/commit/index.js index d8d30c4332c..4eb51d566c5 100644 --- a/app/assets/javascripts/projects/commit/index.js +++ b/app/assets/javascripts/projects/commit/index.js @@ -1,11 +1,9 @@ import initCherryPickCommitModal from './init_cherry_pick_commit_modal'; -import initCommitCommentsButton from './init_commit_comments_button'; import initCommitOptionsDropdown from './init_commit_options_dropdown'; import initRevertCommitModal from './init_revert_commit_modal'; export default () => { initRevertCommitModal(); initCherryPickCommitModal(); - initCommitCommentsButton(); initCommitOptionsDropdown(); }; diff --git a/app/assets/javascripts/projects/commit/init_commit_comments_button.js b/app/assets/javascripts/projects/commit/init_commit_comments_button.js deleted file mode 100644 index d70f7cb65f3..00000000000 --- a/app/assets/javascripts/projects/commit/init_commit_comments_button.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import CommitCommentsButton from './components/commit_comments_button.vue'; - -export default function initCommitCommentsButton() { - const el = document.querySelector('#js-commit-comments-button'); - - if (!el) { - return false; - } - - const { commentsCount } = el.dataset; - - return new Vue({ - el, - render: (createElement) => - createElement(CommitCommentsButton, { props: { commentsCount: Number(commentsCount) } }), - }); -} diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue index 25af4cc8082..684ae5343b0 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue @@ -107,7 +107,7 @@ export default { </script> <template> - <div class="gl-ml-7"> + <div> <refs-list v-if="hasBranches" :has-containing-refs="hasContainingBranches" diff --git a/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue index 8ceab9cb60b..7a926c3f4e6 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue @@ -1,6 +1,6 @@ <script> import { GlCollapse, GlBadge, GlButton, GlIcon, GlSkeletonLoader } from '@gitlab/ui'; -import { CONTAINING_COMMIT, FETCH_CONTAINING_REFS_EVENT } from '../constants'; +import { CONTAINING_COMMIT, FETCH_CONTAINING_REFS_EVENT, BRANCHES_REF_TYPE } from '../constants'; export default { name: 'RefsList', @@ -55,6 +55,9 @@ export default { isLoadingRefs() { return this.isLoading && !this.containingRefs.length; }, + refIcon() { + return this.refType === BRANCHES_REF_TYPE ? 'branch' : 'tag'; + }, }, methods: { toggleCollapse() { @@ -75,7 +78,8 @@ export default { </script> <template> - <div class="gl-pt-4"> + <div class="gl-p-5 gl-border-b gl-border-gray-50"> + <gl-icon :name="refIcon" :size="14" class="gl-ml-2 gl-mr-3" /> <span data-testid="title" class="gl-mr-2">{{ namespace }}</span> <gl-badge v-for="ref in tippingRefs" diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index 84a2ddfce07..c9a502bb6d2 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -1,20 +1,11 @@ <script> -import { - GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlTruncate, GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import { s__ } from '~/locale'; +import { __, s__, n__ } from '~/locale'; import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql'; import eventHub from '../event_hub'; @@ -22,12 +13,9 @@ export default { components: { GlButton, GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownSectionHeader, - GlSearchBoxByType, GlTruncate, + GlCollapsibleListbox, + GlIcon, }, mixins: [Tracking.mixin()], apollo: { @@ -91,12 +79,61 @@ export default { !this.groupPathToFilterBy ); }, - hasNoMatches() { - return !this.hasGroupMatches && !this.hasNamespaceMatches; + items() { + return this.groupsItems.concat(this.namespaceItems); + }, + groupsItems() { + if (this.hasGroupMatches) { + return [ + { + text: __('Groups'), + options: this.filteredGroups.map((group) => ({ + value: group.id, + text: group.fullPath, + })), + }, + ]; + } + + return []; + }, + allItems() { + return this.filteredGroups.concat(this.currentUser.namespace); + }, + namespaceItems() { + if (this.hasNamespaceMatches && this.userNamespaceUniqueId) + return [ + { + text: __('Users'), + options: [ + { + value: this.userNamespace.id, + text: this.userNamespace.fullPath, + }, + ], + }, + ]; + return []; }, dropdownPlaceholderClass() { return this.selectedNamespace.id ? '' : 'gl-text-gray-500!'; }, + dropdownText() { + if (this.selectedNamespace && this.selectedNamespace?.fullPath) { + return this.selectedNamespace.fullPath; + } + return null; + }, + loading() { + return this.$apollo.queries.currentUser.loading; + }, + searchSummary() { + return n__( + 'ProjectsNew|%d group or namespace found', + 'ProjectsNew|%d groups or namespaces found', + this.items.length, + ); + }, }, created() { eventHub.$on('select-template', this.handleSelectTemplate); @@ -109,15 +146,18 @@ export default { if (this.shouldSkipQuery) { this.shouldSkipQuery = false; } - this.$refs.search.focusInput(); - }, - handleDropdownItemClick(namespace) { - eventHub.$emit('update-visibility', { - name: namespace.name, - visibility: namespace.visibility, - showPath: namespace.webUrl, - editPath: joinPaths(namespace.webUrl, '-', 'edit'), - }); + }, + handleDropdownItemClick(namespaceId) { + const namespace = this.allItems.find((item) => item.id === namespaceId); + + if (namespace) { + eventHub.$emit('update-visibility', { + name: namespace.name, + visibility: namespace.visibility, + showPath: namespace.webUrl, + editPath: joinPaths(namespace.webUrl, '-', 'edit'), + }); + } this.setNamespace(namespace); }, handleSelectTemplate(id, fullPath) { @@ -137,6 +177,12 @@ export default { this.track('activate_form_input', { label: this.trackLabel, property: 'project_path' }); } }, + onSearch(query) { + this.search = query; + }, + }, + i18n: { + emptySearchResult: __('No matches found'), }, emptyNameSpace: { id: undefined, @@ -154,48 +200,38 @@ export default { >{{ rootUrl }}</gl-button > - <gl-dropdown - class="js-group-namespace-dropdown gl-flex-grow-1" - :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`" + <gl-collapsible-listbox + searchable + fluid-width + :searching="loading" + :items="items" + class="js-group-namespace-dropdown group-namespace-dropdown gl-flex-grow-1" + :toggle-text="dropdownText" + :no-results-text="$options.i18n.emptySearchResult" data-testid="select-namespace-dropdown" @show="trackDropdownShow" @shown="handleDropdownShown" + @select="handleDropdownItemClick" + @search="onSearch" > - <template #button-text> - <gl-truncate - v-if="selectedNamespace.fullPath" - :text="selectedNamespace.fullPath" - position="start" - with-tooltip - /> + <template #toggle> + <gl-button + class="gl-flex-basis-full! gl-rounded-left-none! gl-w-20" + :class="dropdownPlaceholderClass" + > + <gl-truncate + :text="dropdownText" + position="start" + class="gl-overflow-hidden gl-mr-auto" + with-tooltip + /> + <gl-icon class="gl-button-icon dropdown-chevron gl-mr-0! gl-ml-2!" name="chevron-down" /> + </gl-button> </template> - <gl-search-box-by-type - ref="search" - v-model.trim="search" - :is-loading="$apollo.queries.currentUser.loading" - data-testid="select-namespace-dropdown-search-field" - /> - <template v-if="!$apollo.queries.currentUser.loading"> - <template v-if="hasGroupMatches"> - <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="group of filteredGroups" - :key="group.id" - @click="handleDropdownItemClick(group)" - > - {{ group.fullPath }} - </gl-dropdown-item> - </template> - <template v-if="hasNamespaceMatches && userNamespaceUniqueId"> - <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> - <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)"> - {{ userNamespace.fullPath }} - </gl-dropdown-item> - </template> - <gl-dropdown-text v-if="hasNoMatches">{{ __('No matches found') }}</gl-dropdown-text> + <template #search-summary-sr-only> + {{ searchSummary }} </template> - </gl-dropdown> - + </gl-collapsible-listbox> <input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" /> <input diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 2b5e2dcb301..9e71e662d70 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -70,7 +70,8 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr }; const selectedNamespaceId = () => document.querySelector('[name="project[selected_namespace_id]"]'); -const dropdownButton = () => document.querySelector('.js-group-namespace-dropdown > button'); +const dropdownButton = () => + document.querySelector('.js-group-namespace-dropdown .gl-new-dropdown-custom-toggle > button'); const namespaceButton = () => document.querySelector('.js-group-namespace-button'); const namespaceError = () => document.querySelector('.js-group-namespace-error'); diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js index df99aac6b9e..89ae2a82c6d 100644 --- a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js +++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js @@ -25,10 +25,11 @@ export const getUsers = (query, states) => { }); }; -export const getGroups = () => { +export const getGroups = ({ withProjectAccess = false }) => { return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), { params: { project_id: gon.current_project_id, + with_project_access: withProjectAccess, }, }); }; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 2dd7633e2c8..c863973cd2e 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -94,6 +94,11 @@ export default { required: false, default: true, }, + groupsWithProjectAccess: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -229,7 +234,9 @@ export default { Promise.all([ getDeployKeys(this.query), getUsers(this.query), - this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), + this.groups.length + ? Promise.resolve({ data: this.groups }) + : getGroups({ withProjectAccess: this.groupsWithProjectAccess }), ]) .then(([deployKeysResponse, usersResponse, groupsResponse]) => { this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data); diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index 7d9ad83a1c6..cb6bea76762 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,9 +1,20 @@ <script> -import { GlButton, GlModal, GlModalDirective, GlCard, GlIcon } from '@gitlab/ui'; +import { + GlButton, + GlModal, + GlModalDirective, + GlCard, + GlIcon, + GlDisclosureDropdown, + GlCollapsibleListbox, + GlFormGroup, +} from '@gitlab/ui'; import { createAlert } from '~/alert'; import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { expandSection } from '~/settings_panels'; import { scrollToElement } from '~/lib/utils/common_utils'; +import createBranchRuleMutation from './graphql/mutations/create_branch_rule.mutation.graphql'; import BranchRule from './components/branch_rule.vue'; import { I18N, PROTECTED_BRANCHES_ANCHOR, BRANCH_PROTECTION_MODAL_ID } from './constants'; @@ -14,12 +25,16 @@ export default { BranchRule, GlButton, GlModal, + GlFormGroup, GlCard, GlIcon, + GlCollapsibleListbox, + GlDisclosureDropdown, }, directives: { GlModal: GlModalDirective, }, + mixins: [glFeatureFlagsMixin()], apollo: { branchRules: { query: branchRulesQuery, @@ -38,18 +53,87 @@ export default { }, inject: { projectPath: { default: '' }, + branchRulesPath: { default: '' }, }, data() { return { branchRules: [], + branchRuleName: '', + searchQuery: '', }; }, + computed: { + addRuleItems() { + return [{ text: this.$options.i18n.branchName, action: () => this.openCreateRuleModal() }]; + }, + createRuleItems() { + return this.isWildcardAvailable ? [this.wildcardItem] : this.filteredOpenBranches; + }, + filteredOpenBranches() { + const openBranches = window.gon.open_branches.map((item) => ({ + text: item.text, + value: item.text, + })); + return openBranches.filter((item) => item.text.includes(this.searchQuery)); + }, + wildcardItem() { + return { text: this.$options.i18n.createWildcard, value: this.searchQuery }; + }, + isWildcardAvailable() { + return this.searchQuery.includes('*'); + }, + createRuleText() { + return this.branchRuleName || this.$options.i18n.branchNamePlaceholder; + }, + branchRuleEditPath() { + return `${this.branchRulesPath}?branch=${encodeURIComponent(this.branchRuleName)}`; + }, + primaryProps() { + return { + text: this.$options.i18n.createProtectedBranch, + attributes: { + variant: 'confirm', + disabled: !this.branchRuleName, + }, + }; + }, + cancelProps() { + return { + text: this.$options.i18n.createBranchRule, + }; + }, + }, methods: { showProtectedBranches() { // Protected branches section is on the same page as the branch rules section. expandSection(this.$options.protectedBranchesAnchor); scrollToElement(this.$options.protectedBranchesAnchor); }, + openCreateRuleModal() { + this.$refs[this.$options.modalId].show(); + }, + handleBranchRuleSearch(query) { + this.searchQuery = query; + }, + addBranchRule() { + this.$apollo + .mutate({ + mutation: createBranchRuleMutation, + variables: { + projectPath: this.projectPath, + name: this.branchRuleName, + }, + }) + .then(() => { + window.location.assign(this.branchRuleEditPath); + }) + .catch(() => { + createAlert({ message: this.$options.i18n.createBranchRuleError }); + }); + }, + selectBranchRuleName(branchName) { + this.branchRuleName = branchName; + }, }, modalId: BRANCH_PROTECTION_MODAL_ID, protectedBranchesAnchor: PROTECTED_BRANCHES_ANCHOR, @@ -72,7 +156,15 @@ export default { {{ branchRules.length }} </div> </div> + <gl-disclosure-dropdown + v-if="glFeatures.addBranchRule" + :toggle-text="$options.i18n.addBranchRule" + :items="addRuleItems" + size="small" + no-caret + /> <gl-button + v-else v-gl-modal="$options.modalId" size="small" class="gl-ml-3" @@ -99,6 +191,37 @@ export default { </div> </ul> <gl-modal + v-if="glFeatures.addBranchRule" + :ref="$options.modalId" + :modal-id="$options.modalId" + :title="$options.i18n.createBranchRule" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="addBranchRule" + @change="searchQuery = ''" + > + <gl-form-group + :label="$options.i18n.branchName" + :description="$options.i18n.branchNameDescription" + > + <gl-collapsible-listbox + v-model="branchRuleName" + searchable + :items="createRuleItems" + :toggle-text="createRuleText" + block + @search="handleBranchRuleSearch" + @select="selectBranchRuleName" + > + <template v-if="isWildcardAvailable" #list-item="{ item }"> + {{ item.text }} + <code>{{ searchQuery }}</code> + </template> + </gl-collapsible-listbox> + </gl-form-group> + </gl-modal> + <gl-modal + v-else :ref="$options.modalId" :modal-id="$options.modalId" :title="$options.i18n.addBranchRule" diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js index 4413d8eab4e..d16894e7d6f 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js @@ -15,6 +15,16 @@ export const I18N = { 'BranchRules|After a protected branch is created, it will show up in the list as a branch rule.', ), createProtectedBranch: s__('BranchRules|Create protected branch'), + createBranchRule: s__('BranchRules|Create branch rule'), + branchName: s__('BranchRules|Branch name or pattern'), + branchNamePlaceholder: s__('BranchRules|Select Branch or create wildcard'), + branchNameDescription: s__( + 'BranchRules|Wildcards such as *-stable or production/* are supported', + ), + createBranchRuleError: s__('BranchRules|Something went wrong while creating branch rule.'), + createBranchRuleSuccess: s__('BranchRules|Branch rule created.'), + createWildcard: s__('BranchRules|Create wildcard'), + cancel: s__('BranchRules|Cancel'), }; export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings'; diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql new file mode 100644 index 00000000000..e5fb79e0176 --- /dev/null +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql @@ -0,0 +1,8 @@ +mutation createBranchRule($projectPath: ID!, $name: String!) { + branchRuleCreate(input: { projectPath: $projectPath, name: $name }) { + errors + branchRule { + name + } + } +} diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 49c55efca7b..612f801300e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -83,6 +83,7 @@ export default class ProtectedBranchCreate { block: true, accessLevel, accessLevelsData, + groupsWithProjectAccess: true, testId, }); diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 66da3de516a..67ae33e1fc8 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -97,6 +97,7 @@ export default class ProtectedBranchEdit { block: true, accessLevel, accessLevelsData, + groupsWithProjectAccess: true, testId, }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index b3754cecce4..e1cd069fd92 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -40,6 +40,7 @@ export default class ProtectedTagCreate { hasLicense: this.hasLicense, accessLevel: ACCESS_LEVELS.CREATE, accessLevelsData: gon.create_access_levels, + groupsWithProjectAccess: true, searchEnabled: dropdownEl.dataset.filter !== undefined, testId: 'allowed-to-create-dropdown', }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.vue b/app/assets/javascripts/protected_tags/protected_tag_edit.vue index 7fe1dc9c01a..d5ec88c171c 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.vue +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.vue @@ -107,6 +107,7 @@ export default { :access-levels-data="accessLevelsData" :preselected-items="selected" :search-enabled="searchEnabled" + groups-with-project-access :block="true" @hidden="updatePermissions" /> diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 228007dd7d6..6fce9b4a129 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -55,6 +55,7 @@ export default { 'groupId', 'groupMilestonesAvailable', 'tagNotes', + 'isFetchingTagNotes', ]), ...mapGetters('editNew', ['isValid', 'formattedReleaseNotes']), showForm() { @@ -113,7 +114,7 @@ export default { return this.isExistingRelease ? __('Save changes') : __('Create release'); }, isFormSubmissionDisabled() { - return this.isUpdatingRelease || !this.isValid; + return this.isUpdatingRelease || !this.isValid || this.isFetchingTagNotes; }, milestoneComboboxExtraLinks() { return [ diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index eebaeeea286..31c7ed8482d 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,13 +1,15 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { historyPushState } from '~/lib/utils/common_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; import { convertAllReleasesGraphQLResponse } from '~/releases/util'; import { popDeleteReleaseNotification } from '~/releases/release_notification_service'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import getCiCatalogSettingsQuery from '~/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql'; import allReleasesQuery from '../graphql/queries/all_releases.query.graphql'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; @@ -18,7 +20,10 @@ import ReleasesSort from './releases_sort.vue'; export default { name: 'ReleasesIndexApp', components: { + GlAlert, GlButton, + GlLink, + GlSprintf, ReleaseBlock, ReleaseSkeletonLoader, ReleasesEmptyState, @@ -79,6 +84,20 @@ export default { }); }, }, + isCatalogResource: { + query: getCiCatalogSettingsQuery, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update({ project }) { + return project?.isCatalogResource || false; + }, + error() { + createAlert({ message: this.$options.i18n.catalogResourceQueryError }); + }, + }, }, data() { return { @@ -227,13 +246,44 @@ export default { }, }, i18n: { - newRelease: __('New release'), + alertButtonLink: helpPagePath('ci/components/index', { anchor: 'release-a-component' }), + alertButtonText: __('View the publishing guide'), + alertInfoMessage: s__( + 'CiCatalog|The CI/CD components in this project can be published in the CI/CD Catalog by creating a release. We recommend using the %{linkStart}release%{linkEnd} keyword in a CI/CD job to release new component versions for the Catalog.', + ), + alertInfoMessageLink: helpPagePath('ci/yaml/index.html', { anchor: 'release' }), + alertTitle: __('Publish the CI/CD components in this project to the CI/CD Catalog'), + catalogResourceQueryError: s__( + 'CiCatalog|There was a problem fetching the CI/CD Catalog setting.', + ), errorMessage: __('An error occurred while fetching the releases. Please try again.'), + newRelease: __('New release'), }, }; </script> <template> <div class="gl-display-flex gl-flex-direction-column gl-mt-3"> + <gl-alert + v-if="isCatalogResource" + :title="$options.i18n.alertTitle" + :primary-button-text="$options.i18n.alertButtonText" + :primary-button-link="$options.i18n.alertButtonLink" + :dismissible="false" + variant="warning" + class="mb-3 mt-2" + > + <gl-sprintf :message="$options.i18n.alertInfoMessage"> + <template #link="{ content }"> + <gl-link + :href="$options.i18n.alertInfoMessageLink" + target="_blank" + class="gl-text-decoration-none!" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> <releases-empty-state v-if="shouldRenderEmptyState" /> <div v-else class="gl-align-self-end gl-mb-3"> <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" /> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 04f3d73235b..370e920be02 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -43,6 +43,9 @@ export default { return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew; }, }, + mounted() { + this.newTagName = this.release?.tagName || ''; + }, methods: { ...mapActions('editNew', [ 'setSearching', 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 8bdfb057adc..a0d782a02a1 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -3,6 +3,7 @@ import { getTag } from '~/rest_api'; import { createAlert } from '~/alert'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import AccessorUtilities from '~/lib/utils/accessor'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { s__ } from '~/locale'; import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql'; @@ -245,7 +246,7 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => { } }; -export const fetchTagNotes = ({ commit, state }, tagName) => { +export const fetchTagNotes = ({ commit, state, dispatch }, tagName) => { commit(types.REQUEST_TAG_NOTES); return getTag(state.projectId, tagName) @@ -253,11 +254,15 @@ export const fetchTagNotes = ({ commit, state }, tagName) => { commit(types.RECEIVE_TAG_NOTES_SUCCESS, data); }) .catch((error) => { + if (error?.response?.status === HTTP_STATUS_NOT_FOUND) { + commit(types.RECEIVE_TAG_NOTES_SUCCESS, {}); + return Promise.all([dispatch('setNewTag'), dispatch('setCreating')]); + } createAlert({ message: s__('Release|Unable to fetch the tag notes.'), }); - commit(types.RECEIVE_TAG_NOTES_ERROR, error); + return commit(types.RECEIVE_TAG_NOTES_ERROR, error); }); }; @@ -326,7 +331,7 @@ export const clearDraftRelease = ({ getters }) => { } }; -export const loadDraftRelease = ({ commit, getters, state }) => { +export const loadDraftRelease = ({ commit, getters, state, dispatch }) => { try { const release = window.localStorage.getItem(getters.localStorageKey); const createFrom = window.localStorage.getItem(getters.localStorageCreateFromKey); @@ -340,6 +345,10 @@ export const loadDraftRelease = ({ commit, getters, state }) => { : state.originalReleasedAt, }); commit(types.UPDATE_CREATE_FROM, JSON.parse(createFrom)); + + if (parsedRelease.tagName) { + dispatch('fetchTagNotes', parsedRelease.tagName); + } } else { commit(types.INITIALIZE_EMPTY_RELEASE); } diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index 0b37c2b81d1..d1cde8b9029 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -170,13 +170,11 @@ export const releaseDeleteMutationVariables = (state) => ({ }, }); -export const formattedReleaseNotes = ({ - includeTagNotes, - release: { description, tagMessage }, - tagNotes, - showCreateFrom, -}) => { - const notes = showCreateFrom ? tagMessage : tagNotes; +export const formattedReleaseNotes = ( + { includeTagNotes, release: { description, tagMessage }, tagNotes }, + { isNewTag }, +) => { + const notes = isNewTag ? tagMessage : tagNotes; return includeTagNotes && notes ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n` : description; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js index 7bd3968dd93..a02949568b2 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -61,6 +61,7 @@ export default ({ updateError: null, tagNotes: '', + isFetchingTagNotes: false, includeTagNotes: false, existingRelease: null, originalReleasedAt: new Date(), diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index 4ca625bc0de..c9ac5a19697 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -11,7 +11,7 @@ import { } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; -import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; +import { contentTypeMultipartFormData } from '~/lib/utils/headers'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -159,7 +159,7 @@ export default { url, data: this.formData(), headers: { - ...ContentTypeMultipartFormData, + ...contentTypeMultipartFormData, }, }) .then((response) => { diff --git a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue index 874803a720d..eb56113a4dd 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue @@ -1,6 +1,6 @@ <script> // eslint-disable-next-line no-restricted-imports -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import eventHub from '~/super_sidebar/event_hub'; import NavItem from '~/super_sidebar/components/nav_item.vue'; @@ -15,15 +15,11 @@ export default { NavItem, }, computed: { - ...mapState(['navigation', 'urlQuery']), ...mapGetters(['navigationItems']), }, created() { eventHub.$emit('toggle-menu-header', false); - - if (this.urlQuery?.search) { - this.fetchSidebarCount(); - } + this.fetchSidebarCount(); }, methods: { ...mapActions(['fetchSidebarCount']), diff --git a/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue index c1f0bfc59f3..32327a39de0 100644 --- a/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue @@ -185,6 +185,7 @@ export default { :searching="loading" :reset-button-label="$options.i18n.reset" :toggle-aria-labelled-by="labelId" + fluid-width searchable block @shown="openDropdown" diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 211bbaf82cd..d5e275b8a19 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -1,7 +1,13 @@ import Api from '~/api'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; -import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { + visitUrl, + setUrlParams, + getBaseURL, + queryToObject, + objectToQuery, +} from '~/lib/utils/url_utility'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; @@ -135,19 +141,38 @@ export const setLabelFilterSearch = ({ commit }, { value }) => { commit(types.SET_LABEL_SEARCH_STRING, value); }; +const injectWildCardSearch = (state, link) => { + const urlObject = new URL(`${getBaseURL()}${link}`); + if (!state.urlQuery.search) { + const queryObject = queryToObject(urlObject.search); + urlObject.search = objectToQuery({ ...queryObject, search: '*' }); + } + + return urlObject.href; +}; + export const fetchSidebarCount = ({ commit, state }) => { - const promises = Object.values(state.navigation).map((navItem) => { - // active nav item has count already so we skip it - if (!navItem.active && navItem.count_link) { - return axios - .get(navItem.count_link) - .then(({ data: { count } }) => { - commit(types.RECEIVE_NAVIGATION_COUNT, { key: navItem.scope, count }); - }) - .catch((e) => logError(e)); - } - return Promise.resolve(); - }); + const items = Object.values(state.navigation) + .filter((navigationItem) => !navigationItem.active && navigationItem.count_link) + .map((navItem) => { + const navigationItem = { ...navItem }; + + if (navigationItem.count_link) { + navigationItem.count_link = injectWildCardSearch(state, navigationItem.count_link); + } + + return navigationItem; + }); + + const promises = items.map((navigationItem) => + axios + .get(navigationItem.count_link) + .then(({ data: { count } }) => { + commit(types.RECEIVE_NAVIGATION_COUNT, { key: navigationItem.scope, count }); + }) + .catch((e) => logError(e)), + ); + return Promise.all(promises); }; diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js index 94bcf81a3eb..f8ab8b4685a 100644 --- a/app/assets/javascripts/security_configuration/constants.js +++ b/app/assets/javascripts/security_configuration/constants.js @@ -7,15 +7,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { REPORT_TYPE_SAST, REPORT_TYPE_SAST_IAC, - REPORT_TYPE_DAST, - REPORT_TYPE_DAST_PROFILES, - REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, REPORT_TYPE_SECRET_DETECTION, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_CORPUS_MANAGEMENT, - REPORT_TYPE_API_FUZZING, } from '~/vue_shared/security_reports/constants'; import configureSastMutation from './graphql/configure_sast.mutation.graphql'; @@ -23,132 +15,35 @@ import configureSastIacMutation from './graphql/configure_iac.mutation.graphql'; import configureSecretDetectionMutation from './graphql/configure_secret_detection.mutation.graphql'; /** - * Translations & helpPagePaths for Security Configuration Page + * Translations for Security Configuration Page * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below. */ - export const SAST_NAME = __('Static Application Security Testing (SAST)'); export const SAST_SHORT_NAME = s__('ciReport|SAST'); -export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); -export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); -export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', { - anchor: 'configuration', -}); export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning'); export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC'); -export const SAST_IAC_DESCRIPTION = __( - 'Analyze your infrastructure as code configuration files for known vulnerabilities.', -); -export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index'); -export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/iac_scanning/index', - { - anchor: 'configuration', - }, -); export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); export const DAST_SHORT_NAME = s__('ciReport|DAST'); -export const DAST_DESCRIPTION = s__( - 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.', -); -export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); -export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { - anchor: 'enable-automatic-dast-run', -}); -export const DAST_BADGE_TEXT = __('Available on demand'); -export const DAST_BADGE_TOOLTIP = __( - 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', -); export const DAST_PROFILES_NAME = __('DAST profiles'); -export const DAST_PROFILES_DESCRIPTION = s__( - 'SecurityConfiguration|Manage profiles for use by DAST scans.', -); -export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); +export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); -export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); -export const BAS_BADGE_TOOLTIP = s__( - 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', -); -export const BAS_DESCRIPTION = s__( - 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', -); -export const BAS_HELP_PATH = helpPagePath( - 'user/application_security/breach_and_attack_simulation/index', -); export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); -export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( - 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', -); -export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( - 'user/application_security/breach_and_attack_simulation/index', - { anchor: 'extend-dynamic-application-security-testing-dast' }, -); -export const BAS_DAST_FEATURE_FLAG_NAME = s__( - 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', -); - export const SECRET_DETECTION_NAME = __('Secret Detection'); -export const SECRET_DETECTION_DESCRIPTION = __( - 'Analyze your source code and git history for secrets.', -); -export const SECRET_DETECTION_HELP_PATH = helpPagePath( - 'user/application_security/secret_detection/index', -); -export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/secret_detection/index', - { anchor: 'configuration' }, -); export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); -export const DEPENDENCY_SCANNING_DESCRIPTION = __( - 'Analyze your dependencies for known vulnerabilities.', -); -export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/dependency_scanning/index', -); -export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/dependency_scanning/index', - { anchor: 'configuration' }, -); export const CONTAINER_SCANNING_NAME = __('Container Scanning'); -export const CONTAINER_SCANNING_DESCRIPTION = __( - 'Check your Docker images for known vulnerabilities.', -); -export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/container_scanning/index', -); -export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/container_scanning/index', - { anchor: 'configuration' }, -); export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); -export const COVERAGE_FUZZING_DESCRIPTION = __( - 'Find bugs in your code with coverage-guided fuzzing.', -); -export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( - 'user/application_security/coverage_fuzzing/index', -); -export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/coverage_fuzzing/index', - { anchor: 'enable-coverage-guided-fuzz-testing' }, -); export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); -export const CORPUS_MANAGEMENT_DESCRIPTION = s__( - 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.', -); -export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus'); export const API_FUZZING_NAME = __('API Fuzzing'); -export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); -export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index'); export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); @@ -166,105 +61,6 @@ export const SCANNER_NAMES_MAP = { GENERIC: s__('ciReport|Manually added'), }; -export const securityFeatures = [ - { - name: SAST_NAME, - shortName: SAST_SHORT_NAME, - description: SAST_DESCRIPTION, - helpPath: SAST_HELP_PATH, - configurationHelpPath: SAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST, - }, - { - name: SAST_IAC_NAME, - shortName: SAST_IAC_SHORT_NAME, - description: SAST_IAC_DESCRIPTION, - helpPath: SAST_IAC_HELP_PATH, - configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST_IAC, - }, - { - badge: { - text: DAST_BADGE_TEXT, - tooltipText: DAST_BADGE_TOOLTIP, - variant: 'info', - }, - secondary: { - type: REPORT_TYPE_DAST_PROFILES, - name: DAST_PROFILES_NAME, - description: DAST_PROFILES_DESCRIPTION, - configurationText: DAST_PROFILES_CONFIG_TEXT, - }, - name: DAST_NAME, - shortName: DAST_SHORT_NAME, - description: DAST_DESCRIPTION, - helpPath: DAST_HELP_PATH, - configurationHelpPath: DAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_DAST, - anchor: 'dast', - }, - { - name: DEPENDENCY_SCANNING_NAME, - description: DEPENDENCY_SCANNING_DESCRIPTION, - helpPath: DEPENDENCY_SCANNING_HELP_PATH, - configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_DEPENDENCY_SCANNING, - anchor: 'dependency-scanning', - }, - { - name: CONTAINER_SCANNING_NAME, - description: CONTAINER_SCANNING_DESCRIPTION, - helpPath: CONTAINER_SCANNING_HELP_PATH, - configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_CONTAINER_SCANNING, - }, - { - name: SECRET_DETECTION_NAME, - description: SECRET_DETECTION_DESCRIPTION, - helpPath: SECRET_DETECTION_HELP_PATH, - configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, - type: REPORT_TYPE_SECRET_DETECTION, - }, - { - name: API_FUZZING_NAME, - description: API_FUZZING_DESCRIPTION, - helpPath: API_FUZZING_HELP_PATH, - type: REPORT_TYPE_API_FUZZING, - }, - { - name: COVERAGE_FUZZING_NAME, - description: COVERAGE_FUZZING_DESCRIPTION, - helpPath: COVERAGE_FUZZING_HELP_PATH, - configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, - type: REPORT_TYPE_COVERAGE_FUZZING, - secondary: { - type: REPORT_TYPE_CORPUS_MANAGEMENT, - name: CORPUS_MANAGEMENT_NAME, - description: CORPUS_MANAGEMENT_DESCRIPTION, - configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, - }, - }, - { - anchor: 'bas', - badge: { - alwaysDisplay: true, - text: BAS_BADGE_TEXT, - tooltipText: BAS_BADGE_TOOLTIP, - variant: 'info', - }, - description: BAS_DESCRIPTION, - name: BAS_NAME, - helpPath: BAS_HELP_PATH, - secondary: { - configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, - description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, - name: BAS_DAST_FEATURE_FLAG_NAME, - }, - shortName: BAS_SHORT_NAME, - type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, - }, -]; - export const featureToMutationMap = { [REPORT_TYPE_SAST]: { mutationId: 'configureSast', diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 8086b200891..40c82661305 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -3,7 +3,6 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; -import { securityFeatures } from './constants'; import { augmentFeatures } from './utils'; export const initSecurityConfiguration = (el) => { @@ -28,10 +27,7 @@ export const initSecurityConfiguration = (el) => { vulnerabilityTrainingDocsPath, } = el.dataset; - const { augmentedSecurityFeatures } = augmentFeatures( - securityFeatures, - features ? JSON.parse(features) : [], - ); + const { augmentedSecurityFeatures } = augmentFeatures(features ? JSON.parse(features) : []); return new Vue({ el, diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 59b49cb3820..23f86b30445 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,33 +1,41 @@ +import { isEmpty } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { SCANNER_NAMES_MAP } from '~/security_configuration/constants'; import { REPORT_TYPE_DAST } from '~/vue_shared/security_reports/constants'; /** - * This function takes in 3 arrays of objects, securityFeatures and features. - * securityFeatures are static arrays living in the constants. + * This function takes in a arrays of features. * features is dynamic and coming from the backend. - * This function builds a superset of those arrays. - * It looks for matching keys within the dynamic and the static arrays - * and will enrich the objects with the available static data. - * @param [{}] securityFeatures + * securityFeatures is nested in features and are static arrays living in backend constants + * This function takes the nested securityFeatures config and flattens it to the top level object. + * It then filters out any scanner features that lack a security config for rednering in the UI * @param [{}] features * @returns {Object} Object with enriched features from constants divided into Security and Compliance Features */ -export const augmentFeatures = (securityFeatures, features = []) => { +export const augmentFeatures = (features = []) => { const featuresByType = features.reduce((acc, feature) => { acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true }); return acc; }, {}); + /** + * Track feature configs that are used as nested elements in the UI + * so they aren't rendered at the top level as a seperate card + */ + const secondaryFeatures = []; + + // Modify each feature const augmentFeature = (feature) => { const augmented = { ...feature, ...featuresByType[feature.type], }; + // Secondary layer copies some values from the first layer if (augmented.secondary) { augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] }; + secondaryFeatures.push(feature.secondary.type); } if (augmented.type === REPORT_TYPE_DAST && !augmented.onDemandAvailable) { @@ -41,8 +49,20 @@ export const augmentFeatures = (securityFeatures, features = []) => { return augmented; }; + // Filter out any features that lack a security feature definition or is used as a nested UI element + const filterFeatures = (feature) => { + return !secondaryFeatures.includes(feature.type) && !isEmpty(feature.securityFeatures || {}); + }; + + // Convert backend provided properties to camelCase, and spread nested security config to the root + // level for UI rendering. + const flattenFeatures = (feature) => { + const flattenedFeature = convertObjectPropsToCamelCase(feature, { deep: true }); + return augmentFeature({ ...flattenedFeature, ...flattenedFeature.securityFeatures }); + }; + return { - augmentedSecurityFeatures: securityFeatures.map((feature) => augmentFeature(feature)), + augmentedSecurityFeatures: features.map(flattenFeatures).filter(filterFeatures), }; }; 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 4ff12824008..0ac6208c7d3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -6,6 +6,7 @@ import { TYPE_ALERT, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { __, n__ } from '~/locale'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings'; import { assigneesQueries } from '../../queries/constants'; import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarAssigneesRealtime from './assignees_realtime.vue'; @@ -156,6 +157,12 @@ export default { issuableAuthor() { return this.issuable?.author; }, + assigneeShortcutDescription() { + return ISSUE_MR_CHANGE_ASSIGNEE.description; + }, + assigneeShortcutKey() { + return ISSUE_MR_CHANGE_ASSIGNEE.defaultKeys[0]; + }, }, watch: { iid(_, oldIid) { @@ -246,6 +253,9 @@ export default { :loading="isSettingAssignees" :initial-loading="isAssigneesLoading" :title="assigneeText" + :edit-tooltip="`${assigneeShortcutDescription} <kbd class='flat ml-1' aria-hidden=true>${assigneeShortcutKey}</kbd>`" + :edit-aria-label="assigneeShortcutDescription" + :edit-keyshortcuts="assigneeShortcutKey" :is-dirty="isDirty" @open="showDropdown" @close="saveAssignees" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index 93e3cfba309..f3209d1a02f 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -1,17 +1,11 @@ <script> import { get } from 'lodash'; -import { - GlAlert, - GlTooltipDirective, - GlButton, - GlFormInput, - GlLink, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlAlert, GlTooltipDirective, GlButton, GlFormInput, GlLoadingIcon } from '@gitlab/ui'; import produce from 'immer'; import { createAlert } from '~/alert'; import { WORKSPACE_GROUP } from '~/issues/constants'; import { __ } from '~/locale'; +import SidebarColorPicker from '../../sidebar_color_picker.vue'; import { workspaceLabelsQueries, workspaceCreateLabelMutation } from '../../../queries/constants'; import { DEFAULT_LABEL_COLOR } from './constants'; @@ -22,8 +16,8 @@ export default { GlAlert, GlButton, GlFormInput, - GlLink, GlLoadingIcon, + SidebarColorPicker, }, directives: { GlTooltip: GlTooltipDirective, @@ -84,15 +78,6 @@ export default { }, }, methods: { - getColorCode(color) { - return Object.keys(color).pop(); - }, - getColorName(color) { - return Object.values(color).pop(); - }, - handleColorClick(color) { - this.selectedColor = this.getColorCode(color); - }, updateLabelsInCache(store, label) { const { query, dataPath } = workspaceLabelsQueries[this.workspaceType]; @@ -163,34 +148,7 @@ export default { data-testid="label-title-input" /> </div> - <div class="dropdown-content gl-px-3"> - <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3! gl-mb-0"> - <gl-link - v-for="(color, index) in suggestedColors" - :key="index" - v-gl-tooltip:tooltipcontainer - :style="{ backgroundColor: getColorCode(color) }" - :title="getColorName(color)" - @click.prevent="handleColorClick(color)" - /> - </div> - <div class="color-input-container gl-display-flex"> - <gl-form-input - v-model.trim="selectedColor" - class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8" - type="color" - :value="selectedColor" - :placeholder="__('Select color')" - data-testid="selected-color" - /> - <gl-form-input - v-model.trim="selectedColor" - class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2" - :placeholder="__('Use custom color #FF0000')" - data-testid="selected-color-text" - /> - </div> - </div> + <sidebar-color-picker v-model.trim="selectedColor" /> <div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3"> <gl-button :disabled="disableCreate" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index e0d7400f7a6..cbaa8a59813 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -1,12 +1,13 @@ <script> import { debounce } from 'lodash'; import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; -import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { mutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/alert'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE } from '~/issues/constants'; import { __ } from '~/locale'; +import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings'; import { issuableLabelsQueries } from '../../../queries/constants'; import SidebarEditableItem from '../../sidebar_editable_item.vue'; import { DEBOUNCE_DROPDOWN_DELAY, VARIANT_SIDEBAR } from './constants'; @@ -159,6 +160,12 @@ export default { isLockOnMergeSupported() { return this.issuableSupportsLockOnMerge || this.issuable?.supportsLockOnMerge; }, + labelShortcutDescription() { + return ISSUABLE_CHANGE_LABEL.description; + }, + labelShortcutKey() { + return ISSUABLE_CHANGE_LABEL.defaultKeys[0]; + }, }, apollo: { issuable: { @@ -275,7 +282,7 @@ export default { case TYPE_MERGE_REQUEST: return { ...updateVariables, - operationMode: MutationOperationMode.Replace, + operationMode: mutationOperationMode.replace, }; case TYPE_EPIC: return { @@ -336,7 +343,7 @@ export default { return { ...removeVariables, labelIds: [labelId], - operationMode: MutationOperationMode.Remove, + operationMode: mutationOperationMode.remove, }; case TYPE_EPIC: return { @@ -375,6 +382,9 @@ export default { <sidebar-editable-item ref="editable" :title="__('Labels')" + :edit-tooltip="`${labelShortcutDescription} <kbd class='flat ml-1' aria-hidden=true>${labelShortcutKey}</kbd>`" + :edit-aria-label="labelShortcutDescription" + :edit-keyshortcuts="labelShortcutKey" :loading="isLoading" :can-edit="allowLabelEdit" @open="oldIid = null" diff --git a/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue new file mode 100644 index 00000000000..95b1febb575 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue @@ -0,0 +1,75 @@ +<script> +import { GlFormInput, GlLink, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlFormInput, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + }, + computed: { + suggestedColors() { + const colorsMap = gon.suggested_label_colors; + return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); + }, + selectedColor: { + get() { + return this.value; + }, + set(color) { + this.handleColorClick(color); + }, + }, + }, + methods: { + handleColorClick(color) { + this.$emit('input', color); + }, + getColorCode(color) { + return Object.keys(color).pop(); + }, + getColorName(color) { + return Object.values(color).pop(); + }, + }, +}; +</script> +<template> + <div class="dropdown-content gl-px-3"> + <div class="suggest-colors suggest-colors-dropdown gl-mt-0!"> + <gl-link + v-for="(color, index) in suggestedColors" + :key="index" + v-gl-tooltip:tooltipcontainer + :style="{ backgroundColor: getColorCode(color) }" + :title="getColorName(color)" + @click.prevent="handleColorClick(getColorCode(color))" + /> + </div> + <div class="color-input-container gl-display-flex"> + <gl-form-input + v-model.trim="selectedColor" + class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8" + type="color" + :value="selectedColor" + :placeholder="__('Select color')" + data-testid="selected-color" + /> + <gl-form-input + v-model.trim="selectedColor" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2" + :placeholder="__('Use custom color #FF0000')" + data-testid="selected-color-text" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index ad83866ceb2..c887d5d292e 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export default { @@ -7,6 +7,9 @@ export default { unassigned: __('Unassigned'), }, components: { GlButton, GlLoadingIcon }, + directives: { + GlTooltip: GlTooltipDirective, + }, inject: { canUpdate: {}, isClassicSidebar: { @@ -58,6 +61,21 @@ export default { required: false, default: false, }, + editTooltip: { + type: String, + required: false, + default: '', + }, + editAriaLabel: { + type: String, + required: false, + default: '', + }, + editKeyshortcuts: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -68,6 +86,15 @@ export default { editButtonText() { return this.isDirty ? __('Apply') : __('Edit'); }, + editTooltipText() { + return this.isDirty ? '' : this.editTooltip; + }, + editAriaLabelText() { + return this.isDirty ? this.editButtonText : this.editAriaLabel; + }, + editKeyshortcutsText() { + return this.isDirty ? __('Escape') : this.editKeyshortcuts; + }, }, destroyed() { window.removeEventListener('click', this.collapseWhenOffClick); @@ -150,9 +177,13 @@ export default { <gl-button v-if="canUpdate && !initialLoading && canEdit" :id="buttonId" + v-gl-tooltip.viewport.html category="tertiary" size="small" class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle" + :title="editTooltipText" + :aria-label="editAriaLabelText" + :aria-keyshortcuts="editKeyshortcutsText" data-testid="edit-button" :data-track-action="tracking.event" :data-track-label="tracking.label" diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index f2257adb79c..3dfe5b6cc15 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -6,7 +6,7 @@ import { TYPE_MERGE_REQUEST } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; -import { TodoMutationTypes } from '../../constants'; +import { todoMutationTypes } from '../../constants'; import { todoQueries, todoMutations } from '../../queries/constants'; import { todoLabel } from '../../utils'; import TodoButton from './todo_button.vue'; @@ -104,9 +104,9 @@ export default { }, todoMutationType() { if (this.hasTodo) { - return TodoMutationTypes.MarkDone; + return todoMutationTypes.markDone; } - return TodoMutationTypes.Create; + return todoMutationTypes.create; }, collapsedButtonIcon() { return this.hasTodo ? 'todo-done' : 'todo-add'; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index f13f613733b..953684b5c93 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -44,9 +44,9 @@ export const IssuableAttributeState = { [IssuableAttributeType.Milestone]: 'active', }; -export const TodoMutationTypes = { - Create: 'create', - MarkDone: 'mark-done', +export const todoMutationTypes = { + create: 'create', + markDone: 'mark-done', }; export function dropdowni18nText(issuableAttribute, issuableType) { diff --git a/app/assets/javascripts/sidebar/queries/constants.js b/app/assets/javascripts/sidebar/queries/constants.js index 6bcdc01a003..dbb2f14880a 100644 --- a/app/assets/javascripts/sidebar/queries/constants.js +++ b/app/assets/javascripts/sidebar/queries/constants.js @@ -22,7 +22,7 @@ import groupLabelsQuery from '../components/labels/labels_select_widget/graphql/ import issueLabelsQuery from '../components/labels/labels_select_widget/graphql/issue_labels.query.graphql'; import mergeRequestLabelsQuery from '../components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql'; import projectLabelsQuery from '../components/labels/labels_select_widget/graphql/project_labels.query.graphql'; -import { IssuableAttributeType, TodoMutationTypes } from '../constants'; +import { IssuableAttributeType, todoMutationTypes } from '../constants'; import epicConfidentialQuery from './epic_confidential.query.graphql'; import epicDueDateQuery from './epic_due_date.query.graphql'; import epicParticipantsQuery from './epic_participants.query.graphql'; @@ -282,8 +282,8 @@ export const todoQueries = { }; export const todoMutations = { - [TodoMutationTypes.Create]: todoCreateMutation, - [TodoMutationTypes.MarkDone]: todoMarkDoneMutation, + [todoMutationTypes.create]: todoCreateMutation, + [todoMutationTypes.markDone]: todoMarkDoneMutation, }; export const escalationStatusQuery = getEscalationStatusQuery; diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index 05040218164..2be9f9e9f7d 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -116,7 +116,11 @@ export default { @pin-remove="onPinRemove(item.id, item.title)" /> </draggable> - <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> + <li + v-else + class="gl-text-secondary gl-font-sm gl-py-3 super-sidebar-empty-pinned-text" + style="margin-left: 2.5rem" + > {{ $options.i18n.emptyHint }} </li> </menu-section> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index f129d067cdc..cc558edfd68 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -30,6 +30,8 @@ export default { oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'), gitlabNext: s__('CurrentUser|Switch to GitLab Next'), startTrial: s__('CurrentUser|Start an Ultimate trial'), + enterAdminMode: s__('CurrentUser|Enter Admin Mode'), + leaveAdminMode: s__('CurrentUser|Leave Admin Mode'), signOut: __('Sign out'), }, components: { @@ -142,6 +144,27 @@ export default { }, }; }, + enterAdminModeItem() { + return { + text: this.$options.i18n.enterAdminMode, + href: this.data.admin_mode.enter_admin_mode_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'enter_admin_mode', + }, + }; + }, + leaveAdminModeItem() { + return { + text: this.$options.i18n.leaveAdminMode, + href: this.data.admin_mode.leave_admin_mode_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'leave_admin_mode', + 'data-method': 'post', + }, + }; + }, signOutGroup() { return { items: [ @@ -184,6 +207,20 @@ export default { } : {}; }, + showEnterAdminModeItem() { + return ( + this.data.admin_mode.user_is_admin && + this.data.admin_mode.admin_mode_feature_enabled && + !this.data.admin_mode.admin_mode_active + ); + }, + showLeaveAdminModeItem() { + return ( + this.data.admin_mode.user_is_admin && + this.data.admin_mode.admin_mode_feature_enabled && + this.data.admin_mode.admin_mode_active + ); + }, showNotificationDot() { return this.data.pipeline_minutes?.show_notification_dot; }, @@ -248,7 +285,11 @@ export default { @shown="onShow" > <template #toggle> - <gl-button category="tertiary" class="user-bar-dropdown-toggle btn-with-notification"> + <gl-button + category="tertiary" + class="user-bar-dropdown-toggle btn-with-notification" + data-testid="user-menu-toggle" + > <span class="gl-sr-only">{{ toggleText }}</span> <gl-avatar :size="24" @@ -320,6 +361,17 @@ export default { :item="gitlabNextItem" data-testid="gitlab-next-item" /> + + <gl-disclosure-dropdown-item + v-if="showEnterAdminModeItem" + :item="enterAdminModeItem" + data-testid="enter-admin-mode-item" + /> + <gl-disclosure-dropdown-item + v-if="showLeaveAdminModeItem" + :item="leaveAdminModeItem" + data-testid="leave-admin-mode-item" + /> </gl-disclosure-dropdown-group> <gl-disclosure-dropdown-group diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index bc416b20e80..06d324a970f 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -31,8 +31,6 @@ export const REFERRER_TTL = 24 * 60 * 60 * 1000; export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga'; -export const GITLAB_INTERNAL_EVENT_CATEGORY = 'InternalEventTracking'; - export const SERVICE_PING_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-1'; export const SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT = diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js index a6d14bfbfd8..7da6da16d6f 100644 --- a/app/assets/javascripts/tracking/internal_events.js +++ b/app/assets/javascripts/tracking/internal_events.js @@ -1,11 +1,7 @@ import API from '~/api'; import Tracking from './tracking'; -import { - GITLAB_INTERNAL_EVENT_CATEGORY, - LOAD_INTERNAL_EVENTS_SELECTOR, - SERVICE_PING_SCHEMA, -} from './constants'; +import { LOAD_INTERNAL_EVENTS_SELECTOR, SERVICE_PING_SCHEMA } from './constants'; import { Tracker } from './tracker'; import { InternalEventHandler, createInternalEventPayload } from './utils'; @@ -16,7 +12,7 @@ const InternalEvents = { */ trackEvent(event) { API.trackInternalEvent(event); - Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, { + Tracking.event(undefined, event, { context: { schema: SERVICE_PING_SCHEMA, data: { diff --git a/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue new file mode 100644 index 00000000000..ddf240fcafa --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue @@ -0,0 +1,67 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import StorageUsageStatistics from 'ee_else_ce/usage_quotas/storage/components/storage_usage_statistics.vue'; + +export default { + name: 'NamespaceStorageApp', + components: { + GlAlert, + StorageUsageStatistics, + }, + props: { + namespaceLoadingError: { + type: Boolean, + required: false, + default: false, + }, + projectsLoadingError: { + type: Boolean, + required: false, + default: false, + }, + isNamespaceStorageStatisticsLoading: { + type: Boolean, + required: false, + default: false, + }, + namespace: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + usedStorage() { + return ( + // This is the coefficient adjusted forked repo size, only used in EE + this.namespace.rootStorageStatistics?.costFactoredStorageSize ?? + // This is the actual storage size value, used in CE or when the above is disabled + this.namespace.rootStorageStatistics?.storageSize + ); + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="namespaceLoadingError || projectsLoadingError" + variant="danger" + :dismissible="false" + class="gl-mt-4" + > + {{ + s__( + 'UsageQuota|An error occured while loading the storage usage details. Please refresh the page to try again.', + ) + }} + </gl-alert> + <storage-usage-statistics + :additional-purchased-storage-size="namespace.additionalPurchasedStorageSize" + :used-storage="usedStorage" + :loading="isNamespaceStorageStatisticsLoading" + /> + + <slot name="ee-storage-app"></slot> + </div> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue new file mode 100644 index 00000000000..6e73fc0ba69 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue @@ -0,0 +1,51 @@ +<script> +import { GlCard, GlSkeletonLoader } from '@gitlab/ui'; +import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue'; + +export default { + name: 'StorageUsageOverviewCard', + components: { + GlCard, + GlSkeletonLoader, + NumberToHumanSize, + }, + props: { + usedStorage: { + type: Number, + required: false, + default: null, + }, + loading: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-card> + <gl-skeleton-loader v-if="loading" :height="64"> + <rect width="140" height="30" x="5" y="0" rx="4" /> + <rect width="240" height="10" x="5" y="40" rx="4" /> + <rect width="340" height="10" x="5" y="54" rx="4" /> + </gl-skeleton-loader> + + <div v-else> + <div class="gl-font-weight-bold" data-testid="namespace-storage-card-title"> + {{ s__('UsageQuota|Namespace storage used') }} + </div> + <div class="gl-font-size-h-display gl-font-weight-bold gl-line-height-ratio-1000 gl-my-3"> + <number-to-human-size label-class="gl-font-lg" :value="Number(usedStorage)" plain-zero /> + </div> + <hr class="gl-my-4" /> + <p> + {{ + s__( + 'UsageQuota|Namespace total storage represents the sum of storage consumed by all projects, Container Registry, and Dependency Proxy.', + ) + }} + </p> + </div> + </gl-card> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue new file mode 100644 index 00000000000..d7e550dd715 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue @@ -0,0 +1,33 @@ +<script> +import StorageUsageOverviewCard from './storage_usage_overview_card.vue'; + +export default { + name: 'StorageUsageStatistics', + components: { + StorageUsageOverviewCard, + }, + props: { + usedStorage: { + type: Number, + required: false, + default: 0, + }, + loading: { + type: Boolean, + required: true, + }, + }, +}; +</script> +<template> + <div> + <h3 data-testid="overview-subtitle">{{ s__('UsageQuota|Namespace overview') }}</h3> + <div class="gl-display-grid gl-md-grid-template-columns-2 gl-gap-5 gl-py-4"> + <storage-usage-overview-card + :used-storage="usedStorage" + :loading="loading" + data-testid="namespace-usage-total" + /> + </div> + </div> +</template> 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 a4afdee4d49..1aed3362c42 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 @@ -48,7 +48,7 @@ export default { variables() { return this.mergeRequestQueryVariables; }, - update: (data) => data.project.mergeRequest, + update: (data) => data.project?.mergeRequest || {}, }, }, components: { @@ -90,7 +90,7 @@ export default { return this.state.rebaseInProgress; }, canPushToSourceBranch() { - return this.state.userPermissions.pushToSourceBranch; + return this.state.userPermissions?.pushToSourceBranch || false; }, targetBranch() { return this.state.targetBranch; 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 1516b63f96d..af7453d84d6 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 @@ -119,7 +119,11 @@ export default { }, ) { if (mergeRequestMergeStatusUpdated) { - this.state = mergeRequestMergeStatusUpdated; + this.state = { + ...mergeRequestMergeStatusUpdated, + mergeRequestsFfOnlyEnabled: this.state.mergeRequestsFfOnlyEnabled, + onlyAllowMergeIfPipelineSucceeds: this.state.onlyAllowMergeIfPipelineSucceeds, + }; if (!this.commitMessageIsTouched) { this.commitMessage = mergeRequestMergeStatusUpdated.defaultMergeCommitMessage; @@ -277,10 +281,7 @@ export default { return __('Merge'); }, showAutoMergeHelperText() { - return ( - !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) && - this.isAutoMergeAvailable - ); + return this.isAutoMergeAvailable; }, hasPipelineMustSucceedConflict() { return !this.hasCI && this.state.onlyAllowMergeIfPipelineSucceeds; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue index 7413e2237c3..afdb9e9ff08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue @@ -72,6 +72,11 @@ export default { return this.actionButtons.length > 0; }, }, + methods: { + hasHeader() { + return Boolean(this.$scopedSlots.header || this.header || this.shouldShowHeaderActions); + }, + }, i18n: { learnMore: __('Learn more'), }, @@ -91,9 +96,9 @@ export default { :icon-name="statusIconName" /> <div class="gl-w-full gl-min-w-0"> - <div class="gl-display-flex"> + <div v-if="hasHeader()" class="gl-display-flex"> <slot name="header"> - <div v-if="header" class="gl-mb-2"> + <div class="gl-mb-2"> <strong v-safe-html="generatedHeader" class="gl-display-block"></strong ><span v-if="generatedSubheader" diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 5e9b72e13cf..db1daa3ce01 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -46,6 +46,7 @@ export default () => { gl.mrWidgetData.can_create_pipeline_in_target_project, ), commitPathTemplate: gl.mrWidgetData.commit_path_template, + canAdminVulnerability: gl.mrWidgetData.can_admin_vulnerability, dismissalDescriptions, }, ...MrWidgetOptions, diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 6ac75230d88..3419e6eb1b6 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,5 +1,5 @@ <script> -import { GlTruncate } from '@gitlab/ui'; +import { GlTruncate, GlIcon } from '@gitlab/ui'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; @@ -10,6 +10,7 @@ export default { FileHeader, FileIcon, GlTruncate, + GlIcon, }, props: { file: { @@ -141,6 +142,7 @@ export default { data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > + <gl-icon v-if="file.pinned" name="thumbtack" :size="16" /> <file-icon class="file-row-icon" :class="{ 'text-secondary': file.type === 'tree' }" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 5362ceac9ee..6549de96c98 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -67,6 +67,7 @@ export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_CONTACT = s__('Crm|Contact'); export const TOKEN_TITLE_GROUP = __('Group'); +export const TOKEN_TITLE_GROUP_INVITE = __('Group invite'); export const TOKEN_TITLE_LABEL = __('Label'); export const TOKEN_TITLE_PROJECT = __('Project'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); @@ -90,6 +91,7 @@ export const TOKEN_TYPE_AUTHOR = 'author'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_CONTACT = 'contact'; export const TOKEN_TYPE_GROUP = 'group'; +export const TOKEN_TYPE_GROUP_INVITE = 'group-invite'; export const TOKEN_TYPE_EPIC = 'epic'; // As health status gets reused between issue lists and boards // this is in the shared constants. Until we have not decoupled the EE filtered search bar diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue new file mode 100644 index 00000000000..8da5f0cca4a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue @@ -0,0 +1,158 @@ +<script> +import { + GlIcon, + GlDaterangePicker, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlOutsideDirective, +} from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; + +const CUSTOM_DATE_FILTER_TYPE = 'custom-date'; + +export default { + directives: { Outside: GlOutsideDirective }, + components: { + GlIcon, + GlDaterangePicker, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + }, + props: { + active: { + type: Boolean, + required: true, + }, + value: { + type: Object, + required: true, + }, + config: { + type: Object, + required: true, + }, + }, + data() { + return { + datePickerShown: false, + }; + }, + computed: { + isActive() { + return this.datePickerShown || this.active; + }, + computedValue() { + if (this.datePickerShown) { + return { + ...this.value, + data: '', + }; + } + return this.value; + }, + dataSegmentInputAttributes() { + const id = 'time_range_data_segment_input'; + if (this.datePickerShown) { + return { + id, + placeholder: 'YYYY-MM-DD - YYYY-MM-DD', // eslint-disable-line @gitlab/require-i18n-strings + style: 'padding-left: 23px;', + }; + } + return { + id, + }; + }, + computedConfig() { + return { + ...this.config, + options: undefined, // remove options from config to avoid default options being rendered + }; + }, + suggestions() { + const suggestions = this.config.options.map((option) => ({ + value: option.value, + text: option.title, + })); + suggestions.push({ value: CUSTOM_DATE_FILTER_TYPE, text: __('Custom') }); + return suggestions; + }, + defaultStartDate() { + return new Date(); + }, + }, + methods: { + hideDatePicker() { + this.datePickerShown = false; + }, + showDatePicker() { + this.datePickerShown = true; + }, + handleClickOutside() { + this.hideDatePicker(); + }, + handleComplete(value) { + if (value === CUSTOM_DATE_FILTER_TYPE) { + this.showDatePicker(); + } + }, + selectValue(inputValue, submitValue) { + let value = inputValue; + if (typeof inputValue === 'object' && inputValue.startDate && inputValue.endDate) { + const { startDate, endDate } = inputValue; + const format = 'yyyy-mm-dd'; + value = `${formatDate(startDate, format)} - ${formatDate(endDate, format)}`; + } + submitValue(value); + this.hideDatePicker(); + }, + }, + CUSTOM_DATE_FILTER_TYPE: 'custom-date', +}; +</script> + +<template> + <gl-filtered-search-token + :data-segment-input-attributes="dataSegmentInputAttributes" + v-bind="{ ...$props, ...$attrs }" + :view-only="datePickerShown" + :active="isActive" + :value="computedValue" + :config="computedConfig" + v-on="$listeners" + @complete="handleComplete" + > + <template #before-data-segment-input="{ submitValue }"> + <gl-icon + v-if="datePickerShown" + class="gl-text-gray-500" + name="calendar" + style="margin-left: 5px; margin-right: -15px; z-index: 1; pointer-events: none" + /> + <div + v-if="datePickerShown" + v-outside="handleClickOutside" + class="gl-absolute gl-z-index-1 gl-bg-white gl-border-1 gl-border-gray-200 gl-my-2 gl-p-4 gl-rounded-base gl-shadow-x0-y2-b4-s0 gl-top-full" + > + <gl-daterange-picker + start-opened + :default-start-date="defaultStartDate" + @input="selectValue($event, submitValue)" + /> + </div> + </template> + + <template #suggestions> + <div v-if="!datePickerShown"> + <gl-filtered-search-suggestion + v-for="token in suggestions" + :key="token.value" + :value="token.value" + > + {{ token.text }} + </gl-filtered-search-suggestion> + </div> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_countdown.vue b/app/assets/javascripts/vue_shared/components/gl_countdown.vue index 1769a283d8c..0e8ecc36f37 100644 --- a/app/assets/javascripts/vue_shared/components/gl_countdown.vue +++ b/app/assets/javascripts/vue_shared/components/gl_countdown.vue @@ -30,6 +30,11 @@ export default { mounted() { const updateRemainingTime = () => { const remainingMilliseconds = calculateRemainingMilliseconds(this.endDateString); + + if (remainingMilliseconds < 1) { + this.$emit('timer-expired'); + } + this.remainingTime = formatTime(remainingMilliseconds); }; diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue index a375a167c68..e84a810c0b0 100644 --- a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue @@ -13,6 +13,11 @@ export default { required: false, default: false, }, + listItemClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, }; </script> @@ -24,6 +29,7 @@ export default { :key="group.id" :group="group" :show-group-icon="showGroupIcon" + :class="listItemClass" @delete="$emit('delete', $event)" /> </ul> diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue index ca1e7400f2d..ace3846723c 100644 --- a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue @@ -1,10 +1,9 @@ <script> -import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui'; +import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText, GlBadge } from '@gitlab/ui'; import uniqueId from 'lodash/uniqueId'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants'; import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; -import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { __ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -20,15 +19,12 @@ export default { showMore: __('Show more'), showLess: __('Show less'), }, - avatarSize: { default: 32, md: 48 }, - safeHtmlConfig: { - ADD_TAGS: ['gl-emoji'], - }, + truncateTextToggleButtonProps: { class: 'gl-font-sm!' }, components: { GlAvatarLabeled, GlIcon, - UserAccessRoleBadge, GlTruncateText, + GlBadge, ListActions, DangerConfirmModal, }, @@ -76,7 +72,7 @@ export default { return this.group.parent ? 'subgroup' : 'group'; }, statsPadding() { - return this.showGroupIcon ? 'gl-pl-11' : 'gl-pl-8'; + return this.showGroupIcon ? 'gl-pl-12' : 'gl-pl-10'; }, descendantGroupsCount() { return numberToMetricPrefix(this.group.descendantGroupsCount); @@ -113,21 +109,22 @@ export default { </script> <template> - <li class="groups-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start"> - <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1"> + <li class="groups-list-item gl-py-5 gl-border-b gl-display-flex"> + <div class="gl-md-display-flex gl-flex-grow-1"> <div class="gl-display-flex gl-flex-grow-1"> - <gl-icon + <div v-if="showGroupIcon" - class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" - :name="groupIconName" - /> + class="gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-h-9 gl-mr-3" + > + <gl-icon class="gl-text-secondary" :name="groupIconName" /> + </div> <gl-avatar-labeled :entity-id="group.id" :entity-name="group.fullName" :label="group.fullName" :label-link="group.webUrl" shape="rect" - :size="$options.avatarSize" + :size="48" > <template #meta> <div class="gl-px-2"> @@ -141,9 +138,9 @@ export default { /> </div> <div class="gl-px-2"> - <user-access-role-badge v-if="shouldShowAccessLevel">{{ + <gl-badge v-if="shouldShowAccessLevel" size="sm" class="gl-display-block">{{ accessLevelLabel - }}</user-access-role-badge> + }}</gl-badge> </div> </div> </div> @@ -154,57 +151,57 @@ export default { :mobile-lines="2" :show-more-text="$options.i18n.showMore" :show-less-text="$options.i18n.showLess" - class="gl-mt-2" + :toggle-button-props="$options.truncateTextToggleButtonProps" + class="gl-mt-2 gl-max-w-88" > <div - v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml" - class="gl-font-sm md" + v-safe-html="group.descriptionHtml" + class="gl-font-sm gl-text-secondary md" data-testid="group-description" ></div> </gl-truncate-text> </gl-avatar-labeled> </div> <div - class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3" + class="gl-display-flex gl-align-items-center gl-gap-x-3 gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3 gl-md-h-9" :class="statsPadding" > - <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> - <div - v-gl-tooltip="$options.i18n.subgroups" - :aria-label="$options.i18n.subgroups" - class="gl-text-secondary" - data-testid="subgroups-count" - > - <gl-icon name="subgroup" /> - <span>{{ descendantGroupsCount }}</span> - </div> - <div - v-gl-tooltip="$options.i18n.projects" - :aria-label="$options.i18n.projects" - class="gl-text-secondary" - data-testid="projects-count" - > - <gl-icon name="project" /> - <span>{{ projectsCount }}</span> - </div> - <div - v-gl-tooltip="$options.i18n.directMembers" - :aria-label="$options.i18n.directMembers" - class="gl-text-secondary" - data-testid="members-count" - > - <gl-icon name="users" /> - <span>{{ groupMembersCount }}</span> - </div> + <div + v-gl-tooltip="$options.i18n.subgroups" + :aria-label="$options.i18n.subgroups" + class="gl-text-secondary" + data-testid="subgroups-count" + > + <gl-icon name="subgroup" /> + <span>{{ descendantGroupsCount }}</span> + </div> + <div + v-gl-tooltip="$options.i18n.projects" + :aria-label="$options.i18n.projects" + class="gl-text-secondary" + data-testid="projects-count" + > + <gl-icon name="project" /> + <span>{{ projectsCount }}</span> + </div> + <div + v-gl-tooltip="$options.i18n.directMembers" + :aria-label="$options.i18n.directMembers" + class="gl-text-secondary" + data-testid="members-count" + > + <gl-icon name="users" /> + <span>{{ groupMembersCount }}</span> </div> </div> </div> - <list-actions - v-if="hasActions" - class="gl-ml-3 gl-md-align-self-center" - :actions="actions" - :available-actions="group.availableActions" - /> + <div class="gl-display-flex gl-align-items-center gl-h-9 gl-ml-3"> + <list-actions + v-if="hasActions" + :actions="actions" + :available-actions="group.availableActions" + /> + </div> <danger-confirm-modal v-if="hasActionDelete" diff --git a/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js new file mode 100644 index 00000000000..0576e9796fc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js @@ -0,0 +1,35 @@ +import HelpPageLink from './help_page_link.vue'; + +export default { + component: HelpPageLink, + title: 'vue_shared/help_page_link', +}; + +const Template = (args, { argTypes }) => ({ + components: { HelpPageLink }, + props: Object.keys(argTypes), + template: '<help-page-link v-bind="$props">link</help-page-link>', +}); + +export const Default = Template.bind({}); +Default.args = { + href: 'user/usage_quotas', +}; + +export const LinkWithAnAnchor = Template.bind({}); +LinkWithAnAnchor.args = { + ...Default.args, + anchor: 'namespace-storage-limit', +}; + +export const LinkWithAnchorInPath = Template.bind({}); +LinkWithAnchorInPath.args = { + ...Default.args, + href: 'user/usage_quotas#namespace-storage-limit', +}; + +export const CustomAttributes = Template.bind({}); +CustomAttributes.args = { + ...Default.args, + target: '_blank', +}; diff --git a/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue new file mode 100644 index 00000000000..11b269855ad --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue @@ -0,0 +1,44 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +/** + * Component to link to GitLab docs. + * + * @example + * <help-page-link href="user/usage_quotas"> + * Usage Quotas help. + * <help-page-link> + */ +export default { + name: 'HelpPageLink', + components: { + GlLink, + }, + props: { + href: { + type: String, + required: true, + }, + anchor: { + type: String, + required: false, + default: null, + }, + }, + computed: { + compiledHref() { + return helpPagePath(this.href, { anchor: this.anchor }); + }, + attributes() { + const { href, anchor, ...attrs } = this.$attrs; + return attrs; + }, + }, +}; +</script> +<template> + <gl-link v-bind="attributes" :href="compiledHref" v-on="$listeners"> + <slot></slot> + </gl-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index cffd8471d18..525f684df86 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -382,6 +382,7 @@ export default { @click="handleQuote" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('code')" v-show="!previewMarkdown" tag="`" tag-block="```" diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 73c030b23dc..8fedc816502 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -55,7 +55,7 @@ export default { uploadsPath: { type: String, required: false, - default: () => window.uploads_path, + default: () => window.uploads_path || '', }, enableContentEditor: { type: Boolean, @@ -190,9 +190,9 @@ export default { renderMarkdown(markdown) { const url = setUrlParams( { render_quick_actions: this.supportsQuickActions }, - joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath), + joinPaths(window.location.origin, this.renderMarkdownPath), ); - return axios.post(url, { text: markdown }).then(({ data }) => data.body); + return axios.post(url, { text: markdown }).then(({ data }) => data.body || data.html); }, onEditingModeChange(editingMode) { this.editingMode = editingMode; diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 6a5884e4857..8f35fbdff6e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -160,13 +160,12 @@ export default { v-if="canSeeDescriptionVersion || note.outdated_line_change_path" #extra-controls > - · <gl-button v-if="canSeeDescriptionVersion" variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" - class="gl-vertical-align-text-bottom gl-font-sm!" + class="gl-vertical-align-text-bottom gl-font-sm! gl-ml-3" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > @@ -190,8 +189,11 @@ export default { class="note-text md" ></div> <div v-if="hasMoreCommits" class="flex-list"> - <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> - <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" /> + <div + class="system-note-commit-list-toggler flex-row gl-pl-4 gl-pt-3" + @click="expanded = !expanded" + > + <gl-icon :name="toggleIcon" :size="12" class="gl-mr-2" /> <span>{{ __('Toggle commit list') }}</span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue index 3a4da54c84c..d06024638fa 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue @@ -35,6 +35,11 @@ export default { required: false, default: false, }, + listItemClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, }; </script> @@ -46,6 +51,7 @@ export default { :key="project.id" :project="project" :show-project-icon="showProjectIcon" + :class="listItemClass" @delete="$emit('delete', $event)" /> </ul> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index ce75e305473..3a077d09e40 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -7,13 +7,13 @@ import { GlTooltipDirective, GlPopover, GlSprintf, + GlTruncateText, } from '@gitlab/ui'; import uniqueId from 'lodash/uniqueId'; import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants'; import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; import { FEATURABLE_ENABLED } from '~/featurable/constants'; -import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { __ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import { truncate } from '~/lib/utils/text_utility'; @@ -37,19 +37,18 @@ export default { moreTopics: __('More topics'), updated: __('Updated'), actions: __('Actions'), + showMore: __('Show more'), + showLess: __('Show less'), }, - avatarSize: { default: 32, md: 48 }, - safeHtmlConfig: { - ADD_TAGS: ['gl-emoji'], - }, + truncateTextToggleButtonProps: { class: 'gl-font-sm!' }, components: { GlAvatarLabeled, GlIcon, - UserAccessRoleBadge, GlLink, GlBadge, GlPopover, GlSprintf, + GlTruncateText, TimeAgoTooltip, DeleteModal, ListActions, @@ -203,21 +202,22 @@ export default { </script> <template> - <li class="projects-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start"> - <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1"> + <li class="projects-list-item gl-py-5 gl-border-b gl-display-flex"> + <div class="gl-md-display-flex gl-flex-grow-1"> <div class="gl-display-flex gl-flex-grow-1"> - <gl-icon + <div v-if="showProjectIcon" - class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" - name="project" - /> + class="gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-h-9 gl-mr-3" + > + <gl-icon class="gl-text-secondary" name="project" /> + </div> <gl-avatar-labeled :entity-id="project.id" :entity-name="project.name" :label="project.name" :label-link="project.webUrl" shape="rect" - :size="$options.avatarSize" + :size="48" > <template #meta> <div class="gl-px-2"> @@ -231,33 +231,46 @@ export default { /> </div> <div class="gl-px-2"> - <user-access-role-badge v-if="shouldShowAccessLevel">{{ + <gl-badge v-if="shouldShowAccessLevel" size="sm" class="gl-display-block">{{ accessLevelLabel - }}</user-access-role-badge> + }}</gl-badge> </div> </div> </div> </template> - <div + <gl-truncate-text v-if="project.descriptionHtml" - v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml" - class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md" - data-testid="project-description" - ></div> + :lines="2" + :mobile-lines="2" + :show-more-text="$options.i18n.showMore" + :show-less-text="$options.i18n.showLess" + :toggle-button-props="$options.truncateTextToggleButtonProps" + class="gl-mt-2 gl-max-w-88" + > + <div + v-safe-html="project.descriptionHtml" + class="gl-font-sm gl-text-secondary md" + data-testid="project-description" + ></div> + </gl-truncate-text> <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> <div class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2" > - <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span> + <span class="gl-p-2 gl-font-sm gl-text-secondary">{{ $options.i18n.topics }}:</span> <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + <gl-badge + v-gl-tooltip="topicTooltipTitle(topic)" + size="sm" + :href="topicPath(topic)" + > {{ topicTitle(topic) }} </gl-badge> </div> <template v-if="popoverTopics.length"> <div :id="topicsPopoverTarget" - class="gl-p-2 gl-text-secondary" + class="gl-p-2 gl-font-sm gl-text-secondary" role="button" tabindex="0" > @@ -272,7 +285,11 @@ export default { :key="topic" class="gl-p-2 gl-display-inline-block" > - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + <gl-badge + v-gl-tooltip="topicTooltipTitle(topic)" + size="sm" + :href="topicPath(topic)" + > {{ topicTitle(topic) }} </gl-badge> </div> @@ -285,9 +302,9 @@ export default { </div> <div class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0" - :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'" + :class="showProjectIcon ? 'gl-pl-12' : 'gl-pl-10'" > - <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> + <div class="gl-display-flex gl-align-items-center gl-gap-x-3 gl-md-h-9"> <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge> @@ -323,19 +340,20 @@ export default { </div> <div v-if="project.updatedAt" - class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3" + class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3 gl-md-mt-0" > <span>{{ $options.i18n.updated }}</span> <time-ago-tooltip :time="project.updatedAt" /> </div> </div> </div> - <list-actions - v-if="hasActions" - class="gl-ml-3 gl-md-align-self-center" - :actions="actions" - :available-actions="project.availableActions" - /> + <div class="gl-display-flex gl-align-items-center gl-h-9 gl-ml-3"> + <list-actions + v-if="hasActions" + :actions="actions" + :available-actions="project.availableActions" + /> + </div> <delete-modal v-if="hasActionDelete" diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue deleted file mode 100644 index 06852f511bf..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue +++ /dev/null @@ -1,27 +0,0 @@ -<script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerInstructionsModal from './runner_instructions_modal.vue'; - -export default { - components: { - GlButton, - RunnerInstructionsModal, - }, - directives: { - GlModal: GlModalDirective, - }, - modalId: 'runner-instructions-modal', - i18n: { - buttonText: s__('Runners|Show runner installation instructions'), - }, -}; -</script> -<template> - <div> - <gl-button v-gl-modal="$options.modalId" class="gl-mt-4" data-testid="show-modal-button"> - {{ $options.i18n.buttonText }} - </gl-button> - <runner-instructions-modal :modal-id="$options.modalId" /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue index e0e8200580a..fff70d003b7 100644 --- a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue +++ b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue @@ -43,6 +43,7 @@ export default { :key="opt.value" :disabled="!!opt.disabled" :selected="value === opt.value" + v-bind="opt.props" @click="$emit('input', opt.value)" > <slot name="button-content" v-bind="opt">{{ opt.text }}</slot> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql index c497224cde3..0067a0ca0d9 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql +++ b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql @@ -1,10 +1,16 @@ #import "~/graphql_shared/fragments/author.fragment.graphql" -query getBlameData($fullPath: ID!, $filePath: String!, $fromLine: Int, $toLine: Int) { +query getBlameData( + $fullPath: ID! + $filePath: String! + $fromLine: Int + $toLine: Int + $ref: String! +) { project(fullPath: $fullPath) { id repository { - blobs(paths: [$filePath]) { + blobs(ref: $ref, paths: [$filePath]) { nodes { id blame(fromLine: $fromLine, toLine: $toLine) { diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue index bc46f11ab2d..e62f38d9ca3 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -118,6 +118,7 @@ export default { const { data } = await this.$apollo.query({ query: blameDataQuery, variables: { + ref: this.currentRef, fullPath: this.projectPath, filePath: this.blob.path, fromLine: chunk.startingFrom + 1, diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue index 6764ad4ce73..d4d241b12ec 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue @@ -110,6 +110,7 @@ export default { :no-results-text="$options.translations.noResultsText" :selected="tzValue" block + fluid-width searchable @search="setSearchTerm" @select="selectTimezone" diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue new file mode 100644 index 00000000000..944a48df279 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue @@ -0,0 +1,112 @@ +<script> +import { GlButton, GlAvatar, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { __ } from '~/locale'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; + +export default { + i18n: { + uploadText: __('Drop or %{linkStart}upload%{linkEnd} an avatar.'), + maxFileSize: __('Max file size is 200 KiB.'), + removeAvatar: __('Remove avatar'), + }, + AVATAR_SHAPE_OPTION_RECT, + components: { GlButton, GlAvatar, GlSprintf, GlTruncate, UploadDropzone }, + props: { + entity: { + type: Object, + required: false, + default: () => ({}), + }, + value: { + type: [String, File], + required: false, + default: '', + }, + label: { + type: String, + required: true, + }, + }, + data() { + return { + avatarObjectUrl: null, + }; + }, + computed: { + avatarSrc() { + if (this.avatarObjectUrl) { + return this.avatarObjectUrl; + } + + if (this.isValueAFile) { + return null; + } + + return this.value; + }, + isValueAFile() { + return this.value instanceof File; + }, + }, + watch: { + value(newValue) { + this.revokeAvatarObjectUrl(); + + if (newValue instanceof File) { + this.avatarObjectUrl = URL.createObjectURL(newValue); + } else { + this.avatarObjectUrl = null; + } + }, + }, + beforeDestroy() { + this.revokeAvatarObjectUrl(); + }, + methods: { + revokeAvatarObjectUrl() { + if (this.avatarObjectUrl === null) { + return; + } + + URL.revokeObjectURL(this.avatarObjectUrl); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-column-gap-5"> + <gl-avatar + :entity-id="entity.id || null" + :entity-name="entity.name || 'organization'" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="96" + :src="avatarSrc" + /> + <div class="gl-min-w-0"> + <p class="gl-font-weight-bold gl-line-height-1 gl-mb-3"> + {{ label }} + </p> + <div v-if="value" class="gl-display-flex gl-align-items-center gl-column-gap-3"> + <gl-button @click="$emit('input', null)">{{ $options.i18n.removeAvatar }}</gl-button> + <gl-truncate + v-if="isValueAFile" + class="gl-text-secondary gl-max-w-48 gl-min-w-0" + position="middle" + :text="value.name" + /> + </div> + <upload-dropzone v-else single-file-selection @change="$emit('input', $event)"> + <template #upload-text> + <gl-sprintf :message="$options.i18n.uploadText"> + <template #link="{ content }"> + <span class="gl-link gl-hover-text-decoration-underline">{{ content }}</span> + </template> + </gl-sprintf> + </template> + </upload-dropzone> + <p class="gl-mb-0 gl-mt-3 gl-text-secondary">{{ $options.i18n.maxFileSize }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 863c43b0e55..a113a5ccc66 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 @@ -252,6 +252,7 @@ export default { selected.push(user); this.$emit('input', selected); } + this.clearAndFocusSearch(); }, unassign() { this.$emit('input', []); @@ -260,6 +261,7 @@ export default { unselect(name) { const selected = this.value.filter((user) => user.username !== name); this.$emit('input', selected); + this.clearAndFocusSearch(); }, focusSearch() { this.$refs.search.focusInput(); @@ -296,6 +298,10 @@ export default { } return user.canMerge ? '' : __('Cannot merge'); }, + clearAndFocusSearch() { + this.search = ''; + this.focusSearch(); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue index 5d86f90880d..5f197066cb5 100644 --- a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { truncate } from '~/lib/utils/text_utility'; import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from './constants'; @@ -12,6 +13,7 @@ export default { GlBadge, GlIcon, }, + mixins: [glFeatureFlagsMixin()], props: { user: { type: Object, @@ -65,7 +67,12 @@ export default { <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> <gl-icon v-gl-tooltip="userNoteShort" name="document" /> </div> - <div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1"> + <div + v-for="(badge, idx) in user.badges" + :key="idx" + class="gl-p-1" + :class="{ 'gl-pb-0': glFeatures.simplifiedBadges }" + > <gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{ badge.text }}</gl-badge> 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 3514a9c2d5d..1a3e5208508 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -11,6 +11,7 @@ import { s__, __ } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; +import { GO_TO_PROJECT_WEBIDE } from '~/behaviors/shortcuts/keybindings'; import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants'; export const i18n = { @@ -197,6 +198,9 @@ export default { ...handleOptions, }; }, + webIdeActionShortcutKey() { + return GO_TO_PROJECT_WEBIDE.defaultKeys[0]; + }, webIdeActionText() { if (this.webIdeText) { return this.webIdeText; @@ -234,6 +238,7 @@ export default { key: KEY_WEB_IDE, text: this.webIdeActionText, secondaryText: this.$options.i18n.webIdeText, + shortcut: this.webIdeActionShortcutKey, tracking: { action: TRACKING_ACTION_NAME, label: 'web_ide', @@ -357,9 +362,14 @@ export default { > <template #list-item> <div class="gl-display-flex gl-flex-direction-column"> - <span data-testid="action-primary-text" class="gl-font-weight-bold gl-mb-2">{{ - action.text - }}</span> + <span + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-mb-2" + > + <span data-testid="action-primary-text" class="gl-font-weight-bold">{{ + action.text + }}</span> + <kbd v-if="action.shortcut" class="flat">{{ action.shortcut }}</kbd> + </span> <span data-testid="action-secondary-text" class="gl-font-sm gl-text-secondary"> {{ action.secondaryText }} </span> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index bb36df0a778..de2f7887237 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -1,19 +1,27 @@ <script> -import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; - +import { + GlBadge, + GlLink, + GlIcon, + GlLabel, + GlFormCheckbox, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { STATUS_CLOSED } from '~/issues/constants'; +import { STATUS_OPEN, STATUS_CLOSED } from '~/issues/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; -import { STATE_CLOSED } from '~/work_items/constants'; +import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils'; export default { components: { + GlBadge, GlLink, GlIcon, GlLabel, @@ -120,8 +128,11 @@ export default { createdAt() { return this.timeFormatted(this.issuable.createdAt); }, + isNotOpen() { + return ![STATUS_OPEN, STATE_OPEN].includes(this.issuable.state); + }, isClosed() { - return this.issuable.state === STATUS_CLOSED || this.issuable.state === STATE_CLOSED; + return [STATUS_CLOSED, STATE_CLOSED].includes(this.issuable.state); }, timestamp() { return this.isClosed && this.issuable.closedAt @@ -351,8 +362,12 @@ export default { </div> <div class="issuable-meta"> <ul v-if="showIssuableMeta" class="controls"> - <li v-if="hasSlotContents('status')"> - <slot name="status"></slot> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> + <li v-if="$slots.status" data-testid="issuable-status"> + <gl-badge v-if="isNotOpen" size="sm" variant="info"> + <slot name="status"></slot> + </gl-badge> + <slot v-else name="status"></slot> </li> <li v-if="assignees.length"> <issuable-assignees diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index ad908a674d3..dd203283b3b 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -406,9 +406,7 @@ export default { <slot name="timeframe" :issuable="issuable"></slot> </template> <template #status> - <gl-badge size="sm" variant="info"> - <slot name="status" :issuable="issuable"></slot> - </gl-badge> + <slot name="status" :issuable="issuable"></slot> </template> <template #statistics> <slot name="statistics" :issuable="issuable"></slot> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index 1e6bd9ff1ac..9ede327b2f1 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -235,7 +235,9 @@ export default { :work-item-id="workItemId" :work-item-state="workItemState" :work-item-type="workItemType" + :has-comment="!!commentText.length" can-update + @submit-comment="$emit('submitForm', { commentText, isNoteInternal })" @error="$emit('error', $event)" /> <gl-button diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue index 503328f7b03..78afb9a04ef 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -131,7 +131,7 @@ export default { class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base gl-gap-3" data-testid="links-child" > - <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> + <div class="gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> <div class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" > diff --git a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue new file mode 100644 index 00000000000..53149f62893 --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue @@ -0,0 +1,194 @@ +<script> +import { GlButton, GlForm, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; + +import { s__, __ } from '~/locale'; + +export default { + i18n: { + none: s__('WorkItem|None'), + noMatchingResults: s__('WorkItem|No matching results'), + editButtonLabel: __('Edit'), + applyButtonLabel: __('Apply'), + resetButtonText: __('Clear'), + }, + components: { + GlButton, + GlLoadingIcon, + GlForm, + GlCollapsibleListbox, + }, + props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + dropdownLabel: { + type: String, + required: true, + }, + dropdownName: { + type: String, + required: true, + }, + listItems: { + type: Array, + required: false, + default: () => [], + }, + itemValue: { + type: Object, + required: false, + default: null, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + updateInProgress: { + type: Boolean, + required: false, + default: false, + }, + resetButtonLabel: { + type: String, + required: false, + default: '', + }, + headerText: { + type: String, + required: false, + default: '', + }, + toggleDropdownText: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isEditing: false, + localSelectedItem: this.itemValue?.id, + }; + }, + computed: { + hasValue() { + return this.itemValue != null || !isEmpty(this.item); + }, + listboxText() { + return ( + this.listItems.find(({ value }) => this.localSelectedItem === value)?.text || + this.itemValue?.title || + this.$options.i18n.none + ); + }, + inputId() { + return `work-item-dropdown-listbox-value-${this.dropdownName}`; + }, + toggleText() { + return this.toggleDropdownText || this.listboxText; + }, + resetButton() { + return this.resetButtonLabel || this.$options.i18n.resetButtonText; + }, + }, + watch: { + itemValue: { + handler(newVal) { + if (!this.isEditing) { + this.localSelectedItem = newVal?.id; + } + }, + }, + }, + methods: { + setSearchKey(value) { + this.$emit('searchStarted', value); + }, + handleItemClick(item) { + this.localSelectedItem = item; + this.$emit('updateValue', item); + }, + onListboxShown() { + this.$emit('dropdownShown'); + }, + onListboxHide() { + this.isEditing = false; + }, + unassignValue() { + this.localSelectedItem = null; + this.isEditing = false; + this.$emit('updateValue', null); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-align-items-center gl-gap-3"> + <!-- hide header when editing, since we then have a form label. Keep it reachable for screenreader nav --> + <h3 :class="{ 'gl-sr-only': isEditing }" class="gl-mb-0! gl-heading-scale-5"> + {{ dropdownLabel }} + </h3> + <gl-loading-icon v-if="updateInProgress" /> + <gl-button + v-if="canUpdate && !isEditing" + data-testid="edit-button" + category="tertiary" + size="small" + class="gl-ml-auto gl-mr-2" + :disabled="updateInProgress" + @click="isEditing = true" + >{{ $options.i18n.editButtonLabel }}</gl-button + > + </div> + <gl-form v-if="isEditing"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <label :for="inputId" class="gl-mb-0">{{ dropdownLabel }}</label> + <gl-button + data-testid="apply-button" + category="tertiary" + size="small" + class="gl-mr-2" + :disabled="updateInProgress" + @click="isEditing = false" + >{{ $options.i18n.applyButtonLabel }}</gl-button + > + </div> + <gl-collapsible-listbox + :id="inputId" + block + searchable + start-opened + is-check-centered + fluid-width + :searching="loading" + :header-text="headerText" + :toggle-text="toggleText" + :no-results-text="$options.i18n.noMatchingResults" + :items="listItems" + :selected="localSelectedItem" + :reset-button-label="resetButton" + @reset="unassignValue" + @search="setSearchKey" + @select="handleItemClick" + @shown="onListboxShown" + @hidden="onListboxHide" + > + <template #list-item="{ item }"> + <slot name="list-item" :item="item">{{ item.text }}</slot> + </template> + </gl-collapsible-listbox> + </gl-form> + <slot v-else-if="hasValue" name="readonly"> + {{ listboxText }} + </slot> + <div v-else class="gl-text-secondary"> + {{ $options.i18n.none }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue index c122db6c902..719507d1341 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -1,21 +1,25 @@ <script> import { GlTokenSelector, GlAlert } from '@gitlab/ui'; import { debounce } from 'lodash'; - import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isNumeric } from '~/lib/utils/number_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import { isSafeURL } from '~/lib/utils/url_utility'; + import { highlighter } from 'ee_else_ce/gfm_auto_complete'; import groupWorkItemsQuery from '../../graphql/group_work_items.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import workItemsByReferencesQuery from '../../graphql/work_items_by_references.query.graphql'; import { WORK_ITEMS_TYPE_MAP, I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, I18N_WORK_ITEM_SEARCH_ERROR, + I18N_WORK_ITEM_NO_MATCHES_FOUND, sprintfWorkItem, } from '../../constants'; +import { isReference } from '../../utils'; export default { components: { @@ -55,41 +59,63 @@ export default { }, }, apollo: { - availableWorkItems: { + workspaceWorkItems: { query() { return this.isGroup ? groupWorkItemsQuery : projectWorkItemsQuery; }, variables() { - return { - fullPath: this.fullPath, - searchTerm: '', - types: this.childrenType ? [this.childrenType] : [], - isNumber: false, - }; + return this.queryVariables; }, skip() { - return !this.searchStarted; + return !this.searchStarted || this.isSearchingByReference; }, update(data) { return [ ...this.filterItems(data.workspace.workItemsByIid?.nodes), - ...this.filterItems(data.workspace.workItems.nodes), + ...this.filterItems(data.workspace.workItems?.nodes), ]; }, error() { this.error = sprintfWorkItem(I18N_WORK_ITEM_SEARCH_ERROR, this.childrenTypeName); }, }, + workItemsByReference: { + query: workItemsByReferencesQuery, + variables() { + return { + contextNamespacePath: this.fullPath, + refs: [this.searchTerm], + }; + }, + skip() { + return !this.isSearchingByReference; + }, + update(data) { + return data.workItemsByReference.nodes; + }, + error() { + this.error = sprintfWorkItem(I18N_WORK_ITEM_SEARCH_ERROR, this.childrenTypeName); + }, + }, }, data() { return { - availableWorkItems: [], - query: '', + workspaceWorkItems: [], + searchTerm: '', searchStarted: false, error: '', + textInputAttrs: { + class: 'gl-min-w-fit-content!', + }, }; }, computed: { + availableWorkItems() { + return this.isSearchingByReference ? this.workItemsByReference : this.workspaceWorkItems; + }, + isSearchingByReference() { + return isReference(this.searchTerm) || isSafeURL(this.searchTerm); + }, workItemsToAdd: { get() { return this.value; @@ -99,10 +125,10 @@ export default { }, }, isLoading() { - return this.$apollo.queries.availableWorkItems.loading; - }, - addInputPlaceholder() { - return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); + return ( + this.$apollo.queries.workspaceWorkItems.loading || + this.$apollo.queries.workItemsByReference.loading + ); }, childrenTypeName() { return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; @@ -110,31 +136,25 @@ export default { tokenSelectorContainerClass() { return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; }, + queryVariables() { + return { + fullPath: this.fullPath, + searchTerm: this.searchTerm, + types: this.childrenType ? [this.childrenType] : [], + in: this.searchTerm ? 'TITLE' : undefined, + iid: isNumeric(this.searchTerm) ? this.searchTerm : null, + searchByIid: isNumeric(this.searchTerm), + searchByText: true, + }; + }, }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { getIdFromGraphQLId, - setSearchKey(value) { - this.query = value; - - // Query parameters for searching by text - const variables = { - searchTerm: value, - in: value ? 'TITLE' : undefined, - iid: null, - isNumber: false, - }; - - // Check if it is a number, add iid as query parameter - if (isNumeric(value) && value) { - variables.iid = value; - variables.isNumber = true; - } - - // Fetch combined results of search by iid and search by title. - this.$apollo.queries.availableWorkItems.refetch(variables); + async setSearchKey(value) { + this.searchTerm = value; }, handleFocus() { this.searchStarted = true; @@ -154,16 +174,16 @@ export default { focusInputText() { this.$nextTick(() => { if (this.areWorkItemsToAddValid) { - this.$refs.tokenSelector.$el.querySelector('input[type="text"]').focus(); + this.$refs.tokenSelector.focusTextInput(); } }); }, formatResults(input) { - if (!this.query) { + if (!this.searchTerm) { return input; } - return highlighter(`<span class="gl-text-black-normal">${input}</span>`, this.query); + return highlighter(`<span class="gl-text-black-normal">${input}</span>`, this.searchTerm); }, unsetError() { this.error = ''; @@ -176,6 +196,10 @@ export default { ); }, }, + i18n: { + noMatchesFoundMessage: I18N_WORK_ITEM_NO_MATCHES_FOUND, + addInputPlaceholder: I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + }, }; </script> <template> @@ -188,10 +212,11 @@ export default { v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" - :placeholder="addInputPlaceholder" + :placeholder="$options.i18n.addInputPlaceholder" menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" :container-class="tokenSelectorContainerClass" data-testid="work-item-token-select-input" + :text-input-attrs="textInputAttrs" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" @mouseover.native="handleMouseOver" @@ -210,6 +235,11 @@ export default { <div v-safe-html="formatResults(dropdownItem.title)" class="gl-text-truncate"></div> </div> </template> + <template #no-results-content> + <span data-testid="no-match-found-namespace-message">{{ + $options.i18n.noMatchesFoundMessage + }}</span> + </template> </gl-token-selector> </div> </template> diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue index 27de858fe4e..6feae8dd94e 100644 --- a/app/assets/javascripts/work_items/components/widget_wrapper.vue +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -57,28 +57,31 @@ export default { </script> <template> - <div :id="widgetName" class="gl-new-card" :aria-expanded="isOpenString"> + <div :id="widgetName" class="gl-new-card"> <div class="gl-new-card-header"> <div class="gl-new-card-title-wrapper"> - <h3 class="gl-new-card-title"> - <gl-link - :id="anchorLinkId" - class="gl-text-decoration-none" - :href="anchorLink" - aria-hidden="true" - /> + <h2 class="gl-new-card-title"> + <div aria-hidden="true"> + <gl-link + :id="anchorLinkId" + class="gl-text-decoration-none gl-display-none" + :href="anchorLink" + /> + </div> <slot name="header"></slot> - </h3> + </h2> <slot name="header-suffix"></slot> </div> <slot name="header-right"></slot> <div class="gl-new-card-toggle"> + <!-- https://www.w3.org/TR/wai-aria-1.2/#aria-expanded --> <gl-button category="tertiary" size="small" :icon="toggleIcon" :aria-label="toggleLabel" data-testid="widget-toggle" + :aria-expanded="isOpenString" @click="toggle" /> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index b7206d502a6..79f0fdca061 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -17,14 +17,16 @@ import { import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; -import WorkItemMilestone from './work_item_milestone.vue'; +import WorkItemMilestoneInline from './work_item_milestone_inline.vue'; +import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue'; import WorkItemParentInline from './work_item_parent_inline.vue'; import WorkItemParent from './work_item_parent_with_edit.vue'; export default { components: { WorkItemLabels, - WorkItemMilestone, + WorkItemMilestoneInline, + WorkItemMilestoneWithEdit, WorkItemAssignees, WorkItemDueDate, WorkItemParent, @@ -34,7 +36,10 @@ export default { WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight_with_edit.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), - WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemIterationInline: () => + import('ee_component/work_items/components/work_item_iteration_inline.vue'), + WorkItemIteration: () => + import('ee_component/work_items/components/work_item_iteration_with_edit.vue'), WorkItemHealthStatus: () => import('ee_component/work_items/components/work_item_health_status_with_edit.vue'), WorkItemHealthStatusInline: () => @@ -137,15 +142,28 @@ export default { :work-item-type="workItemType" @error="$emit('error', $event)" /> - <work-item-milestone - v-if="workItemMilestone" - :full-path="fullPath" - :work-item-id="workItem.id" - :work-item-milestone="workItemMilestone.milestone" - :work-item-type="workItemType" - :can-update="canUpdate" - @error="$emit('error', $event)" - /> + <template v-if="workItemMilestone"> + <work-item-milestone-with-edit + v-if="glFeatures.workItemsMvc2" + class="gl-mb-5" + :full-path="fullPath" + :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.milestone" + :work-item-type="workItemType" + :can-update="canUpdate" + @error="$emit('error', $event)" + /> + <work-item-milestone-inline + v-else + class="gl-mb-5" + :full-path="fullPath" + :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.milestone" + :work-item-type="workItemType" + :can-update="canUpdate" + @error="$emit('error', $event)" + /> + </template> <template v-if="workItemWeight"> <work-item-weight v-if="glFeatures.workItemsMvc2" @@ -177,17 +195,30 @@ export default { :work-item-type="workItemType" @error="$emit('error', $event)" /> - <work-item-iteration - v-if="workItemIteration" - class="gl-mb-5" - :full-path="fullPath" - :iteration="workItemIteration.iteration" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - @error="$emit('error', $event)" - /> + <template v-if="workItemIteration"> + <work-item-iteration + v-if="glFeatures.workItemsMvc2" + class="gl-mb-5" + :full-path="fullPath" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-iteration-inline + v-else + class="gl-mb-5" + :full-path="fullPath" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + </template> <template v-if="workItemHealthStatus"> <work-item-health-status v-if="glFeatures.workItemsMvc2" diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 77c573b47e4..4301dcca30b 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -40,12 +40,27 @@ export default { type: String, required: true, }, + disableInlineEditing: { + type: Boolean, + required: false, + default: false, + }, + editMode: { + type: Boolean, + required: false, + default: false, + }, + updateInProgress: { + type: Boolean, + required: false, + default: false, + }, }, markdownDocsPath: helpPagePath('user/markdown'), data() { return { workItem: {}, - isEditing: false, + isEditing: this.editMode, isSubmitting: false, isSubmittingWithKeydown: false, descriptionText: '', @@ -126,6 +141,26 @@ export default { autocompleteDataSources() { return autocompleteDataSources(this.fullPath, this.workItem.iid); }, + saveButtonText() { + return this.editMode ? __('Save changes') : __('Save'); + }, + formGroupClass() { + return { + 'gl-border-t gl-pt-6': !this.disableInlineEditing, + 'gl-mb-5 common-note-form': true, + }; + }, + }, + watch: { + updateInProgress(newValue) { + this.isSubmitting = newValue; + }, + editMode(newValue) { + this.isEditing = newValue; + if (newValue) { + this.startEditing(); + } + }, }, methods: { checkForConflicts() { @@ -159,6 +194,7 @@ export default { } this.isEditing = false; + this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, onInput() { @@ -175,6 +211,11 @@ export default { this.isSubmittingWithKeydown = true; } + if (this.disableInlineEditing) { + this.$emit('updateWorkItem'); + return; + } + this.isSubmitting = true; try { @@ -210,6 +251,9 @@ export default { }, setDescriptionText(newText) { this.descriptionText = newText; + if (this.disableInlineEditing) { + this.$emit('updateDraft', this.descriptionText); + } updateDraft(this.autosaveKey, this.descriptionText); }, handleDescriptionTextUpdated(newText) { @@ -224,12 +268,13 @@ export default { <div> <gl-form v-if="isEditing" @submit.prevent="updateWorkItem" @reset.prevent="cancelEditing"> <gl-form-group - class="gl-mb-5 gl-border-t gl-pt-6 common-note-form" + :class="formGroupClass" :label="__('Description')" + :label-sr-only="disableInlineEditing" label-for="work-item-description" > <markdown-editor - class="gl-my-5" + class="gl-mb-5" :value="descriptionText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" @@ -285,9 +330,9 @@ export default { :loading="isSubmitting" data-testid="save-description" type="submit" - >{{ __('Save') }} + >{{ saveButtonText }} </gl-button> - <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" type="reset" + <gl-button category="secondary" class="gl-ml-3" data-testid="cancel" type="reset" >{{ __('Cancel') }} </gl-button> </template> @@ -296,13 +341,14 @@ export default { </gl-form> <work-item-description-rendered v-else + :disable-inline-editing="disableInlineEditing" :work-item-description="workItemDescription" :can-edit="canEdit" @startEditing="startEditing" @descriptionUpdated="handleDescriptionTextUpdated" /> <edited-at - v-if="lastEditedAt" + v-if="lastEditedAt && !editMode" :updated-at="lastEditedAt" :updated-by-name="lastEditedByName" :updated-by-path="lastEditedByPath" diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index 124e05db431..1699f6c419e 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -22,6 +22,16 @@ export default { type: Boolean, required: true, }, + disableInlineEditing: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + checkboxes: [], + }; }, computed: { descriptionText() { @@ -33,6 +43,12 @@ export default { descriptionEmpty() { return this.descriptionHtml?.trim() === ''; }, + showEmptyDescription() { + return this.descriptionEmpty && !this.disableInlineEditing; + }, + showEditButton() { + return this.canEdit && !this.disableInlineEditing; + }, }, watch: { descriptionHtml: { @@ -96,9 +112,11 @@ export default { <template> <div class="gl-mb-5"> <div class="gl-display-inline-flex gl-align-items-center gl-mb-3"> - <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> + <label v-if="!disableInlineEditing" class="d-block col-form-label gl-mr-5">{{ + __('Description') + }}</label> <gl-button - v-if="canEdit" + v-if="showEditButton" v-gl-tooltip class="gl-ml-auto" icon="pencil" @@ -109,9 +127,9 @@ export default { /> </div> - <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div> + <div v-if="showEmptyDescription" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div> <div - v-else + v-else-if="!descriptionEmpty" ref="gfm-content" v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8 gl-clearfix" diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index b74cbc85379..85b981d9370 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; import { GlAlert, GlSkeletonLoader, GlButton, GlTooltipDirective, GlEmptyState } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__ } from '~/locale'; import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -18,6 +19,7 @@ import { WIDGET_TYPE_AWARD_EMOJI, WIDGET_TYPE_HIERARCHY, WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WORK_ITEM_TYPE_VALUE_EPIC, WIDGET_TYPE_NOTES, WIDGET_TYPE_LINKED_ITEMS, } from '../constants'; @@ -41,6 +43,7 @@ import WorkItemAwardEmoji from './work_item_award_emoji.vue'; import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; import WorkItemStickyHeader from './work_item_sticky_header.vue'; import WorkItemAncestors from './work_item_ancestors/work_item_ancestors.vue'; +import WorkItemTitleWithEdit from './work_item_title_with_edit.vue'; export default { i18n, @@ -67,6 +70,7 @@ export default { WorkItemRelationships, WorkItemStickyHeader, WorkItemAncestors, + WorkItemTitleWithEdit, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'isGroup', 'reportAbusePath'], @@ -94,6 +98,8 @@ export default { reportedUrl: '', reportedUserId: 0, isStickyHeaderShowing: false, + editMode: false, + draftData: {}, }; }, apollo: { @@ -219,7 +225,7 @@ export default { }; }, showIntersectionObserver() { - return !this.isModal && this.workItemsMvc2Enabled; + return !this.isModal && this.workItemsMvc2Enabled && !this.editMode; }, hasLinkedWorkItems() { return this.glFeatures.linkedWorkItems; @@ -227,19 +233,26 @@ export default { workItemLinkedItems() { return this.isWidgetPresent(WIDGET_TYPE_LINKED_ITEMS); }, + showWorkItemTree() { + return [WORK_ITEM_TYPE_VALUE_OBJECTIVE, WORK_ITEM_TYPE_VALUE_EPIC].includes( + this.workItemType, + ); + }, showWorkItemLinkedItems() { return this.hasLinkedWorkItems && this.workItemLinkedItems; }, titleClassHeader() { return { 'gl-sm-display-none!': this.parentWorkItem, - 'gl-w-full': !this.parentWorkItem, + 'gl-w-full': !this.parentWorkItem && !this.editMode, + 'editable-wi-title': this.editMode && !this.parentWorkItem, }; }, titleClassComponent() { return { 'gl-sm-display-block!': !this.parentWorkItem, 'gl-display-none gl-sm-display-block!': this.parentWorkItem, + 'gl-mt-3 editable-wi-title': this.workItemsMvc2Enabled, }; }, headerWrapperClass() { @@ -258,6 +271,9 @@ export default { } }, methods: { + enableEditMode() { + this.editMode = true; + }, isWidgetPresent(type) { return this.workItem.widgets?.find((widget) => widget.type === type); }, @@ -349,6 +365,45 @@ export default { this.isStickyHeaderShowing = true; } }, + updateDraft(type, value) { + this.draftData[type] = value; + }, + async updateWorkItem() { + this.updateInProgress = true; + try { + const { + data: { workItemUpdate }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItem.id, + title: this.draftData.title, + descriptionWidget: { + description: this.draftData.description, + }, + }, + }, + }); + + const { errors } = workItemUpdate; + + if (errors?.length) { + this.updateError = errors.join('\n'); + throw new Error(this.updateError); + } + + this.editMode = false; + } catch (error) { + Sentry.captureException(error); + } finally { + this.updateInProgress = false; + } + }, + cancelEditing() { + this.draftData = {}; + this.editMode = false; + }, }, WORK_ITEM_TYPE_VALUE_OBJECTIVE, WORKSPACE_PROJECT, @@ -388,8 +443,17 @@ export default { :class="titleClassHeader" data-testid="work-item-type" > + <work-item-title-with-edit + v-if="workItem.title && workItemsMvc2Enabled" + ref="title" + class="gl-mt-3 gl-sm-display-block!" + :is-editing="editMode" + :title="workItem.title" + @updateWorkItem="updateWorkItem" + @updateDraft="updateDraft('title', $event)" + /> <work-item-title - v-if="workItem.title" + v-else-if="workItem.title" ref="title" class="gl-sm-display-block!" :work-item-id="workItem.id" @@ -402,6 +466,14 @@ export default { <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-ml-auto gl-gap-3" > + <gl-button + v-if="workItemsMvc2Enabled && !editMode" + category="secondary" + data-testid="work-item-edit-form-button" + @click="enableEditMode" + > + {{ __('Edit') }} + </gl-button> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" @@ -441,8 +513,17 @@ export default { /> </div> <div> + <work-item-title-with-edit + v-if="workItem.title && workItemsMvc2Enabled && parentWorkItem" + ref="title" + :is-editing="editMode" + :class="titleClassComponent" + :title="workItem.title" + @updateWorkItem="updateWorkItem" + @updateDraft="updateDraft('title', $event)" + /> <work-item-title - v-if="workItem.title && parentWorkItem" + v-else-if="workItem.title && parentWorkItem" ref="title" :class="titleClassComponent" :work-item-id="workItem.id" @@ -453,6 +534,7 @@ export default { @error="updateError = $event" /> <work-item-created-updated + v-if="!editMode" :full-path="fullPath" :work-item-iid="workItemIid" :update-in-progress="updateInProgress" @@ -490,10 +572,16 @@ export default { /> <work-item-description v-if="hasDescriptionWidget" + :class="workItemsMvc2Enabled ? '' : 'gl-pt-5'" + :disable-inline-editing="workItemsMvc2Enabled" + :edit-mode="editMode" :full-path="fullPath" :work-item-id="workItem.id" :work-item-iid="workItem.iid" - class="gl-pt-5" + :update-in-progress="updateInProgress" + @updateWorkItem="updateWorkItem" + @updateDraft="updateDraft('description', $event)" + @cancelEditing="cancelEditing" @error="updateError = $event" /> <work-item-award-emoji @@ -506,7 +594,7 @@ export default { @emoji-updated="$emit('work-item-emoji-updated', $event)" /> <work-item-tree - v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + v-if="showWorkItemTree" :full-path="fullPath" :work-item-type="workItemType" :parent-work-item-type="workItem.workItemType.name" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index f24b56cac36..cc46932539d 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -11,6 +11,7 @@ import { import { __, s__, sprintf } from '~/locale'; import WorkItemTokenInput from '../shared/work_item_token_input.vue'; import { addHierarchyChild } from '../../graphql/cache_utils'; +import groupWorkItemTypesQuery from '../../graphql/group_work_item_types.query.graphql'; import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; @@ -90,7 +91,9 @@ export default { }, apollo: { workItemTypes: { - query: projectWorkItemTypesQuery, + query() { + return this.isGroup ? groupWorkItemTypesQuery : projectWorkItemTypesQuery; + }, variables() { return { fullPath: this.fullPath, diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone_inline.vue index dbeb3d4d3ff..dbeb3d4d3ff 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone_inline.vue diff --git a/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue new file mode 100644 index 00000000000..9588d21a3c5 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue @@ -0,0 +1,203 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import Tracking from '~/tracking'; +import { s__, __ } from '~/locale'; +import { MILESTONE_STATE } from '~/sidebar/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + TRACKING_CATEGORY_SHOW, +} from '../constants'; + +export default { + i18n: { + milestone: s__('WorkItem|Milestone'), + none: s__('WorkItem|None'), + noMilestone: s__('WorkItem|No milestone'), + milestoneFetchError: s__( + 'WorkItem|Something went wrong while fetching milestones. Please try again.', + ), + expiredText: __('(expired)'), + }, + components: { + WorkItemSidebarDropdownWidgetWithEdit, + GlLink, + }, + mixins: [Tracking.mixin()], + props: { + fullPath: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + workItemMilestone: { + type: Object, + required: false, + default: () => ({}), + }, + workItemType: { + type: String, + required: false, + default: '', + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchTerm: '', + shouldFetch: false, + updateInProgress: false, + milestones: [], + localMilestone: this.workItemMilestone, + }; + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_milestone', + property: `type_${this.workItemType}`, + }; + }, + emptyPlaceholder() { + return this.canUpdate ? this.$options.i18n.noMilestone : this.$options.i18n.none; + }, + expired() { + return this.localMilestone?.expired ? ` ${this.$options.i18n.expiredText}` : ''; + }, + dropdownText() { + return this.localMilestone?.title + ? `${this.localMilestone?.title}${this.expired}` + : this.emptyPlaceholder; + }, + isLoadingMilestones() { + return this.$apollo.queries.milestones.loading; + }, + milestonesList() { + return this.milestones.map(({ id, title, expired }) => ({ + value: id, + text: title, + expired, + })); + }, + }, + apollo: { + milestones: { + query: projectMilestonesQuery, + debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + variables() { + return { + fullPath: this.fullPath, + title: this.searchTerm, + state: MILESTONE_STATE.ACTIVE, + first: 20, + }; + }, + skip() { + return !this.shouldFetch; + }, + update(data) { + return data?.workspace?.attributes?.nodes || []; + }, + error() { + this.$emit('error', this.i18n.milestoneFetchError); + }, + }, + }, + methods: { + onDropdownShown() { + this.searchTerm = ''; + this.shouldFetch = true; + }, + search(searchTerm) { + this.searchTerm = searchTerm; + this.shouldFetch = true; + }, + itemExpiredText(item) { + return item.expired ? this.$options.i18n.expiredText : ''; + }, + updateMilestone(selectedMilestoneId) { + if (this.localMilestone?.id === selectedMilestoneId) { + return; + } + + this.localMilestone = selectedMilestoneId + ? this.milestones.find(({ id }) => id === selectedMilestoneId) + : null; + + this.track('updated_milestone'); + this.updateInProgress = true; + + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + milestoneWidget: { + milestoneId: selectedMilestoneId, + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('\n')); + } + }) + .catch((error) => { + this.localMilestone = this.workItemMilestone; + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', msg); + Sentry.captureException(error); + }) + .finally(() => { + this.updateInProgress = false; + this.searchTerm = ''; + this.shouldFetch = false; + }); + }, + }, +}; +</script> + +<template> + <work-item-sidebar-dropdown-widget-with-edit + :dropdown-label="$options.i18n.milestone" + :can-update="canUpdate" + dropdown-name="milestone" + :loading="isLoadingMilestones" + :list-items="milestonesList" + :item-value="localMilestone" + :update-in-progress="updateInProgress" + :toggle-dropdown-text="dropdownText" + :header-text="__('Select milestone')" + :reset-button-label="__('Clear')" + data-testid="work-item-milestone-with-edit" + @dropdownShown="onDropdownShown" + @searchStarted="search" + @updateValue="updateMilestone" + > + <template #list-item="{ item }"> + <div>{{ item.text }}{{ itemExpiredText(item) }}</div> + <div v-if="item.title">{{ item.title }}</div> + </template> + <template #readonly> + <gl-link class="gl-text-gray-900!" :href="localMilestone.webPath"> + {{ localMilestone.title }}{{ expired }} + </gl-link> + </template> + </work-item-sidebar-dropdown-widget-with-edit> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_parent_inline.vue b/app/assets/javascripts/work_items/components/work_item_parent_inline.vue index 0c0842a3e05..bb75de677c3 100644 --- a/app/assets/javascripts/work_items/components/work_item_parent_inline.vue +++ b/app/assets/javascripts/work_items/components/work_item_parent_inline.vue @@ -108,7 +108,7 @@ export default { types: this.parentType, in: this.search ? 'TITLE' : undefined, iid: null, - isNumber: false, + searchByIid: false, }; }, skip() { diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue index c98bd6ce1e9..10c59d677f7 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue @@ -172,7 +172,7 @@ export default { relatedToLabel: s__('WorkItem|relates to'), blockingLabel: s__('WorkItem|blocks'), blockedByLabel: s__('WorkItem|is blocked by'), - linkItemInputLabel: s__('WorkItem|the following item(s)'), + linkItemInputLabel: s__('WorkItem|the following items'), addLinkedItemErrorMessage: s__( 'WorkItem|Something went wrong when trying to link a item. Please try again.', ), diff --git a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue index 69752967efe..48884be54f6 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue @@ -38,6 +38,11 @@ export default { required: false, default: false, }, + hasComment: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -49,9 +54,15 @@ export default { return this.workItemState === STATE_OPEN; }, toggleWorkItemStateText() { - const baseText = this.isWorkItemOpen + let baseText = this.isWorkItemOpen ? __('Close %{workItemType}') : __('Reopen %{workItemType}'); + + if (this.hasComment) { + baseText = this.isWorkItemOpen + ? __('Comment & close %{workItemType}') + : __('Comment & reopen %{workItemType}'); + } return sprintfWorkItem(baseText, this.workItemType); }, tracking() { @@ -96,6 +107,10 @@ export default { Sentry.captureException(error); } + if (this.hasComment) { + this.$emit('submit-comment'); + } + this.updateInProgress = false; }, }, diff --git a/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue new file mode 100644 index 00000000000..6af564a6a91 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue @@ -0,0 +1,45 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + i18n: { + titleLabel: __('Title (required)'), + }, + props: { + title: { + type: String, + required: true, + }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <gl-form-group v-if="isEditing" :label="$options.i18n.titleLabel" label-for="work-item-title"> + <gl-form-input + class="gl-w-full" + :value="title" + data-testid="work-item-title-with-edit" + @keydown.meta.enter="$emit('updateWorkItem')" + @keydown.ctrl.enter="$emit('updateWorkItem')" + @input="$emit('updateDraft', $event)" + /> + </gl-form-group> + <h1 + v-else + data-testid="work-item-title" + class="gl-w-full gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-font-size-h-display" + > + {{ title }} + </h1> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 41cf5d8932d..62fdc8a21c2 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -46,6 +46,9 @@ export const WORK_ITEM_TYPE_VALUE_REQUIREMENTS = 'Requirements'; export const WORK_ITEM_TYPE_VALUE_KEY_RESULT = 'Key Result'; export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; +export const NAMESPACE_GROUP = 'group'; +export const NAMESPACE_PROJECT = 'project'; + export const WORK_ITEM_TITLE_MAX_LENGTH = 255; export const i18n = { @@ -91,10 +94,13 @@ export const I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR = s__( export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}'); export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}'); export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s'); -export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__('WorkItem|Search existing items'); +export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( + 'WorkItem|Search existing items, paste URL, or enter reference ID', +); export const I18N_WORK_ITEM_SEARCH_ERROR = s__( 'WorkItem|Something went wrong while fetching the %{workItemType}. Please try again.', ); +export const I18N_WORK_ITEM_NO_MATCHES_FOUND = s__('WorkItem|No matches found'); export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__( 'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access', ); @@ -195,6 +201,8 @@ export const WORK_ITEMS_TYPE_MAP = { export const WORK_ITEM_TYPE_VALUE_MAP = { [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: WORK_ITEM_TYPE_ENUM_OBJECTIVE, [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: WORK_ITEM_TYPE_ENUM_KEY_RESULT, + [WORK_ITEM_TYPE_VALUE_ISSUE]: WORK_ITEM_TYPE_ENUM_ISSUE, + [WORK_ITEM_TYPE_VALUE_EPIC]: WORK_ITEM_TYPE_ENUM_EPIC, }; export const WORK_ITEMS_TREE_TEXT_MAP = { @@ -208,9 +216,14 @@ export const WORK_ITEMS_TREE_TEXT_MAP = { 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', ), }, + [WORK_ITEM_TYPE_VALUE_EPIC]: { + title: s__('WorkItem|Child items'), + empty: s__('WorkItem|No epics or issues are currently assigned.'), + }, }; export const WORK_ITEM_NAME_TO_ICON_MAP = { + Epic: 'epic', Issue: 'issue-type-issue', Task: 'issue-type-task', Objective: 'issue-type-objective', diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql index 7efd67467e5..17b338f7a8d 100644 --- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -4,11 +4,12 @@ query projectWorkItems( $types: [IssueType!] $in: [IssuableSearchableField!] $iid: String = null - $isNumber: Boolean! + $searchByIid: Boolean = false + $searchByText: Boolean = true ) { workspace: project(fullPath: $fullPath) { id - workItems(search: $searchTerm, types: $types, in: $in) { + workItems(search: $searchTerm, types: $types, in: $in) @include(if: $searchByText) { nodes { id iid @@ -16,7 +17,7 @@ query projectWorkItems( confidential } } - workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $isNumber) { + workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $searchByIid) { nodes { id iid diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index ef43b9c026d..c1ec3fe276f 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -12,6 +12,7 @@ fragment WorkItem on WorkItem { createdAt updatedAt closedAt + webUrl reference(full: true) createNoteEmail namespace { diff --git a/app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql b/app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql new file mode 100644 index 00000000000..1e8d62596b7 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql @@ -0,0 +1,10 @@ +query getWorkItemsByReferences($contextNamespacePath: ID!, $refs: [String!]!) { + workItemsByReference(contextNamespacePath: $contextNamespacePath, refs: $refs) { + nodes { + id + iid + title + confidential + } + } +} diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index c3c292c3dd9..6d304e7ebf0 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -55,3 +55,14 @@ export const markdownPreviewPath = (fullPath, iid) => `${ gon.relative_url_root || '' }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`; + +export const isReference = (input) => { + /** + * The regular expression checks if the `value` is + * a project work item or group work item. + * e.g., gitlab-org/project-path#101 or gitlab-org&101 + * or #1234 + */ + + return /^([\w-]+(?:\/[\w-]+)*)?[#&](\d+)$/.test(input); +}; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index ce8ccb2bc08..1b99a27b12c 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -13,10 +13,10 @@ @import 'page_specific_files'; // Component specific styles, will be moved to gitlab-ui -@import 'components/**/*'; +@import 'components/index'; // Vendors specific styles -@import 'vendors/**/*'; +@import 'vendors/index'; // Styles for JS behaviors. @import 'behaviors'; @@ -27,7 +27,5 @@ // JH-only stylesheets @import 'application_jh'; -/* print styles */ -@media print { - @import 'print'; -} +// print styles +@import 'print'; diff --git a/app/assets/stylesheets/components/_index.scss b/app/assets/stylesheets/components/_index.scss new file mode 100644 index 00000000000..f53837b5671 --- /dev/null +++ b/app/assets/stylesheets/components/_index.scss @@ -0,0 +1,11 @@ +@import './avatar'; +@import './collapsible_card'; +@import './content_editor'; +@import './deployment_instance'; +@import './detail_page'; +@import './ref_selector'; +@import './related_items_list'; +@import './severity/icons'; +@import './shortcuts_help'; +@import './upload_dropzone/upload_dropzone'; +@import './whats_new'; diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 97f2add4e77..c654eb16af5 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -35,6 +35,10 @@ background-color: transparent; } + th[align] *, td[align] * { + text-align: inherit; + } + td, th, li, @@ -149,6 +153,11 @@ padding: $gl-spacing-scale-1 $gl-spacing-scale-3 0 0; margin: 0; } + + &[data-inapplicable] * { + text-decoration: line-through; + color: $gl-text-color-disabled; + } } } diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss index 5a5f39a4b77..10bf54b4ffb 100644 --- a/app/assets/stylesheets/emoji_sprites.scss +++ b/app/assets/stylesheets/emoji_sprites.scss @@ -7176,7 +7176,7 @@ } .emoji-icon { - background-image: image-url('emoji.png'); + background-image: url('emoji.png'); background-repeat: no-repeat; color: transparent; text-indent: -99em; @@ -7190,7 +7190,7 @@ only screen and (min-device-pixel-ratio: 2), only screen and (min-resolution: 192dpi), only screen and (min-resolution: 2dppx) { - background-image: image-url('emoji@2x.png'); + background-image: url('emoji@2x.png'); background-size: 860px 840px; } /* stylelint-enable media-feature-name-no-vendor-prefix */ diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss index 6886e751b72..f776328ebdf 100644 --- a/app/assets/stylesheets/fonts.scss +++ b/app/assets/stylesheets/fonts.scss @@ -11,7 +11,7 @@ Usage: font-style: normal; /* stylelint-disable-next-line property-no-unknown */ font-named-instance: 'Regular'; - src: font-url('gitlab-sans/GitLabSans.woff2') format('woff2'); + src: url('gitlab-sans/GitLabSans.woff2') format('woff2'); } @font-face { @@ -21,7 +21,7 @@ Usage: font-style: italic; /* stylelint-disable-next-line property-no-unknown */ font-named-instance: 'Regular'; - src: font-url('gitlab-sans/GitLabSans-Italic.woff2') format('woff2'); + src: url('gitlab-sans/GitLabSans-Italic.woff2') format('woff2'); } /* ------------------------------------------------------- @@ -35,7 +35,7 @@ Usage: font-weight: 100 900; font-display: swap; font-style: normal; - src: font-url('gitlab-mono/GitLabMono.woff2') format('woff2'); + src: url('gitlab-mono/GitLabMono.woff2') format('woff2'); } @font-face { @@ -43,7 +43,7 @@ Usage: font-weight: 100 900; font-display: swap; font-style: italic; - src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2'); + src: url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2'); } // This isn't the best solution, but we needed a quick fix diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 6f4f7a29334..dd4b6f51ebe 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -10,6 +10,7 @@ @import 'framework/animations'; @import 'framework/vue_transitions'; @import 'framework/blocks'; +@import 'framework/breadcrumbs'; @import 'framework/buttons'; @import 'framework/badges'; @import 'framework/calendar'; @@ -23,7 +24,9 @@ @import 'framework/gfm'; @import 'framework/kbd'; @import 'framework/header'; +@import 'framework/top_bar'; @import 'framework/highlight'; +@import 'framework/labels'; @import 'framework/lists'; @import 'framework/logo'; @import 'framework/markdown_area'; diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss index 3f1d742ca14..7c3684f7c2e 100644 --- a/app/assets/stylesheets/framework/badges.scss +++ b/app/assets/stylesheets/framework/badges.scss @@ -12,3 +12,56 @@ color: $green; } } + +// FF :simplified_badges +// +// Temporarily override badge styles +// globally +// +// Once verified we will update the +// badge component in GitLab UI +// refactor GitLab and remove this +// custom code +// +// see https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3307 +.ff-simplified-badges-enabled { + // These changes will be moved to + // GitLab UI's badge component + .gl-badge, + .gl-badge.sm, + .gl-badge.md, + .gl-badge.lg { + @include gl-font-sm; + padding-block: $gl-spacing-scale-1; + padding-inline: calc(#{$gl-spacing-scale-3} - 2px); + + > .gl-icon { + @include gl-ml-0; + } + } + + // These changes will be moved to + // GitLab UI's button component + .gl-button .gl-badge { + @include gl-py-0; + } + + // These changes will be moved to + // app/assets/stylesheets/framework/super_sidebar.scss + .super-sidebar-nav-item .gl-badge { + vertical-align: 2px; + } + + // These changes will be moved to + // GitLab UI's tab component + .gl-tab-nav-item .gl-badge { + margin-block: -2px; + } + + // Temporarily needed because of the + // speciality this FF adds + // the utility class gets overriden + .gl-badge.ci-icon { + @include gl-p-2; + } +} diff --git a/app/assets/stylesheets/framework/breadcrumbs.scss b/app/assets/stylesheets/framework/breadcrumbs.scss new file mode 100644 index 00000000000..b71382f5570 --- /dev/null +++ b/app/assets/stylesheets/framework/breadcrumbs.scss @@ -0,0 +1,13 @@ +.breadcrumbs { + flex: 1; + min-width: 0; + align-self: center; + color: $gl-text-color-secondary; + + .avatar-tile { + margin-right: 4px; + border: 1px solid $border-color; + border-radius: 50%; + vertical-align: sub; + } +} diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index b948a57ea33..497a8a08a6f 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -225,7 +225,7 @@ $diff-file-header: 41px; width: 15px; position: absolute; top: 0; - background: image-url('swipemode_sprites.gif') 0 3px no-repeat; + background: url('swipemode_sprites.gif') 0 3px no-repeat; } .bottom-handle { @@ -234,7 +234,7 @@ $diff-file-header: 41px; width: 15px; position: absolute; bottom: 0; - background: image-url('swipemode_sprites.gif') 0 -11px no-repeat; + background: url('swipemode_sprites.gif') 0 -11px no-repeat; } } } @@ -272,7 +272,7 @@ $diff-file-header: 41px; left: 12px; height: 10px; width: 276px; - background: image-url('onion_skin_sprites.gif') -4px -20px repeat-x; + background: url('onion_skin_sprites.gif') -4px -20px repeat-x; } .dragger { @@ -282,7 +282,7 @@ $diff-file-header: 41px; top: 0; height: 14px; width: 14px; - background: image-url('onion_skin_sprites.gif') 0 -34px repeat-x; + background: url('onion_skin_sprites.gif') 0 -34px repeat-x; cursor: pointer; } @@ -293,7 +293,7 @@ $diff-file-header: 41px; right: 0; height: 10px; width: 10px; - background: image-url('onion_skin_sprites.gif') -2px 0 no-repeat; + background: url('onion_skin_sprites.gif') -2px 0 no-repeat; } .opaque { @@ -303,7 +303,7 @@ $diff-file-header: 41px; left: 0; height: 10px; width: 10px; - background: image-url('onion_skin_sprites.gif') -2px -10px no-repeat; + background: url('onion_skin_sprites.gif') -2px -10px no-repeat; } } } @@ -770,12 +770,12 @@ table.code { .frame.click-to-comment, .btn-transparent.image-diff-overlay-add-comment { position: relative; - cursor: image-url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + cursor: url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; // Retina cursor - cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, - image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + cursor: image-set(url('illustrations/image_comment_light_cursor.svg') 1x, + url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; .comment-indicator { @@ -944,3 +944,12 @@ table.code { left: -2px !important; } } + +.diff-file.pinned-file .file-title { + background-color: $blue-50; + border-color: $blue-200; +} + +.diff-file.pinned-file .diff-content { + border-color: $blue-200; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index e791a0dbbbd..2558ddec9b9 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -575,7 +575,7 @@ left: 1rem; width: 1rem; height: 1rem; - mask-image: asset_url('icons-stacked.svg#check'); + mask-image: url('icons-stacked.svg#check'); mask-repeat: no-repeat; mask-size: cover; mask-position: center center; @@ -806,28 +806,6 @@ } } -@include media-breakpoint-down(xs) { - .navbar-gitlab { - li.dropdown { - position: static; - } - } - - header.navbar-gitlab .dropdown { - .dropdown-menu { - width: 100%; - min-width: 100%; - } - } - - header.navbar-gitlab-new .header-content .dropdown { - .dropdown-menu { - left: 0; - min-width: 100%; - } - } -} - .dropdown-content-faded-mask { position: relative; @@ -959,3 +937,17 @@ width: 100%; } } + +.group-namespace-dropdown .gl-new-dropdown-custom-toggle { + display: flex; + flex: auto; + + .gl-button-text { + display: flex; + @include gl-w-full; + } +} + +.group-namespace-dropdown .gl-new-dropdown-item-text-wrapper { + word-break: break-word; +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 9cb264c992b..7dcde5f0b3c 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -420,7 +420,7 @@ span.idiff { @include gl-h-5; @include gl-float-left; background-color: $gray-400; - mask-image: asset_url('icons-stacked.svg#doc-versions'); + mask-image: url('icons-stacked.svg#doc-versions'); mask-repeat: no-repeat; mask-size: cover; mask-position: center; diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 6b4f1478978..56667c10752 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -96,14 +96,6 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); } } -@include media-breakpoint-down(sm) { - ul.notes { - .flash-container.timeline-content { - margin-left: 0; - } - } -} - .gl-browser-ie .flash-container { position: fixed; max-width: $limited-layout-width; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 23f40dfe4bf..84e69e40bc2 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -9,119 +9,6 @@ left: 0; right: 0; border-radius: 0; - - .close-icon { - display: none; - } - - .header-content { - width: 100%; - display: flex; - justify-content: space-between; - position: relative; - min-height: var(--header-height); - padding-left: 0; - - .title { - padding-right: 0; - color: currentColor; - display: flex; - position: relative; - margin: 0; - font-size: 18px; - vertical-align: top; - white-space: nowrap; - - img { - height: 24px; - - + .logo-text { - margin-left: 8px; - } - } - - &.wrap { - white-space: normal; - } - - &.initializing { - opacity: 0; - } - - a:not(.canary-badge) { - display: flex; - align-items: center; - padding: 2px 8px; - margin: 4px 2px 4px -8px; - border-radius: $border-radius-default; - - &:active, - &:focus { - @include gl-focus($focus-ring: $focus-ring-dark); - } - } - } - - .dropdown.open { - > a { - border-bottom-color: $white; - } - } - } - - .container-fluid { - padding: 0; - - .nav > li { - > a { - will-change: color; - margin: 4px 0; - padding: 6px 8px; - height: 32px; - } - } - } -} - -.top-bar-container { - min-height: $top-bar-height; -} - -.top-bar-fixed { - @include gl-inset-border-b-1-gray-100; - background-color: $body-bg; - left: var(--application-bar-left); - position: fixed; - right: var(--application-bar-right); - top: $calc-application-bars-height; - width: auto; - z-index: $top-bar-z-index; - - @media (prefers-reduced-motion: no-preference) { - transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium; - } -} - -.breadcrumbs { - flex: 1; - min-width: 0; - align-self: center; - color: $gl-text-color-secondary; - - .avatar-tile { - margin-right: 4px; - border: 1px solid $border-color; - border-radius: 50%; - vertical-align: sub; - } -} - -.breadcrumb-item-text { - text-decoration: inherit; - - @include media-breakpoint-down(xs) { - @include str-truncated(128px); - } } .navbar-empty { @@ -173,17 +60,6 @@ @include media-breakpoint-down(xs) { margin-right: 3px; } } -.top-nav-menu-item { - &.active, - &:hover { - background-color: $nav-active-bg !important; - } - - .gl-icon { - color: inherit !important; - } -} - .header-logged-out { z-index: $header-zindex; min-height: var(--header-height); diff --git a/app/assets/stylesheets/framework/labels.scss b/app/assets/stylesheets/framework/labels.scss new file mode 100644 index 00000000000..1933af5151c --- /dev/null +++ b/app/assets/stylesheets/framework/labels.scss @@ -0,0 +1,56 @@ +// FF :simplified_labels +// +// Temporarily override label styles +// globally +// +// Once verified we will update the +// label component in GitLab UI +// refactor GitLab and remove this +// custom code +// +// see https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3307 +.ff-simplified-labels-enabled { + // These changes will be moved to + // GitLab UI's label component + .gl-label, + .gl-label-sm { + @include gl-vertical-align-bottom; + + &:focus:active { + @include gl-reset-color; + @include gl-shadow-none; + @include gl-outline-none; + } + + .gl-label-text, + .gl-label-text-scoped { + @include gl-font-sm; + padding-block: $gl-spacing-scale-1; + padding-inline: calc(#{$gl-spacing-scale-3} - 2px); + } + + > .gl-label-close.gl-button { + width: px-to-rem(14px); + height: px-to-rem(14px); + margin-left: calc(#{-$gl-spacing-scale-2} - 1px); + margin-right: calc(#{$gl-spacing-scale-2} - 1px); + } + } + + // These changes will be moved to + // app/assets/stylesheets/framework/sidebar.scss + .issuable-show-labels .gl-label { + margin-bottom: $gl-spacing-scale-2; + margin-right: $gl-spacing-scale-2; + } + + // These changes will be moved to + // app/assets/stylesheets/framework/typography.scss + .md p > code { + font-size: px-to-rem(13px); + } + + .md code { + @include gl-vertical-align-bottom; + } +} diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 7ec13c3d54c..4ef53c673f6 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -1,6 +1,4 @@ html { - overflow-y: scroll; - &.touch .tooltip { display: none !important; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 0eecf7bddc1..04799a6b8f8 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -601,6 +601,7 @@ .gutter-toggle { width: 100%; height: $sidebar-toggle-height; + margin-top: 0; margin-left: 0; border-bottom: 1px solid $border-color; border-radius: 0; diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss index a09ab7ed64c..2b597634519 100644 --- a/app/assets/stylesheets/framework/source_editor.scss +++ b/app/assets/stylesheets/framework/source_editor.scss @@ -78,7 +78,7 @@ @include gl-mr-2; @include gl-w-4; @include gl-h-4; - mask-image: asset_url('icons-stacked.svg#link'); + mask-image: url('icons-stacked.svg#link'); mask-repeat: no-repeat; mask-size: cover; mask-position: center; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 84f0612a7b4..5a9a739fb13 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -22,7 +22,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; .super-sidebar { --super-sidebar-bg: #{$gray-10}; --super-sidebar-border-color: #{$t-gray-a-08}; - --super-sidebar-primary: #{$blue-500}; + --super-sidebar-context-header-color: inherit; + --super-sidebar-active-indicator-color: #{$blue-500}; --super-sidebar-notification-dot: #{$blue-500}; --super-sidebar-user-bar-bg: #{$t-gray-a-04}; @@ -42,6 +43,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; --super-sidebar-nav-item-current-bg: #{$t-gray-a-08}; --super-sidebar-nav-item-icon-color: #{$gray-500}; + --super-sidebar-hr-mix-blend-mode: normal; + .gl-dark & { --super-sidebar-border-color: #{$t-white-a-08}; --super-sidebar-user-bar-bg: #{$t-white-a-04}; @@ -58,7 +61,148 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; --super-sidebar-nav-item-current-bg: #{$t-white-a-08}; --super-sidebar-nav-item-icon-color: #{$gray-600}; } +} + +@mixin super-sidebar-theme( + $background, + $user-bar-background, + $user-bar-button-color, + $user-bar-button-icon-color, + $context-header, + $active-indicator, + $notification-dot, +) { + .super-sidebar { + --super-sidebar-bg: #{$background}; + --super-sidebar-user-bar-bg: #{$user-bar-background}; + --super-sidebar-context-header-color: #{$context-header}; + --super-sidebar-active-indicator-color: #{$active-indicator}; + --super-sidebar-notification-dot: #{$notification-dot}; + + --super-sidebar-user-bar-button-bg: #{$t-white-a-16}; + --super-sidebar-user-bar-button-color: #{$user-bar-button-color}; + --super-sidebar-user-bar-button-border-color: #{$t-white-a-16}; + --super-sidebar-user-bar-button-hover-bg: #{$t-white-a-24}; + --super-sidebar-user-bar-button-hover-color: #{$white}; + --super-sidebar-user-bar-button-active-bg: #{$t-white-a-36}; + + --super-sidebar-user-bar-button-icon-color: #{$user-bar-button-icon-color}; + --super-sidebar-user-bar-button-icon-hover-color: #{$user-bar-button-icon-color}; + --super-sidebar-user-bar-button-icon-mix-blend-mode: screen; + + --super-sidebar-hr-mix-blend-mode: multiply; + } +} + +.ui-blue { + @include super-sidebar-theme( + $background: $theme-blue-10, + $user-bar-background: $theme-blue-900, + $user-bar-button-color: $theme-blue-50, + $user-bar-button-icon-color: $theme-blue-100, + $context-header: $theme-blue-900, + $active-indicator: $theme-blue-900, + $notification-dot: $theme-blue-900, + ); +} + +.ui-gray { + @include super-sidebar-theme( + $background: $gray-10, + $user-bar-background: $gray-900, + $user-bar-button-color: $gray-50, + $user-bar-button-icon-color: $gray-100, + $context-header: $gray-900, + $active-indicator: $gray-900, + $notification-dot: $gray-900, + ); +} + +.ui-green { + @include super-sidebar-theme( + $background: $theme-green-10, + $user-bar-background: $theme-green-900, + $user-bar-button-color: $theme-green-50, + $user-bar-button-icon-color: $theme-green-100, + $context-header: $theme-green-900, + $active-indicator: $theme-green-900, + $notification-dot: $theme-green-900, + ); +} + +.ui-indigo { + @include super-sidebar-theme( + $background: $theme-indigo-10, + $user-bar-background: $theme-indigo-900, + $user-bar-button-color: $theme-indigo-50, + $user-bar-button-icon-color: $theme-indigo-100, + $context-header: $theme-indigo-900, + $active-indicator: $theme-indigo-900, + $notification-dot: $theme-indigo-900, + ); +} + +.ui-light-blue { + @include super-sidebar-theme( + $background: $theme-light-blue-10, + $user-bar-background: $theme-light-blue-700, + $user-bar-button-color: $theme-light-blue-50, + $user-bar-button-icon-color: $theme-light-blue-100, + $context-header: $theme-light-blue-900, + $active-indicator: $theme-light-blue-900, + $notification-dot: $theme-light-blue-900, + ); +} + +.ui-light-green { + @include super-sidebar-theme( + $background: $theme-green-10, + $user-bar-background: $theme-green-700, + $user-bar-button-color: $theme-green-50, + $user-bar-button-icon-color: $theme-green-100, + $context-header: $theme-green-900, + $active-indicator: $theme-green-900, + $notification-dot: $theme-green-900, + ); +} + +.ui-light-indigo { + @include super-sidebar-theme( + $background: $theme-indigo-10, + $user-bar-background: $theme-indigo-700, + $user-bar-button-color: $theme-indigo-50, + $user-bar-button-icon-color: $theme-indigo-100, + $context-header: $theme-indigo-900, + $active-indicator: $theme-indigo-900, + $notification-dot: $theme-indigo-900, + ); +} + +.ui-light-red { + @include super-sidebar-theme( + $background: $theme-light-red-10, + $user-bar-background: $theme-light-red-700, + $user-bar-button-color: $theme-light-red-50, + $user-bar-button-icon-color: $theme-light-red-100, + $context-header: $theme-light-red-900, + $active-indicator: $theme-light-red-900, + $notification-dot: $theme-light-red-900, + ); +} +.ui-red { + @include super-sidebar-theme( + $background: $theme-red-10, + $user-bar-background: $theme-red-900, + $user-bar-button-color: $theme-red-50, + $user-bar-button-icon-color: $theme-red-100, + $context-header: $theme-red-900, + $active-indicator: $theme-red-900, + $notification-dot: $theme-red-900, + ); +} + +.super-sidebar { display: flex; flex-direction: column; position: fixed; @@ -167,8 +311,12 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; color: var(--super-sidebar-nav-item-icon-color); } + hr { + mix-blend-mode: var(--super-sidebar-hr-mix-blend-mode); + } + .active-indicator { - background-color: var(--super-sidebar-primary); + background-color: var(--super-sidebar-active-indicator-color); } .btn-with-notification { @@ -200,6 +348,10 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } +.super-sidebar-context-header { + color: var(--super-sidebar-context-header-color); +} + .super-sidebar-overlay { display: none; } @@ -408,6 +560,13 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } +.super-sidebar-empty-pinned-text { + mix-blend-mode: multiply; + + .gl-dark & { + mix-blend-mode: screen; + } +} // Styles for the ScrollScrim component. // Should eventually be moved to gitlab-ui. @@ -461,3 +620,17 @@ $scroll-scrim-height: 2.25rem; opacity: 1; } } + +// Tweaks to the styles for the ScrollScrim component above (line 418) +// are leaking into the collapsible list box dropdowns +// https://gitlab.com/gitlab-org/gitlab/-/issues/435538 + +.gl-new-dropdown { + .top-scrim-wrapper { + margin-bottom: 0; + } + + .bottom-scrim-wrapper { + margin-top: 0; + } +} diff --git a/app/assets/stylesheets/framework/top_bar.scss b/app/assets/stylesheets/framework/top_bar.scss new file mode 100644 index 00000000000..d4b36b82584 --- /dev/null +++ b/app/assets/stylesheets/framework/top_bar.scss @@ -0,0 +1,20 @@ +.top-bar-container { + min-height: $top-bar-height; +} + +.top-bar-fixed { + @include gl-inset-border-b-1-gray-100; + background-color: $body-bg; + position: fixed; + left: var(--application-bar-left); + right: var(--application-bar-right); + top: $calc-application-bars-height; + width: calc(100% - var(--application-bar-left)); + z-index: $top-bar-z-index; + + @media (prefers-reduced-motion: no-preference) { + transition: left $gl-transition-duration-medium, + right $gl-transition-duration-medium, + width $gl-transition-duration-medium; + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index eefdbda8f4f..15e794fc347 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -495,7 +495,7 @@ &::after { @include gl-dark-invert-keep-hue; - content: image-url('icon_anchor.svg'); + content: url('icon_anchor.svg'); visibility: hidden; } } @@ -602,6 +602,20 @@ } @include email-code-block; + + &.gl-text-secondary { + color: $gl-text-color-secondary; + + p, + h1, + h2, + h3, + h4, + h5, + table:not(.code) { + color: $gl-text-color-secondary; + } + } } /** diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 23fa1326881..3fd72904655 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -118,7 +118,7 @@ @include gl-w-5; @include gl-h-5; background-color: rgba($color, 0.3); - mask-image: asset_url('icons-stacked.svg##{$icon}'); + mask-image: url('icons-stacked.svg##{$icon}'); mask-repeat: no-repeat; mask-size: cover; mask-position: center; diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 8b353b42f58..05563f8e314 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -105,8 +105,3 @@ @include gl-font-weight-normal; } } - -[data-page="projects:issues:show"] .top-bar-fixed, -[data-page="groups:epics:show"] .top-bar-fixed { - width: 100%; -} diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss index 1ca0c5e7ce6..9084bffa951 100644 --- a/app/assets/stylesheets/page_bundles/issuable_list.scss +++ b/app/assets/stylesheets/page_bundles/issuable_list.scss @@ -90,12 +90,19 @@ .issuable-list li, .issuable-info-container .controls { .avatar-counter { - display: inline-block; - vertical-align: middle; - min-width: 16px; + @include gl-pl-1 + @include gl-pr-2; + @include gl-h-5; + @include gl-min-w-5; line-height: 14px; - height: 16px; - padding-left: 2px; - padding-right: 2px; + } +} + +.merge-request { + .issuable-info-container .controls { + .avatar-counter { + @include gl-line-height-normal; + border: 0; + } } } diff --git a/app/assets/stylesheets/page_bundles/labels.scss b/app/assets/stylesheets/page_bundles/labels.scss index bc0bf4bc490..3204e678986 100644 --- a/app/assets/stylesheets/page_bundles/labels.scss +++ b/app/assets/stylesheets/page_bundles/labels.scss @@ -1,54 +1,5 @@ @import 'mixins_and_variables_and_functions'; -.suggest-colors { - padding-top: 3px; - - a { - border-radius: 4px; - width: 30px; - height: 30px; - display: inline-block; - margin-right: 10px; - margin-bottom: 10px; - text-decoration: none; - - &:focus, - &:focus:active { - position: relative; - z-index: 1; - @include gl-focus; - } - } - - &.suggest-colors-dropdown { - margin-top: 10px; - margin-bottom: 10px; - - a { - border-radius: 0; - width: (100% / 7); - margin-right: 0; - margin-bottom: -5px; - - &:first-of-type { - border-top-left-radius: $gl-border-radius-base; - } - - &:nth-of-type(7) { - border-top-right-radius: $gl-border-radius-base; - } - - &:nth-last-child(7) { - border-bottom-left-radius: $gl-border-radius-base; - } - - &:last-of-type { - border-bottom-right-radius: $gl-border-radius-base; - } - } - } -} - .labels-select-contents-create { .dropdown-input { margin-bottom: 4px; diff --git a/app/assets/stylesheets/page_bundles/login.scss b/app/assets/stylesheets/page_bundles/login.scss index b63f199f7b9..11582ff72f0 100644 --- a/app/assets/stylesheets/page_bundles/login.scss +++ b/app/assets/stylesheets/page_bundles/login.scss @@ -16,43 +16,8 @@ top: 8px; } - .brand-holder { - font-size: 18px; - line-height: 1.5; - - p { - font-size: 16px; - color: $login-brand-holder-color; - } - - h3 { - font-size: 22px; - } - - img { - max-width: 100%; - margin-bottom: 30px; - } - - a { - font-weight: $gl-font-weight-bold; - } - } - - p { - font-size: 13px; - } - - .signin-text { - p { - margin-bottom: 0; - line-height: 1.5; - } - } - .borderless { - .login-box, - .omniauth-container { + .login-box { box-shadow: none; } } @@ -64,67 +29,6 @@ } } - .login-box, - .omniauth-container { - box-shadow: 0 0 0 1px $border-color; - border-radius: $border-radius; - - .login-heading h3 { - font-weight: $gl-font-weight-normal; - line-height: 1.5; - margin: 0 0 10px; - } - - .login-footer { - margin-top: 10px; - - p:last-child { - margin-bottom: 0; - } - } - - a.forgot { - float: right; - padding-top: 6px; - } - - .nav .active a { - background: transparent; - } - - // Styles the glowing border of focused input for username async validation - .login-body { - font-size: 13px; - - .username .validation-success { - color: $green-600; - } - - .username .validation-error { - color: $red-500; - } - - .terms .gl-form-checkbox { - @include gl-reset-font-size; - } - } - } - - .omniauth-container { - border-radius: $border-radius; - font-size: 13px; - - p { - margin: 0; - } - - form { - padding: 0; - border: 0; - background: none; - } - } - .new-session-tabs { &.nav-links-unboxed { border-color: transparent; @@ -143,30 +47,6 @@ border-top-right-radius: $border-radius-default; border-top-left-radius: $border-radius-default; - // Ldap configurations may need more tabs & the tab labels are user generated (arbitrarily long). - // These styles prevent this from breaking the layout, and only applied when providers are configured. - &.custom-provider-tabs { - flex-wrap: wrap; - - li { - min-width: 85px; - flex-basis: auto; - - // This styles tab elements that have wrapped to a second line. We cannot easily predict when this will happen. - // We are making somewhat of an assumption about the configuration here: that users do not have more than - // 3 LDAP servers configured (in addition to standard login) and they are not using especially long names for any - // of them. If either condition is false, this will work as expected. If both are true, there may be a missing border - // above one of the bottom row elements. If you know a better way, please implement it! - &:nth-child(n+5) { - border-top: 1px solid $border-color; - } - } - - a { - font-size: 16px; - } - } - li { flex: 1; text-align: center; @@ -230,11 +110,8 @@ height: 100%; body { - padding-top: 48px; // Remove this line when the restyle_login_page feature flag is deleted. Instead, add self-align `center` to container, and maybe a top margin. - &.with-system-header { padding-top: $system-header-height; - padding-top: calc(#{$system-header-height} + 48px); // Remove this line when the restyle_login_page feature flag is deleted } &.with-system-footer { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index d112fd83ebf..b30ec4b4253 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -259,7 +259,7 @@ $tabs-holder-z-index: 250; position: sticky; top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); // height calc is fully delegated to the tree_list_height.vue component - height: 0; + height: 100%; min-height: 300px; .drag-handle { diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 9bab5d65b59..c729bd7a380 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -130,6 +130,11 @@ .gl-pipeline-job-width { width: 100%; + max-width: 400px; + + .pipeline-graph-container & { + max-width: unset; + } } .gl-pipeline-job-width\! { @@ -318,3 +323,13 @@ background-color: $gray-100; } } + +.scan-reports-summary-grid { + grid-template-columns: 1fr 1fr max-content; +} + +@media (max-width: $breakpoint-sm) { + .scan-reports-summary-grid :nth-child(3n+1) { + grid-column: 1 / -1; + } +} diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index 9a8eeb9c9d6..912f0145bf1 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -164,12 +164,6 @@ .user-profile { .profile-header { - margin: 0 $gl-padding; - - &.with-no-profile-tabs { - margin-bottom: $gl-padding-24; - } - .avatar-holder { margin: 0 auto 10px; } diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss index c2ecf3702f9..bd24d991c8d 100644 --- a/app/assets/stylesheets/page_bundles/project.scss +++ b/app/assets/stylesheets/page_bundles/project.scss @@ -189,10 +189,6 @@ .project-page-sidebar-block { width: $right-sidebar-width - 1px; - - &:first-of-type { - padding-top: $gl-spacing-scale-1; - } } .nav { diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index b145d046fa4..87d0d5b91d3 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -281,14 +281,6 @@ $language-filter-max-height: 20rem; margin-right: 5px; } } - - .dropdown-menu-toggle, - .gl-dropdown { - @include media-breakpoint-up(sm) { - width: 180px; - margin-top: 0; - } - } } .search-page-form { diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index ed2c7662a98..6d85a4da035 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -101,7 +101,7 @@ } .active > .wiki-list { - background-color: $gray-50; + background-color: var(--gray-50, $gray-50); } .wiki-list { @@ -110,7 +110,7 @@ @include gl-rounded-base; &:hover { - background: $gray-50; + background: var(--gray-50, $gray-50); .wiki-list-create-child-button { display: block; @@ -150,10 +150,6 @@ .wiki-sidebar-header { padding: 0 $gl-padding $gl-padding; - - .gutter-toggle { - margin-top: 0; - } } } diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index b9ab2450ff9..5b354f3575c 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -4,6 +4,7 @@ $work-item-field-inset-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-200, $gray-200) !important; $work-item-overview-right-sidebar-width: 23rem; $work-item-sticky-header-height: 52px; +$work-item-overview-gap-width: 2rem; .gl-token-selector-token-container { display: flex; @@ -146,7 +147,7 @@ $work-item-sticky-header-height: 52px; @include media-breakpoint-up(md) { display: grid; grid-template-columns: 1fr $work-item-overview-right-sidebar-width; - gap: 2rem; + gap: $work-item-overview-gap-width; } } @@ -216,6 +217,12 @@ $work-item-sticky-header-height: 52px; } } +.editable-wi-title { + width: 100%; + @include media-breakpoint-up(md) { + width: calc(100% - #{$work-item-overview-right-sidebar-width} - #{$work-item-overview-gap-width}); + } +} // Disclosure hierarchy component, used for Ancestors widget $disclosure-hierarchy-chevron-dimension: 1.2rem; diff --git a/app/assets/stylesheets/pages/colors.scss b/app/assets/stylesheets/pages/colors.scss index 85c1f7da07f..aeaf2d7c1b3 100644 --- a/app/assets/stylesheets/pages/colors.scss +++ b/app/assets/stylesheets/pages/colors.scss @@ -29,3 +29,52 @@ .danger-title { color: var(--red-500, $red-500); } + +.suggest-colors { + padding-top: 3px; + + a { + border-radius: 4px; + width: 30px; + height: 30px; + display: inline-block; + margin-right: 10px; + margin-bottom: 10px; + text-decoration: none; + + &:focus, + &:focus:active { + position: relative; + z-index: 1; + @include gl-focus; + } + } + + &.suggest-colors-dropdown { + margin-top: 10px; + margin-bottom: 10px; + + a { + border-radius: 0; + width: (100% / 7); + margin-right: 0; + margin-bottom: -5px; + + &:first-of-type { + border-top-left-radius: $gl-border-radius-base; + } + + &:nth-of-type(7) { + border-top-right-radius: $gl-border-radius-base; + } + + &:nth-last-child(7) { + border-bottom-left-radius: $gl-border-radius-base; + } + + &:last-of-type { + border-bottom-right-radius: $gl-border-radius-base; + } + } + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 9748983d1ae..f57a8519992 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -225,7 +225,7 @@ ul.related-merge-requests > li gl-emoji { &::after { @include gl-dark-invert-keep-hue; - content: image-url('icon_anchor.svg'); + content: url('icon_anchor.svg'); visibility: hidden; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 8792c7f9a72..da03726fa64 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -88,18 +88,18 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; margin-top: 5px; } - .timeline-content:not(.flash-container) { + .timeline-content { margin-left: 2.5rem; border: 1px solid $border-color; border-radius: $gl-border-radius-base; padding: $gl-padding-4 $gl-padding-8; } - &:not(.target) .timeline-content:not(.flash-container) { + &:not(.target) .timeline-content { background-color: $white; } - &.draft-note .timeline-content:not(.flash-container) { + &.draft-note .timeline-content { border: 0; } @@ -127,7 +127,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; margin-top: 5px; } - .timeline-content:not(.flash-container) { + .timeline-content { margin-left: 2.5rem; border-left: 1px solid $border-color; border-right: 1px solid $border-color; @@ -138,11 +138,11 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } - &:not(.target) .timeline-content:not(.flash-container) { + &:not(.target) .timeline-content { background-color: $white; } - &.draft-note .timeline-content:not(.flash-container) { + &.draft-note .timeline-content { margin-left: 0; border-top-left-radius: 0; border-top-right-radius: 0; @@ -154,7 +154,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; border-right: 1px solid $border-color; background-color: $white; - .timeline-content:not(.flash-container) { + .timeline-content { padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 18px; } @@ -419,7 +419,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .system-note-commit-list-toggler { color: $blue-600; - padding: 10px 0 0; cursor: pointer; position: relative; z-index: 2; @@ -966,7 +965,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .unified-diff-components-diff-note-button { &::before { background-color: $blue-500; - mask-image: asset_url('icons-stacked.svg#comment'); + mask-image: url('icons-stacked.svg#comment'); mask-repeat: no-repeat; mask-size: cover; mask-position: center; @@ -1057,7 +1056,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; padding-left: 0; ul.notes li.note-wrapper { - .timeline-content:not(.flash-container) { + .timeline-content { padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; } @@ -1106,7 +1105,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .draft-note-component.draft-note.timeline-entry { - .timeline-content:not(.flash-container) { + .timeline-content { padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; } diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 315b9c829a7..d6fcfb3461d 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -4,97 +4,99 @@ @import '@gitlab/ui/src/scss/variables'; @import '@gitlab/ui/src/scss/utility-mixins/index'; -.md h1, -.md h2, -.md h3, -.md h4, -.md h5, -.md h6 { - margin-top: 17px; -} +@media print { + .md h1, + .md h2, + .md h3, + .md h4, + .md h5, + .md h6 { + margin-top: 17px; + } -.md h1 { - font-size: 30px; -} + .md h1 { + font-size: 30px; + } -.md h2 { - font-size: 22px; -} + .md h2 { + font-size: 22px; + } -.md h3 { - font-size: 18px; - font-weight: 600; -} + .md h3 { + font-size: 18px; + font-weight: 600; + } -.md { - print-color-adjust: exact; - -webkit-print-color-adjust: exact; + .md { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; - // fix blockquote style in print - blockquote { - &::before { - position: absolute; - top: 0; - left: -4px; - content: ' '; - height: 100%; - width: 4px; - background-color: $gray-100; - } + // fix blockquote style in print + blockquote { + &::before { + position: absolute; + top: 0; + left: -4px; + content: ' '; + height: 100%; + width: 4px; + background-color: $gray-100; + } - position: relative; - font-size: inherit; - @include gl-text-gray-700; - @include gl-py-3; - @include gl-pl-6; - @include gl-my-3; - @include gl-mx-0; - @include gl-inset-border-l-4-gray-100; - margin-left: 4px; - border: 0 !important; + position: relative; + font-size: inherit; + @include gl-text-gray-700; + @include gl-py-3; + @include gl-pl-6; + @include gl-my-3; + @include gl-mx-0; + @include gl-inset-border-l-4-gray-100; + margin-left: 4px; + border: 0 !important; + } } -} -header, -nav, -.nav-sidebar, -.super-sidebar, -.profiler-results, -.tree-ref-holder, -.tree-holder .breadcrumb, -.nav, -.btn, -ul.notes-form, -.issuable-gutter-toggle, -.gutter-toggle, -.issuable-details .content-block-small, -.edit-link, -.note-action-button, -.right-sidebar, -.flash-container, -copy-code, -#js-peek { - display: none !important; -} + header, + nav, + .nav-sidebar, + .super-sidebar, + .profiler-results, + .tree-ref-holder, + .tree-holder .breadcrumb, + .nav, + .btn, + ul.notes-form, + .issuable-gutter-toggle, + .gutter-toggle, + .issuable-details .content-block-small, + .edit-link, + .note-action-button, + .right-sidebar, + .flash-container, + copy-code, + #js-peek { + display: none !important; + } -pre { - page-break-before: avoid; - page-break-inside: auto; -} + pre { + page-break-before: avoid; + page-break-inside: auto; + } -.page-gutter { - padding-top: 0; - padding-left: 0; -} + .page-gutter { + padding-top: 0; + padding-left: 0; + } -.right-sidebar { - top: 0; -} + .right-sidebar { + top: 0; + } -a[href]::after { - content: none !important; -} + a[href]::after { + content: none !important; + } -.with-performance-bar .layout-page { - padding-top: 0; + .with-performance-bar .layout-page { + padding-top: 0; + } } diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index 91b381462be..e1b14df683e 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -15,8 +15,7 @@ .gl-snippet-icon { display: inline-block; - /* stylelint-disable-next-line function-url-quotes */ - background: url(asset_path('ext_snippet_icons/ext_snippet_icons.png')) no-repeat; + background: url('ext_snippet_icons/ext_snippet_icons.png') no-repeat; overflow: hidden; text-align: left; width: 16px; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 3ab3e195b06..59c2391d2e9 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -1,7 +1,6 @@ @import './themes/dark'; @import '@gitlab/ui/dist/tokens/css/tokens.dark'; @import 'page_bundles/mixins_and_variables_and_functions'; -@import './themes/theme_helper'; :root { color-scheme: dark; diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss deleted file mode 100644 index 1a373fbfeda..00000000000 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-blue { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-blue-50, - $theme-blue-100, - $theme-blue-900, - $theme-blue-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss deleted file mode 100644 index 9a24142f286..00000000000 --- a/app/assets/stylesheets/themes/theme_gray.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-gray { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $gray-50, - $gray-100, - $gray-900, - $gray-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss deleted file mode 100644 index a766fdddc78..00000000000 --- a/app/assets/stylesheets/themes/theme_green.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-green { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-green-50, - $theme-green-100, - $theme-green-900, - $theme-green-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss deleted file mode 100644 index c94a32891f6..00000000000 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ /dev/null @@ -1,36 +0,0 @@ -@import '../page_bundles/mixins_and_variables_and_functions'; -/** - * Styles the GitLab application with a specific color theme - */ -@mixin gitlab-theme-super-sidebar( - $theme-color-lightest, - $theme-color-light, - $theme-color, - $theme-color-darkest, -) { - .super-sidebar { - --super-sidebar-bg: #{mix(white, $theme-color-lightest, 50%)}; - --super-sidebar-user-bar-bg: #{$theme-color}; - --super-sidebar-primary: #{$theme-color}; - --super-sidebar-notification-dot: #{$theme-color-darkest}; - - --super-sidebar-user-bar-button-bg: #{$t-white-a-16}; - --super-sidebar-user-bar-button-color: #{$theme-color-lightest}; - --super-sidebar-user-bar-button-border-color: #{$t-white-a-16}; - --super-sidebar-user-bar-button-hover-bg: #{$t-white-a-24}; - --super-sidebar-user-bar-button-hover-color: #{$white}; - --super-sidebar-user-bar-button-active-bg: #{$t-white-a-36}; - - --super-sidebar-user-bar-button-icon-color: #{$theme-color-light}; - --super-sidebar-user-bar-button-icon-hover-color: #{$theme-color-light}; - --super-sidebar-user-bar-button-icon-mix-blend-mode: screen; - - hr { - mix-blend-mode: multiply; - } - - .super-sidebar-context-header { - color: var(--super-sidebar-primary); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss deleted file mode 100644 index d0a8d597b59..00000000000 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-indigo { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-indigo-50, - $theme-indigo-100, - $theme-indigo-900, - $theme-indigo-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss deleted file mode 100644 index e712b6ae859..00000000000 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-light-blue { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-light-blue-50, - $theme-light-blue-100, - $theme-light-blue-700, - $theme-light-blue-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss deleted file mode 100644 index 5cb9bee37b0..00000000000 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ /dev/null @@ -1,2 +0,0 @@ -// "Light gray" is the default unthemed state of the sidebar. -// Nothing to do here. diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss deleted file mode 100644 index 44e19b02e36..00000000000 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-light-green { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-green-50, - $theme-green-100, - $theme-green-700, - $theme-green-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss deleted file mode 100644 index ab299ca9d84..00000000000 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-light-indigo { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-indigo-50, - $theme-indigo-100, - $theme-indigo-700, - $theme-indigo-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss deleted file mode 100644 index 499cdace772..00000000000 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-light-red { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-light-red-50, - $theme-light-red-100, - $theme-light-red-700, - $theme-light-red-900, - ); - } - } -} diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss deleted file mode 100644 index 9a17f98aa80..00000000000 --- a/app/assets/stylesheets/themes/theme_red.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import './theme_helper'; - -:root { - &.ui-red { - .page-with-super-sidebar { - @include gitlab-theme-super-sidebar( - $theme-red-50, - $theme-red-100, - $theme-red-900, - $theme-red-900, - ); - } - } -} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 79ea8d3cc70..7ae17f4c191 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -136,3 +136,13 @@ .gl-last-of-type-border-b-0:last-of-type { @include gl-border-b-0; } + +.gl-md-h-9 { + @include gl-media-breakpoint-up(md) { + height: $gl-spacing-scale-9; + } +} + +.gl-pl-12 { + padding-left: $gl-spacing-scale-12; +} diff --git a/app/assets/stylesheets/vendors/_index.scss b/app/assets/stylesheets/vendors/_index.scss new file mode 100644 index 00000000000..e26ba23d1b9 --- /dev/null +++ b/app/assets/stylesheets/vendors/_index.scss @@ -0,0 +1 @@ +@import './atwho'; diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb index 423934b6887..0821818103a 100644 --- a/app/components/pajamas/avatar_component.rb +++ b/app/components/pajamas/avatar_component.rb @@ -1,16 +1,21 @@ # frozen_string_literal: true module Pajamas + AvatarEmail = Struct.new(:email) do + def name + email + end + end class AvatarComponent < Pajamas::Component include Gitlab::Utils::StrongMemoize - # @param record [User, Project, Group] + # @param item [User, Project, Group, AvatarEmail] # @param alt [String] text for the alt tag # @param class [String] custom CSS class(es) # @param size [Integer] size in pixel # @param [Hash] avatar_options - def initialize(record, alt: nil, class: "", size: 64, avatar_options: {}) - @record = record + def initialize(item, alt: nil, class: "", size: 64, avatar_options: {}) + @item = item @alt = alt @class = binding.local_variable_get(:class) @size = filter_attribute(size.to_i, SIZE_OPTIONS, default: 64) @@ -23,11 +28,11 @@ module Pajamas def avatar_classes classes = ["gl-avatar", "gl-avatar-s#{@size}", @class] - classes.push("gl-avatar-circle") if @record.is_a?(User) + classes.push("gl-avatar-circle") if @item.is_a?(User) || @item.is_a?(AvatarEmail) unless src classes.push("gl-avatar-identicon") - classes.push("gl-avatar-identicon-bg#{((@record.id || 0) % 7) + 1}") + classes.push("gl-avatar-identicon-bg#{((@item.id || 0) % 7) + 1}") end classes.join(' ') @@ -35,7 +40,7 @@ module Pajamas def src strong_memoize(:src) do - if @record.is_a?(User) + if @item.is_a?(User) # Users show a gravatar instead of an identicon. Also avatars of # blocked users are only shown if the current_user is an admin. # To not duplicate this logic, we are using existing helpers here. @@ -44,9 +49,11 @@ module Pajamas rescue StandardError nil end - helpers.avatar_icon_for_user(@record, @size, current_user: current_user) - elsif @record.try(:avatar_url) - "#{@record.avatar_url}?width=#{@size}" + helpers.avatar_icon_for_user(@item, @size, current_user: current_user) + elsif @item.is_a?(AvatarEmail) + helpers.avatar_icon_for_email(@item.email, @size) + elsif @item.try(:avatar_url) + "#{@item.avatar_url}?width=#{@size}" end end end @@ -59,11 +66,11 @@ module Pajamas end def alt - @alt || @record.name + @alt || @item.name end def initial - @record.name[0, 1].upcase + @item.name[0, 1].upcase end end end diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb index 5e37777eb61..ef5c8a34bf5 100644 --- a/app/components/projects/ml/models_index_component.rb +++ b/app/components/projects/ml/models_index_component.rb @@ -3,11 +3,16 @@ module Projects module Ml class ModelsIndexComponent < ViewComponent::Base - attr_reader :paginator, :model_count + include Rails.application.routes.url_helpers + include API::Helpers::RelatedResourcesHelpers - def initialize(paginator:, model_count:) + attr_reader :paginator, :model_count, :project, :user + + def initialize(project:, current_user:, paginator:, model_count:) + @project = project @paginator = paginator @model_count = model_count + @user = current_user end private @@ -16,7 +21,10 @@ module Projects vm = { models: models_view_model, page_info: page_info_view_model, - model_count: model_count + model_count: model_count, + create_model_path: create_model_path, + can_write_model_registry: user.can?(:write_model_registry, project), + mlflow_tracking_url: mlflow_tracking_url } Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) }) @@ -35,6 +43,10 @@ module Projects end end + def create_model_path + new_project_ml_model_path(project) + end + def page_info_view_model { has_next_page: paginator.has_next_page?, @@ -43,6 +55,14 @@ module Projects end_cursor: paginator.cursor_for_next_page } end + + def mlflow_tracking_url + path = api_v4_projects_ml_mlflow_api_2_0_mlflow_registered_models_create_path(id: project.id) + + path = path.delete_suffix('registered-models/create') + + expose_url(path) + end end end end diff --git a/app/components/projects/ml/show_ml_model_component.rb b/app/components/projects/ml/show_ml_model_component.rb index 11a36a78b18..424c8e262e2 100644 --- a/app/components/projects/ml/show_ml_model_component.rb +++ b/app/components/projects/ml/show_ml_model_component.rb @@ -31,11 +31,12 @@ module Projects def latest_version_view_model return unless model.latest_version - model_version = model.latest_version + model_version = model.latest_version.present { version: model_version.version, description: model_version.description, + path: model_version.path, project_path: project_path(model_version.project), package_id: model_version.package_id, **::Ml::CandidateDetailsPresenter.new(model_version.candidate, current_user).present diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb index 4ab67e54766..1085de6fa05 100644 --- a/app/controllers/admin/ci/variables_controller.rb +++ b/app/controllers/admin/ci/variables_controller.rb @@ -44,7 +44,7 @@ module Admin end def variable_params_attributes - %i[id variable_type key secret_value protected masked raw _destroy] + %i[id variable_type key description secret_value protected masked raw _destroy] end end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index ee78d5a8c35..3a0618c0d40 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -9,6 +9,10 @@ class Admin::UsersController < Admin::ApplicationController before_action :ensure_destroy_prerequisites_met, only: [:destroy] before_action :set_shared_view_parameters, only: [:show, :projects, :keys] + before_action only: [:index] do + push_frontend_feature_flag(:simplified_badges) + end + feature_category :user_management PAGINATION_WITH_COUNT_LIMIT = 1000 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fca3bb3460f..d7b005d03b5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -26,7 +26,6 @@ class ApplicationController < BaseActionController include CheckRateLimit include RequestPayloadLogger - before_action :limit_session_time, if: -> { !current_user } before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? before_action :check_password_expiration, if: :html_request? @@ -51,7 +50,6 @@ class ApplicationController < BaseActionController around_action :set_current_admin after_action :set_page_title_header, if: :json_request? - after_action :ensure_authenticated_session_time, if: -> { current_user } protect_from_forgery with: :exception, prepend: true diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb index 2efea461a35..c55911eed48 100644 --- a/app/controllers/concerns/confirm_email_warning.rb +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -38,6 +38,6 @@ module ConfirmEmailWarning end def email_to_display - html_escape(email) + ERB::Util.html_escape(email) end end diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 24475909b62..81130fcd6a6 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -46,15 +46,11 @@ module EnforcesTwoFactorAuthentication end # rubocop: disable CodeReuse/ActiveRecord - def two_factor_authentication_reason(global: -> {}, group: -> {}) - if two_factor_authentication_required? - if Gitlab::CurrentSettings.require_two_factor_authentication? - global.call - else - groups = current_user.source_groups_of_two_factor_authentication_requirement.reorder(name: :asc) - group.call(groups) - end - end + def execute_action_for_2fa_reason(actions) + reason = two_factor_verifier.two_factor_authentication_reason + groups_enforcing_two_factor = current_user.source_groups_of_two_factor_authentication_requirement + .reorder(name: :asc) + actions[reason].call(groups_enforcing_two_factor) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index e344e0dcd8c..d71ab98c3fd 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -38,6 +38,9 @@ module Integrations :default_irc_uri, :device, :disable_diffs, + :diffblue_access_token_name, + :diffblue_access_token_secret, + :diffblue_license_key, :drone_url, :enable_ssl_verification, :external_wiki_url, diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 7f1b961e92a..8bd120b5ed5 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -44,6 +44,7 @@ module PreviewMarkdown when 'groups' then { group: group, issuable_reference_expansion_enabled: true } when 'projects' then projects_filter_params when 'timeline_events' then timeline_events_filter_params + when 'organizations' then { pipeline: :description } else {} end.merge( requested_path: params[:path], diff --git a/app/controllers/explore/catalog_controller.rb b/app/controllers/explore/catalog_controller.rb index d384ad10c86..39c43182fbf 100644 --- a/app/controllers/explore/catalog_controller.rb +++ b/app/controllers/explore/catalog_controller.rb @@ -6,7 +6,7 @@ module Explore feature_category :pipeline_composition before_action :check_resource_access, only: :show - track_internal_event :index, name: 'unique_users_visiting_ci_catalog' + track_internal_event :index, name: 'unique_users_visiting_ci_catalog', conditions: :current_user before_action do push_frontend_feature_flag(:ci_catalog_components_tab, current_user) end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 1941920325f..e39f1148cf2 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -256,8 +256,7 @@ class GraphqlController < ApplicationController def authorize_access_api! if current_user.nil? && - request_authenticator.authentication_token_present? && - Feature.enabled?(:invalid_graphql_auth_401) + request_authenticator.authentication_token_present? render_error('Invalid token', status: :unauthorized) end diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 7a490b34511..191720f69a0 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -10,7 +10,7 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController urgency :low, [:issues, :labels, :milestones, :commands, :merge_requests, :members] def members - render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) + render json: ::Groups::ParticipantsService.new(@group, current_user, params).execute(target) end def issues diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 7cc0e6a8558..eb3661ea3d7 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 do push_frontend_feature_flag(:board_multi_select, group) - push_frontend_feature_flag(:apollo_boards, group) push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, group) experiment(:prominent_create_board_btn, subject: current_user) do |e| e.control {} diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index fad3a6ab9f5..d27d70dc857 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -50,7 +50,7 @@ module Groups end def variable_params_attributes - %i[id variable_type key secret_value protected masked raw _destroy] + %i[id variable_type key description secret_value protected masked raw _destroy] end def authorize_admin_build! diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 5b9b3b7de11..b151793ad8b 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -305,7 +305,8 @@ class GroupsController < Groups::ApplicationController :prevent_sharing_groups_outside_hierarchy, :setup_for_company, :jobs_to_be_done, - :crm_enabled + :crm_enabled, + :enable_namespace_descendants_cache ] + [group_feature_attributes: group_feature_attributes] end diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index ba2743e1002..01657df28fd 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -49,6 +49,9 @@ class Import::BitbucketServerController < Import::BaseController session[bitbucket_server_username_key] = params[:bitbucket_server_username] session[bitbucket_server_url_key] = params[:bitbucket_server_url] + experiment(:default_to_import_tab, actor: current_user) + .track(:authentication, property: provider_name) + redirect_to status_import_bitbucket_server_path(namespace_id: params[:namespace_id]) end diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index e211ea70a56..6ff0f55d2f6 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -6,10 +6,6 @@ class Import::BulkImportsController < ApplicationController before_action :ensure_bulk_import_enabled before_action :verify_blocked_uri, only: :status - before_action only: [:history] do - push_frontend_feature_flag(:bulk_import_details_page) - end - feature_category :importers urgency :low @@ -53,9 +49,7 @@ class Import::BulkImportsController < ApplicationController end end - def details - render_404 unless Feature.enabled?(:bulk_import_details_page) - end + def details; end def create return render json: { success: false }, status: :too_many_requests if throttled_request? diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 34fdf513313..05ba317057d 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -22,6 +22,9 @@ class Import::FogbugzController < Import::BaseController session[:fogbugz_token] = res.get_token.to_s session[:fogbugz_uri] = params[:uri] + experiment(:default_to_import_tab, actor: current_user) + .track(:successfully_authenticated, property: provider_name) + redirect_to new_user_map_import_fogbugz_path(namespace_id: params[:namespace_id]) end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 2b72ceceb5a..0159c1913af 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -41,6 +41,9 @@ class Import::GithubController < Import::BaseController end def personal_access_token + experiment(:default_to_import_tab, actor: current_user) + .track(:authentication, property: provider_name) + session[access_token_key] = params[:personal_access_token]&.strip redirect_to status_import_url end diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index d1b182a57d8..71d66dc3db8 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -21,6 +21,9 @@ class Import::GitlabProjectsController < Import::BaseController @project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute if @project.saved? + experiment(:default_to_import_tab, actor: current_user) + .track(:successfully_imported, property: 'gitlab_export') + redirect_to( project_path(@project), notice: _("Project '%{project_name}' is being imported.") % { project_name: @project.name } diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb index 03884717e54..7d3c91a7f5c 100644 --- a/app/controllers/import/manifest_controller.rb +++ b/app/controllers/import/manifest_controller.rb @@ -31,6 +31,9 @@ class Import::ManifestController < Import::BaseController if manifest.valid? manifest_import_metadata.save(manifest.projects, group.id) + experiment(:default_to_import_tab, actor: current_user) + .track(:successfully_imported, property: provider_name) + redirect_to status_import_manifest_path else @errors = manifest.errors diff --git a/app/controllers/jwks_controller.rb b/app/controllers/jwks_controller.rb index 2e030cf46c4..fb190846ffa 100644 --- a/app/controllers/jwks_controller.rb +++ b/app/controllers/jwks_controller.rb @@ -2,9 +2,7 @@ class JwksController < Doorkeeper::OpenidConnect::DiscoveryController def index - if ::Feature.enabled?(:cache_control_headers_for_openid_jwks) - expires_in 24.hours, public: true, must_revalidate: true, 'no-transform': true - end + expires_in 24.hours, public: true, must_revalidate: true, 'no-transform': true render json: { keys: payload } end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index 955dfe58449..1c79bd3a668 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -28,7 +28,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController define_providers! override :set_remember_me - def set_remember_me(user) + def set_remember_me(user, _auth_user) user.remember_me = params[:remember_me] if user.persisted? end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 907ece1a06e..0701b1ee977 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -139,9 +139,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session) link_identity(identity_linker) - set_remember_me(current_user) - store_idp_two_factor_status(build_auth_user(auth_module::User).bypass_two_factor?) + current_auth_user = build_auth_user(auth_module::User) + set_remember_me(current_user, current_auth_user) + + store_idp_two_factor_status(current_auth_user.bypass_two_factor?) if identity_linker.changed? redirect_identity_linked @@ -193,7 +195,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController track_event(@user, oauth['provider'], 'succeeded') Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: @user) if new_user - set_remember_me(@user) + set_remember_me(@user, auth_user) set_session_active_since(oauth['provider']) if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym) if @user.two_factor_enabled? && !auth_user.bypass_two_factor? @@ -278,10 +280,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController .for_authentication.security_event end - def set_remember_me(user) + def set_remember_me(user, auth_user) return unless remember_me? - if user.two_factor_enabled? + if user.two_factor_enabled? && !auth_user.bypass_two_factor? params[:remember_me] = '1' else remember_me(user) diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb index 9f09627b1e4..0596441591d 100644 --- a/app/controllers/organizations/organizations_controller.rb +++ b/app/controllers/organizations/organizations_controller.rb @@ -2,9 +2,11 @@ module Organizations class OrganizationsController < ApplicationController + include PreviewMarkdown + feature_category :cell - skip_before_action :authenticate_user!, except: [:index, :new, :users] + skip_before_action :authenticate_user!, only: [:show, :groups_and_projects] def index; end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index f1646027e8e..5a956a14552 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -207,15 +207,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def setup_show_page if two_factor_authentication_required? && !current_user.two_factor_enabled? - two_factor_authentication_reason( - global: lambda do + two_factor_auth_actions = { + global: lambda do |_| flash.now[:alert] = _('The global settings require you to enable Two-Factor Authentication for your account.') end, + admin_2fa: lambda do |_| + flash.now[:alert] = _('Administrator users are required to enable Two-Factor Authentication for their account.') + end, group: lambda do |groups| flash.now[:alert] = groups_notification(groups) end - ) + } + execute_action_for_2fa_reason(two_factor_auth_actions) unless two_factor_grace_period_expired? grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index ff3484d3020..dc10004c62b 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -15,7 +15,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController urgency :low, [:issues, :labels, :milestones, :commands, :contacts] def members - render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) + render json: ::Projects::ParticipantsService.new(@project, current_user, params).execute(target) end def issues diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index fd853b5aaed..29bc00ae870 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 do push_frontend_feature_flag(:board_multi_select, project) - push_frontend_feature_flag(:apollo_boards, project) push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project) experiment(:prominent_create_board_btn, subject: current_user) do |e| e.control {} diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 88e9113188a..c36742e8bb9 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -186,7 +186,6 @@ class Projects::CommitController < Projects::ApplicationController opts[:use_extra_viewer_as_main] = false @diffs = commit.diffs(opts) - @notes_count = commit.notes.count @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 8cdd6efa7c5..65cbe5a78ce 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -26,7 +26,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } - before_action :set_kas_cookie, only: [:index, :edit, :new], if: -> { current_user && request.format.html? } + before_action :set_kas_cookie, only: [:index, :folder, :edit, :new], if: -> { current_user && request.format.html? } after_action :expire_etag_cache, only: [:cancel_auto_stop] track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal, diff --git a/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb b/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb index b88b86975a4..60adbbe6e5d 100644 --- a/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb +++ b/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb @@ -25,7 +25,7 @@ module Projects private def service - ::Integrations::GoogleCloudPlatform::ArtifactRegistry::ListDockerImagesService.new( + ::GoogleCloudPlatform::ArtifactRegistry::ListDockerImagesService.new( project: @project, current_user: current_user, params: { @@ -124,6 +124,10 @@ module Projects Time.zone.parse(upload_time) end + + def details_url + "https://#{uri}" + end end end end diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb index d35b2d54c53..3baa1210ec2 100644 --- a/app/controllers/projects/google_cloud/configuration_controller.rb +++ b/app/controllers/projects/google_cloud/configuration_controller.rb @@ -8,7 +8,7 @@ module Projects configurationUrl: project_google_cloud_configuration_path(project), deploymentsUrl: project_google_cloud_deployments_path(project), databasesUrl: project_google_cloud_databases_path(project), - serviceAccounts: ::GoogleCloud::ServiceAccountsService.new(project).find_for_project, + serviceAccounts: ::CloudSeed::GoogleCloud::ServiceAccountsService.new(project).find_for_project, createServiceAccountUrl: project_google_cloud_service_accounts_path(project), emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/empty-state/empty-pipeline-md.svg'), diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb index ea79efd9f4f..9023b8a5fa6 100644 --- a/app/controllers/projects/google_cloud/databases_controller.rb +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -14,7 +14,7 @@ module Projects cloudsqlPostgresUrl: new_project_google_cloud_database_path(project, :postgres), cloudsqlMysqlUrl: new_project_google_cloud_database_path(project, :mysql), cloudsqlSqlserverUrl: new_project_google_cloud_database_path(project, :sqlserver), - cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute, + cloudsqlInstances: ::CloudSeed::GoogleCloud::GetCloudsqlInstancesService.new(project).execute, emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/empty-state/empty-pipeline-md.svg') } @@ -46,7 +46,7 @@ module Projects end def create - enable_response = ::GoogleCloud::EnableCloudsqlService + enable_response = ::CloudSeed::GoogleCloud::EnableCloudsqlService .new(project, current_user, enable_service_params) .execute @@ -54,7 +54,7 @@ module Projects track_event(:error_enable_cloudsql_services) flash[:alert] = error_message(enable_response[:message]) else - create_response = ::GoogleCloud::CreateCloudsqlInstanceService + create_response = ::CloudSeed::GoogleCloud::CreateCloudsqlInstanceService .new(project, current_user, create_service_params) .execute diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index 92c99ad4271..e4666f9335c 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -17,7 +17,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base def cloud_run params = { google_oauth2_token: token_in_session } - enable_cloud_run_response = GoogleCloud::EnableCloudRunService + enable_cloud_run_response = CloudSeed::GoogleCloud::EnableCloudRunService .new(project, current_user, params).execute if enable_cloud_run_response[:status] == :error @@ -25,8 +25,8 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base flash[:alert] = enable_cloud_run_response[:message] redirect_to project_google_cloud_deployments_path(project) else - params = { action: GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } - generate_pipeline_response = GoogleCloud::GeneratePipelineService + params = { action: CloudSeed::GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } + generate_pipeline_response = CloudSeed::GoogleCloud::GeneratePipelineService .new(project, current_user, params).execute if generate_pipeline_response[:status] == :error diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb index c51261721b2..593e27eeebf 100644 --- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -20,7 +20,7 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC def create permitted_params = params.permit(:ref, :gcp_region) - GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) + CloudSeed::GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) track_event(:configure_region) redirect_to project_google_cloud_configuration_path(project), notice: _('GCP region configured') end diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index 7b029e25ea2..5a5f53943c0 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -27,7 +27,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: def create permitted_params = params.permit(:gcp_project, :ref) - response = GoogleCloud::CreateServiceAccountsService.new( + response = CloudSeed::GoogleCloud::CreateServiceAccountsService.new( project, current_user, google_oauth2_token: token_in_session, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d0eabf8d837..c1de24f300b 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -71,6 +71,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project) push_force_frontend_feature_flag(:linked_work_items, project.linked_work_items_feature_flag_enabled?) push_frontend_feature_flag(:notifications_todos_buttons, current_user) + push_frontend_feature_flag(:mention_autocomplete_backend_filtering, project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index b269d41fa77..c62a1e09c00 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -9,11 +9,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic before_action :commit before_action :define_diff_vars before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata] - before_action :update_diff_discussion_positions! + before_action :update_diff_discussion_positions!, except: [:diff_by_file_hash] around_action :allow_gitaly_ref_name_caching - after_action :track_viewed_diffs_events, only: [:diffs_batch, :diff_for_path] + after_action :track_viewed_diffs_events, only: [:diffs_batch, :diff_for_path, :diff_by_file_hash] urgency :low, [ :show, @@ -26,6 +26,14 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render_diffs end + def diff_by_file_hash + diff_file = @compare.diffs.diff_files.find { |file| file.file_hash == params[:file_hash] } + params[:old_path] = diff_file&.old_path + params[:new_path] = diff_file&.new_path + + render_diffs + end + def diff_for_path render_diffs end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 0899e303305..6cb00fea922 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -46,6 +46,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:notifications_todos_buttons, current_user) push_frontend_feature_flag(:mr_request_changes, current_user) push_frontend_feature_flag(:merge_blocked_component, current_user) + push_frontend_feature_flag(:mention_autocomplete_backend_filtering, project) + push_frontend_feature_flag(:pinned_file, project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] @@ -448,6 +450,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @update_current_user_path = expose_path(api_v4_user_preferences_path) @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) @endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request) + if params[:pin] && Feature.enabled?(:pinned_file) + @pinned_file_url = diff_by_file_hash_namespace_project_merge_request_path( + format: 'json', + id: merge_request.iid, + namespace_id: project&.namespace.to_param, + project_id: project&.path, + file_hash: params[:pin] + ) + end if merge_request.diffs_batch_cache_with_max_age? @diffs_batch_cache_key = @merge_request.merge_head_diff&.patch_id_sha diff --git a/app/controllers/projects/ml/models_controller.rb b/app/controllers/projects/ml/models_controller.rb index 68a8b7a1686..2dff3ec3325 100644 --- a/app/controllers/projects/ml/models_controller.rb +++ b/app/controllers/projects/ml/models_controller.rb @@ -4,7 +4,7 @@ module Projects module Ml class ModelsController < ::Projects::ApplicationController before_action :authorize_read_model_registry! - before_action :authorize_write_model_registry!, only: [:destroy] + before_action :authorize_write_model_registry!, only: [:destroy, :new] before_action :set_model, only: [:show, :destroy] feature_category :mlops @@ -22,6 +22,8 @@ module Projects @model_count = finder.count end + def new; end + def show; end def destroy diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 278d306301a..e52e13e8ce6 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -42,7 +42,7 @@ class Projects::RefsController < Projects::ApplicationController redirect_to new_path end end - rescue Gitlab::PathTraversal::PathTraversalAttackError + rescue Gitlab::PathTraversal::PathTraversalAttackError, ActionController::UrlGenerationError head :bad_request end diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb index ee2e60b5a1a..abf564a00e1 100644 --- a/app/controllers/projects/security/configuration_controller.rb +++ b/app/controllers/projects/security/configuration_controller.rb @@ -24,11 +24,7 @@ module Projects private def configuration - if unify_configuration_enabled? - configuration_presenter - else - {} - end + configuration_presenter end def configuration_presenter @@ -38,10 +34,6 @@ module Projects def presenter_attributes {} end - - def unify_configuration_enabled? - Feature.enabled?(:unify_security_configuration, project) - end end end end diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb index 76c9cead360..5c352866c8d 100644 --- a/app/controllers/projects/settings/packages_and_registries_controller.rb +++ b/app/controllers/projects/settings/packages_and_registries_controller.rb @@ -7,6 +7,7 @@ module Projects before_action :authorize_admin_project! before_action :packages_and_registries_settings_enabled! + before_action :set_feature_flag_packages_protected_packages, only: :show feature_category :package_registry urgency :low @@ -30,6 +31,10 @@ module Projects render_404 unless Gitlab.config.registry.enabled && can?(current_user, :admin_container_image, project) end + + def set_feature_flag_packages_protected_packages + push_frontend_feature_flag(:packages_protected_packages, project) + end end end end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 38b23b24c9a..6a10d603ad7 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -7,6 +7,10 @@ module Projects before_action :authorize_admin_project! before_action :define_variables, only: [:create_deploy_token] + before_action do + push_frontend_feature_flag(:add_branch_rule, @project) + end + feature_category :source_code_management, [:show, :cleanup, :update] feature_category :continuous_delivery, [:create_deploy_token] urgency :low, [:show, :create_deploy_token] diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index f7542d68642..29ecca1b7e0 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -47,6 +47,6 @@ class Projects::VariablesController < Projects::ApplicationController end def variable_params_attributes - %i[id variable_type key secret_value protected masked raw environment_scope _destroy] + %i[id variable_type key description secret_value protected masked raw environment_scope _destroy] end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1152bdcf058..d4b77c588dc 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -29,7 +29,7 @@ class ProjectsController < Projects::ApplicationController before_action :authorize_read_code!, only: [:refs] # Authorize - before_action :authorize_admin_project_or_custom_permissions!, only: :edit + before_action :authorize_view_edit_page!, only: :edit before_action :authorize_admin_project!, only: [:update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] before_action :authorize_archive_project!, only: [:archive, :unarchive] before_action :event_filter, only: [:show, :activity] @@ -44,6 +44,7 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:explain_code_chat, current_user) push_frontend_feature_flag(:issue_email_participants, @project) push_frontend_feature_flag(:encoding_logs_tree) + push_frontend_feature_flag(:add_branch_rule, @project) # TODO: We need to remove the FF eventually when we rollout page_specific_styles push_frontend_feature_flag(:page_specific_styles, current_user) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) @@ -87,8 +88,14 @@ class ProjectsController < Projects::ApplicationController @parent_group = Group.find_by(id: params[:namespace_id]) + manageable_groups_count = current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count + + if manageable_groups_count == 0 && !can?(current_user, :create_projects, current_user.namespace) + return access_denied! + end + @current_user_group = - if current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count == 1 + if manageable_groups_count == 1 current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).first end @@ -612,11 +619,6 @@ class ProjectsController < Projects::ApplicationController def render_edit render 'edit' end - - # Overridden in EE - def authorize_admin_project_or_custom_permissions! - authorize_admin_project! - end end ProjectsController.prepend_mod_with('ProjectsController') diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 64d9db41a1b..896b71d2822 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -175,7 +175,7 @@ class SearchController < ApplicationController return false unless commit.present? link = search_path(safe_params.merge(force_search_results: true)) - flash[:notice] = html_escape(_("You have been redirected to the only result; see the %{a_start}search results%{a_end} instead.")) % { a_start: "<a href=\"#{link}\"><u>".html_safe, a_end: '</u></a>'.html_safe } + flash[:notice] = ERB::Util.html_escape(_("You have been redirected to the only result; see the %{a_start}search results%{a_end} instead.")) % { a_start: "<a href=\"#{link}\"><u>".html_safe, a_end: '</u></a>'.html_safe } redirect_to project_commit_path(@project, commit) true diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 6d3811514d9..94e114e7da8 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -16,6 +16,7 @@ class UploadsController < ApplicationController "projects/topic" => Projects::Topic, 'alert_management_metric_image' => ::AlertManagement::MetricImage, "achievements/achievement" => Achievements::Achievement, + "organizations/organization_detail" => Organizations::OrganizationDetail, "abuse_report" => AbuseReport, nil => PersonalSnippet }.freeze @@ -65,6 +66,8 @@ class UploadsController < ApplicationController can?(current_user, :read_alert_management_metric_image, model.alert) when ::Achievements::Achievement true + when Organizations::OrganizationDetail + can?(current_user, :read_organization, model.organization) else can?(current_user, "read_#{model.class.underscore}".to_sym, model) end @@ -96,7 +99,7 @@ class UploadsController < ApplicationController def cache_settings case model - when User, Appearance, Projects::Topic, Achievements::Achievement + when User, Appearance, Projects::Topic, Achievements::Achievement, Organizations::OrganizationDetail [5.minutes, { public: true, must_revalidate: false }] when Project, Group [5.minutes, { private: true, must_revalidate: true }] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 88a8851607b..83cd84c396a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -261,7 +261,8 @@ class UsersController < ApplicationController end def load_groups - @groups = JoinedGroupsFinder.new(user).execute(current_user) + groups = JoinedGroupsFinder.new(user).execute(current_user) + @groups = groups.with_route.page(params[:page]).without_count prepare_groups_for_rendering(@groups) end diff --git a/app/events/ci/job_artifacts_deleted_event.rb b/app/events/ci/job_artifacts_deleted_event.rb index 2972342cae6..4d85c0cfbee 100644 --- a/app/events/ci/job_artifacts_deleted_event.rb +++ b/app/events/ci/job_artifacts_deleted_event.rb @@ -9,8 +9,8 @@ module Ci 'properties' => { 'job_ids' => { 'type' => 'array', - 'properties' => { - 'job_id' => { 'type' => 'integer' } + 'items' => { + 'type' => 'integer' } } } diff --git a/app/events/project_authorizations/authorizations_added_event.rb b/app/events/project_authorizations/authorizations_added_event.rb new file mode 100644 index 00000000000..521a862218d --- /dev/null +++ b/app/events/project_authorizations/authorizations_added_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ProjectAuthorizations + class AuthorizationsAddedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[project_id user_ids], + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'user_ids' => { 'type' => 'array' } + } + } + end + end +end diff --git a/app/events/projects/release_published_event.rb b/app/events/projects/release_published_event.rb new file mode 100644 index 00000000000..f0be95b893e --- /dev/null +++ b/app/events/projects/release_published_event.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + class ReleasePublishedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'release_id' => { 'type' => 'integer' } + }, + 'required' => %w[release_id] + } + end + end +end diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb deleted file mode 100644 index 78602874cb7..00000000000 --- a/app/experiments/in_product_guidance_environments_webide_experiment.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment - control { false } - - exclude :has_environments? - - private - - def has_environments? - !context.project.environments.empty? - end -end diff --git a/app/finders/ci/catalog/resources/versions_finder.rb b/app/finders/ci/catalog/resources/versions_finder.rb index b37d4f0377a..16f8531c5c4 100644 --- a/app/finders/ci/catalog/resources/versions_finder.rb +++ b/app/finders/ci/catalog/resources/versions_finder.rb @@ -18,6 +18,7 @@ module Ci versions = params[:latest] ? get_latest_versions : get_versions versions = versions.preloaded + versions = by_name(versions) sort(versions) end @@ -45,6 +46,12 @@ module Ci end strong_memoize_attr :authorized_catalog_resources + def by_name(versions) + return versions unless params[:name] + + versions.by_name(params[:name]) + end + def sort(versions) versions.order_by(params[:sort] || DEFAULT_SORT) end diff --git a/app/finders/ci/runner_jobs_finder.rb b/app/finders/ci/runner_jobs_finder.rb index b659eda6646..91d8eccff21 100644 --- a/app/finders/ci/runner_jobs_finder.rb +++ b/app/finders/ci/runner_jobs_finder.rb @@ -13,7 +13,13 @@ module Ci end def execute - items = @runner.builds + items = if params[:system_id].blank? + runner.builds + else + runner_manager = Ci::RunnerManager.for_runner(runner).with_system_xid(params[:system_id]).first + Ci::Build.belonging_to_runner_manager(runner_manager&.id) + end + items = by_permission(items) items = by_status(items) sort_items(items) diff --git a/app/finders/ci/runner_managers_finder.rb b/app/finders/ci/runner_managers_finder.rb new file mode 100644 index 00000000000..f24be74bbeb --- /dev/null +++ b/app/finders/ci/runner_managers_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + class RunnerManagersFinder + def initialize(runner:, params:) + @runner = runner + @params = params + end + + def execute + items = runner_managers + + filter_by_status(items) + end + + private + + attr_reader :runner, :params + + def runner_managers + ::Ci::RunnerManager.for_runner(runner) + end + + def filter_by_status(items) + status = params[:status] + return items if status.blank? + + items.with_status(status) + end + end +end diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 945d332ff47..18be2aec2e2 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -14,18 +14,25 @@ module Ci end def execute - search! - filter_by_active! - filter_by_status! - filter_by_upgrade_status! - filter_by_runner_type! - filter_by_tag_list! - filter_by_creator_id! - filter_by_version_prefix! - sort! - request_tag_list! - - @runners + items = if @project + project_runners + elsif @group + group_runners + else + all_runners + end + + items = search(items) + items = by_active(items) + items = by_status(items) + items = by_upgrade_status(items) + items = by_runner_type(items) + items = by_tag_list(items) + items = by_creator_id(items) + items = by_version_prefix(items) + items = request_tag_list(items) + + sort(items) end def sort_key @@ -40,110 +47,104 @@ module Ci %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc] end - def search! - if @project - project_runners - elsif @group - group_runners - else - all_runners - end - - @runners = @runners.search(@params[:search]) if @params[:search].present? - end - def all_runners raise Gitlab::Access::AccessDeniedError unless @current_user&.can_admin_all_resources? - @runners = Ci::Runner.all + Ci::Runner.all end def group_runners raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :read_group_runners, @group) - @runners = case @params[:membership] - when :direct - Ci::Runner.belonging_to_group(@group.id) - when :descendants, nil - Ci::Runner.belonging_to_group_or_project_descendants(@group.id) - when :all_available - unless can?(@current_user, :read_group_all_available_runners, @group) - raise Gitlab::Access::AccessDeniedError - end - - Ci::Runner.usable_from_scope(@group) - else - raise ArgumentError, 'Invalid membership filter' - end + case @params[:membership] + when :direct + Ci::Runner.belonging_to_group(@group.id) + when :descendants, nil + Ci::Runner.belonging_to_group_or_project_descendants(@group.id) + when :all_available + unless can?(@current_user, :read_group_all_available_runners, @group) + raise Gitlab::Access::AccessDeniedError + end + + Ci::Runner.usable_from_scope(@group) + else + raise ArgumentError, 'Invalid membership filter' + end end def project_runners raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :read_project_runners, @project) - @runners = ::Ci::Runner.owned_or_instance_wide(@project.id) + ::Ci::Runner.owned_or_instance_wide(@project.id) + end + + def search(items) + return items unless @params[:search].present? + + items.search(@params[:search]) end - def filter_by_active! - @runners = @runners.active(@params[:active]) if @params.include?(:active) + def by_active(items) + return items if @params.exclude?(:active) + + items.active(@params[:active]) end - def filter_by_status! - filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES) + def by_status(items) + status = @params[:status_status].presence + return items unless status + + items.with_status(status) end - def filter_by_upgrade_status! + def by_upgrade_status(items) upgrade_status = @params[:upgrade_status] - return unless upgrade_status + return items unless upgrade_status unless Ci::RunnerVersion.statuses.key?(upgrade_status) raise ArgumentError, "Invalid upgrade status value '#{upgrade_status}'" end - @runners = @runners.with_upgrade_status(upgrade_status) + items.with_upgrade_status(upgrade_status) end - def filter_by_runner_type! - filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES) + def by_runner_type(items) + runner_type = @params[:type_type].presence + return items unless runner_type + + items.with_runner_type(runner_type) end - def filter_by_tag_list! + def by_tag_list(items) tag_list = @params[:tag_name].presence + return items unless tag_list - if tag_list - @runners = @runners.tagged_with(tag_list) - end + items.tagged_with(tag_list) end - def filter_by_creator_id! - creator_id = @params[:creator_id] - @runners = @runners.with_creator_id(creator_id) if creator_id.present? - end + def by_creator_id(items) + creator_id = @params[:creator_id].presence + return items unless creator_id - def filter_by_version_prefix! - return @runners unless @params[:version_prefix] - - sanitized_prefix = @params[:version_prefix][/^[\d+.]+/] - - return @runners unless sanitized_prefix - - @runners = @runners.with_version_prefix(sanitized_prefix) + items.with_creator_id(creator_id) end - def sort! - @runners = @runners.order_by(sort_key) + def by_version_prefix(items) + sanitized_prefix = @params.fetch(:version_prefix, '')[/^[\d+.]+/] + return items unless sanitized_prefix + + items.with_version_prefix(sanitized_prefix) end - def request_tag_list! - @runners = @runners.with_tags if !@params[:preload].present? || @params.dig(:preload, :tag_name) + def sort(items) + items.order_by(sort_key) end - def filter_by!(scope_name, available_scopes) - scope = @params[scope_name] + def request_tag_list(items) + return items if @params.include?(:preload) && !@params.dig(:preload, :tag_name) # Backward-compatible behavior - if scope.present? && available_scopes.include?(scope) - @runners = @runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend - end + items.with_tags end end end diff --git a/app/finders/groups/accepting_project_shares_finder.rb b/app/finders/groups/accepting_project_shares_finder.rb index c85e5a0f538..cf20292318e 100644 --- a/app/finders/groups/accepting_project_shares_finder.rb +++ b/app/finders/groups/accepting_project_shares_finder.rb @@ -42,7 +42,12 @@ module Groups # rubocop: disable CodeReuse/Finder def groups_with_guest_access_plus - GroupsFinder.new(current_user, min_access_level: Gitlab::Access::GUEST).execute + groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::GUEST).execute + + # We move the result into a materialized CTE to improve query performance during text search. + union_query = ::Group.from_union([groups]) + cte = Gitlab::SQL::CTE.new(:my_union_cte, union_query) + Group.with(cte.to_arel).from(cte.alias_to(Group.arel_table)) # rubocop: disable CodeReuse/ActiveRecord -- CTE use end # rubocop: enable CodeReuse/Finder diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index bc136848dd5..6b56c966025 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -135,14 +135,9 @@ class IssuableFinder strong_memoize(:projects) do next Array.wrap(project) if project? - projects = - if current_user && params[:authorized_only].presence && !current_user_related? - current_user.authorized_projects(min_access_level) - else - projects_public_or_visible_to_user - end - - projects.with_feature_available_for_user(klass.base_class, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord + projects_public_or_visible_to_user + .with_feature_available_for_user(klass.base_class, current_user) + .without_order end end diff --git a/app/finders/packages/terraform_module/packages_finder.rb b/app/finders/packages/terraform_module/packages_finder.rb new file mode 100644 index 00000000000..bcef8738622 --- /dev/null +++ b/app/finders/packages/terraform_module/packages_finder.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Packages + module TerraformModule + class PackagesFinder + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + return ::Packages::Package.none unless project && params[:package_name] + + packages + end + + private + + attr_reader :project, :params + + def packages + result = project + .packages + .with_name(params[:package_name]) + .terraform_module + .installable + + params[:package_version] ? result.with_version(params[:package_version]) : result.has_version.order_version_desc + end + end + end +end diff --git a/app/finders/projects/ml/experiment_finder.rb b/app/finders/projects/ml/experiment_finder.rb new file mode 100644 index 00000000000..0363cc6ec39 --- /dev/null +++ b/app/finders/projects/ml/experiment_finder.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ExperimentFinder + include Gitlab::Utils::StrongMemoize + + VALID_ORDER_BY = %w[name created_at updated_at id].freeze + VALID_SORT = %w[asc desc].freeze + + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + relation + end + + private + + def relation + @experiments = ::Ml::Experiment + .by_project(project) + .including_project + + ordered + end + + def ordered + order_by = valid_or_default(params[:order_by]&.downcase, VALID_ORDER_BY, 'id') + sort = valid_or_default(params[:sort]&.downcase, VALID_SORT, 'desc') + + experiments.order_by("#{order_by}_#{sort}").with_order_id_desc + end + + def valid_or_default(value, valid_values, default) + return value if valid_values.include?(value) + + default + end + + attr_reader :params, :project, :experiments + end + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 2a781c037f6..cd919c88f99 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -29,7 +29,7 @@ # repository_storage: string # not_aimed_for_deletion: boolean # full_paths: string[] -# organization_id: int +# organization: Scope the groups to the Organizations::Organization # class ProjectsFinder < UnionFinder include CustomAttributesFilter @@ -96,7 +96,7 @@ class ProjectsFinder < UnionFinder collection = by_language(collection) collection = by_feature_availability(collection) collection = by_updated_at(collection) - collection = by_organization_id(collection) + collection = by_organization(collection) by_repository_storage(collection) end @@ -173,7 +173,7 @@ class ProjectsFinder < UnionFinder # rubocop: enable CodeReuse/ActiveRecord def by_full_paths(items) - params[:full_paths].present? ? items.where_full_path_in(params[:full_paths], use_includes: false) : items + params[:full_paths].present? ? items.where_full_path_in(params[:full_paths], preload_routes: false) : items end def union(items) @@ -295,8 +295,11 @@ class ProjectsFinder < UnionFinder items end - def by_organization_id(items) - params[:organization_id].present? ? items.in_organization(params[:organization_id]) : items + def by_organization(items) + organization = params[:organization] + return items unless organization + + items.in_organization(organization) end def finder_params diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 88ba635e20b..101562de209 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -55,7 +55,16 @@ class UsersFinder private def base_scope - scope = current_user&.can_admin_all_resources? ? User.all : User.without_forbidden_states + group = params[:group] + + if group + raise Gitlab::Access::AccessDeniedError unless user_can_read_group?(group) + + scope = ::Autocomplete::GroupUsersFinder.new(group: group).execute # rubocop: disable CodeReuse/Finder -- For SQL optimization sake we need to scope out group members first see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/137647#note_1664081899 + else + scope = current_user&.can_admin_all_resources? ? User.all : User.without_forbidden_states + end + scope.order_id_desc end @@ -155,6 +164,10 @@ class UsersFinder users.order_by(params[:sort]) end # rubocop: enable CodeReuse/ActiveRecord + + def user_can_read_group?(group) + Ability.allowed?(current_user, :read_group, group) + end end UsersFinder.prepend_mod_with('UsersFinder') diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 527eb50b644..52f3e56aec3 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -59,6 +59,12 @@ module GraphqlTriggers ) end + def self.merge_request_diff_generated(merge_request) + GitlabSchema.subscriptions.trigger( + :merge_request_diff_generated, { issuable_id: merge_request.to_gid }, merge_request + ) + end + def self.work_item_updated(work_item) # becomes is necessary here since this can be triggered with both a WorkItem and also an Issue # depending on the update service the call comes from diff --git a/app/graphql/mutations/branch_rules/create.rb b/app/graphql/mutations/branch_rules/create.rb new file mode 100644 index 00000000000..c478d981c33 --- /dev/null +++ b/app/graphql/mutations/branch_rules/create.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Mutations + module BranchRules + class Create < BaseMutation + graphql_name 'BranchRuleCreate' + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path to the project that the branch is associated with.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Branch name, with wildcards, for the branch rules.' + + field :branch_rule, + Types::Projects::BranchRuleType, + null: true, + description: 'Branch rule after mutation.' + + def resolve(project_path:, name:) + project = Project.find_by_full_path(project_path) + + service_params = protected_branch_params(name) + protected_branch = ::ProtectedBranches::CreateService.new(project, current_user, service_params).execute + + if protected_branch.persisted? + { + branch_rule: ::Projects::BranchRule.new(project, protected_branch), + errors: [] + } + else + { errors: errors_on_object(protected_branch) } + end + rescue Gitlab::Access::AccessDeniedError + raise_resource_not_available_error! + end + + def protected_branch_params(name) + { + name: name, + push_access_levels_attributes: access_level_attributes(:push), + merge_access_levels_attributes: access_level_attributes(:merge) + } + end + + def access_level_attributes(type) + ::ProtectedRefs::AccessLevelParams.new( + type, + {}, + with_defaults: true + ).access_levels + end + end + end +end diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index 7aa78509bea..a5d9014af17 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -48,6 +48,10 @@ module Mutations ::Types::WorkItems::Widgets::AwardEmojiUpdateInputType, required: false, description: 'Input for emoji reactions widget.' + argument :notes_widget, + ::Types::WorkItems::Widgets::NotesInputType, + required: false, + description: 'Input for notes widget.' end end end diff --git a/app/graphql/mutations/issues/set_assignees.rb b/app/graphql/mutations/issues/set_assignees.rb index 8413c89b010..1e55cdee0a8 100644 --- a/app/graphql/mutations/issues/set_assignees.rb +++ b/app/graphql/mutations/issues/set_assignees.rb @@ -8,7 +8,7 @@ module Mutations include Assignable def assign!(issue, users, mode) - permitted, forbidden = users.partition { |u| u.can?(:read_issue, issue) } + permitted, forbidden = users.partition { |u| u.can?(:read_issue, issue.resource_parent) } super(issue, permitted, mode) diff --git a/app/graphql/mutations/ml/models/base.rb b/app/graphql/mutations/ml/models/base.rb new file mode 100644 index 00000000000..e3c5a7a13a8 --- /dev/null +++ b/app/graphql/mutations/ml/models/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module Ml + module Models + class Base < BaseMutation + authorize :write_model_registry + + argument :project_path, GraphQL::Types::ID, + required: true, + description: "Project the model to mutate is in." + + field :model, + Types::Ml::ModelType, + null: true, + description: 'Model after mutation.' + end + end + end +end diff --git a/app/graphql/mutations/ml/models/create.rb b/app/graphql/mutations/ml/models/create.rb new file mode 100644 index 00000000000..21570fc34b8 --- /dev/null +++ b/app/graphql/mutations/ml/models/create.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Ml + module Models + class Create < Base + graphql_name 'MlModelCreate' + + include FindsProject + + argument :name, GraphQL::Types::String, + required: true, + description: 'Name of the model.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of the model.' + + def resolve(**args) + project = authorized_find!(args[:project_path]) + + model = ::Ml::CreateModelService.new(project, args[:name], current_user, args[:description]).execute + + { + model: model.persisted? ? model : nil, + errors: errors_on_object(model) + } + end + end + end + end +end diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index 813c5687642..a429dd06a7c 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -51,6 +51,16 @@ module Mutations required: false, description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_duplicate_exception_regex) + argument :terraform_module_duplicates_allowed, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :terraform_module_duplicates_allowed) + + argument :terraform_module_duplicate_exception_regex, + Types::UntrustedRegexp, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :terraform_module_duplicate_exception_regex) + argument :maven_package_requests_forwarding, GraphQL::Types::Boolean, required: false, diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 7ce508e5ef1..754b453ce5d 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -60,6 +60,7 @@ module Mutations def resolve(project_path: nil, namespace_path: nil, **attributes) container_path = project_path || namespace_path container = authorized_find!(container_path) + check_env_feature_available!(container) check_feature_available!(container) params = global_id_compatibility_params(attributes).merge(author_id: current_user.id) @@ -83,6 +84,15 @@ module Mutations private + # This is just a temporary measure while we migrate and backfill epic internal_ids + # More info in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/139367 + def check_env_feature_available!(container) + return unless container.is_a?(::Group) && Rails.env.production? + + message = 'Group level work items are disabled. Only project paths allowed in `namespacePath`.' + raise Gitlab::Graphql::Errors::ArgumentError, message + end + def check_feature_available!(container) return unless container.is_a?(::Group) && Feature.disabled?(:namespace_level_work_items, container) diff --git a/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb index 9332076a493..899b407b180 100644 --- a/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb +++ b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb @@ -11,12 +11,18 @@ module Resolvers # field is evaluated on more than one node, it causes performance degradation. extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + argument :name, GraphQL::Types::String, + required: false, + description: 'Name of the version.' + argument :sort, Types::Ci::Catalog::Resources::VersionSortEnum, required: false, description: 'Sort versions by given criteria.' - def resolve(sort: nil) - ::Ci::Catalog::Resources::VersionsFinder.new(object, current_user, sort: sort).execute + alias_method :catalog_resource, :object + + def resolve(name: nil, sort: nil) + ::Ci::Catalog::Resources::VersionsFinder.new(catalog_resource, current_user, name: name, sort: sort).execute end end end diff --git a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb index f4e044b81c9..28c39427872 100644 --- a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb +++ b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb @@ -34,7 +34,7 @@ module Resolvers def resolve_owner return unless runner.project_type? - BatchLoader::GraphQL.for(runner.id).batch(key: :runner_owner_projects) do |runner_ids, loader| + BatchLoader::GraphQL.for(runner.id).batch do |runner_ids, loader| # rubocop: disable CodeReuse/ActiveRecord runner_and_projects_with_row_number = ::Ci::RunnerProject diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb index c5037965e20..99c9bba1bd6 100644 --- a/app/graphql/resolvers/ci/runner_projects_resolver.rb +++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb @@ -28,7 +28,7 @@ module Resolvers return unless runner.project_type? # rubocop:disable CodeReuse/ActiveRecord - BatchLoader::GraphQL.for(runner.id).batch(key: :runner_projects) do |runner_ids, loader| + BatchLoader::GraphQL.for(runner.id).batch do |runner_ids, loader| plucked_runner_and_project_ids = ::Ci::RunnerProject .select(:runner_id, :project_id) .where(runner_id: runner_ids) diff --git a/app/graphql/resolvers/ci/runner_resolver.rb b/app/graphql/resolvers/ci/runner_resolver.rb index 4250b069d20..60fb4163afe 100644 --- a/app/graphql/resolvers/ci/runner_resolver.rb +++ b/app/graphql/resolvers/ci/runner_resolver.rb @@ -6,13 +6,12 @@ module Resolvers include LooksAhead type Types::Ci::RunnerType, null: true - extras [:lookahead] description 'Runner information.' argument :id, - type: ::Types::GlobalIDType[::Ci::Runner], - required: true, - description: 'Runner ID.' + type: ::Types::GlobalIDType[::Ci::Runner], + required: true, + description: 'Runner ID.' def resolve_with_lookahead(id:) find_runner(id: id) @@ -21,19 +20,13 @@ module Resolvers private def find_runner(id:) - runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id.to_i - key = { - preload_tag_list: lookahead.selects?(:tag_list), - preload_creator: lookahead.selects?(:created_by) - } - - BatchLoader::GraphQL.for(runner_id).batch(key: key) do |ids, loader, batch| - results = ::Ci::Runner.id_in(ids) - results = results.with_tags if batch[:key][:preload_tag_list] - results = results.with_creator if batch[:key][:preload_creator] - - results.each { |record| loader.call(record.id, record) } - end + preloads = [] + preloads << :creator if lookahead.selects?(:created_by) + preloads << :tags if lookahead.selects?(:tag_list) + + runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id + + ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Runner, runner_id, preloads).find end end end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 9121c413b1f..38d2ebe046b 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -82,7 +82,7 @@ module Resolvers creator_id: params[:creator_id] ? ::GitlabSchema.parse_gid(params[:creator_id], expected_type: ::User).model_id : nil, version_prefix: params[:version_prefix], - preload: false # we'll handle preloading ourselves + preload: {} # we'll handle preloading ourselves }.compact .merge(parent_param) end diff --git a/app/graphql/resolvers/concerns/resolves_groups.rb b/app/graphql/resolvers/concerns/resolves_groups.rb index 86dda5cb1cb..1673b1bd37f 100644 --- a/app/graphql/resolvers/concerns/resolves_groups.rb +++ b/app/graphql/resolvers/concerns/resolves_groups.rb @@ -5,6 +5,19 @@ module ResolvesGroups extend ActiveSupport::Concern include LooksAhead + PRELOADS = { + container_repositories_count: [:container_repositories], + custom_emoji: [:custom_emoji], + full_path: [:route], + path: [:route], + web_url: [:route], + dependency_proxy_blob_count: [:dependency_proxy_blobs], + dependency_proxy_blobs: [:dependency_proxy_blobs], + dependency_proxy_image_count: [:dependency_proxy_manifests], + dependency_proxy_image_ttl_policy: [:dependency_proxy_image_ttl_policy], + dependency_proxy_setting: [:dependency_proxy_setting] + }.freeze + def resolve_with_lookahead(...) apply_lookahead(resolve_groups(...)) end @@ -17,17 +30,8 @@ module ResolvesGroups end def preloads - { - container_repositories_count: [:container_repositories], - custom_emoji: [:custom_emoji], - full_path: [:route], - path: [:route], - web_url: [:route], - dependency_proxy_blob_count: [:dependency_proxy_blobs], - dependency_proxy_blobs: [:dependency_proxy_blobs], - dependency_proxy_image_count: [:dependency_proxy_manifests], - dependency_proxy_image_ttl_policy: [:dependency_proxy_image_ttl_policy], - dependency_proxy_setting: [:dependency_proxy_setting] - } + PRELOADS end end + +ResolvesGroups.prepend_mod diff --git a/app/graphql/resolvers/container_repository_tags_resolver.rb b/app/graphql/resolvers/container_repository_tags_resolver.rb index 50adf98fa07..d3929451bd0 100644 --- a/app/graphql/resolvers/container_repository_tags_resolver.rb +++ b/app/graphql/resolvers/container_repository_tags_resolver.rb @@ -14,6 +14,11 @@ module Resolvers required: false, default_value: nil + argument :referrers, GraphQL::Types::Boolean, + description: 'Include tag referrers.', + required: false, + default_value: nil + alias_method :container_repository, :object def resolve(sort:, **filters) @@ -25,7 +30,8 @@ module Resolvers last: filters[:after], sort: map_sort_field(sort), name: filters[:name], - page_size: page_size + page_size: page_size, + referrers: filters[:referrers] ) Gitlab::Graphql::ExternallyPaginatedArray.new( diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb index b8df54f49ab..2c64d08a219 100644 --- a/app/graphql/resolvers/full_path_resolver.rb +++ b/app/graphql/resolvers/full_path_resolver.rb @@ -4,10 +4,10 @@ module Resolvers module FullPathResolver extend ActiveSupport::Concern - prepended do + included do argument :full_path, GraphQL::Types::ID, - required: true, - description: 'Full path of the project, group, or namespace. For example, `gitlab-org/gitlab-foss`.' + required: true, + description: "Full path of the #{target_type}. For example, `gitlab-org/gitlab-foss`." end def model_by_full_path(model, full_path) diff --git a/app/graphql/resolvers/group_resolver.rb b/app/graphql/resolvers/group_resolver.rb index 4260e18829e..e3b651b6493 100644 --- a/app/graphql/resolvers/group_resolver.rb +++ b/app/graphql/resolvers/group_resolver.rb @@ -2,7 +2,11 @@ module Resolvers class GroupResolver < BaseResolver - prepend FullPathResolver + def self.target_type + 'group' + end + + include FullPathResolver type Types::GroupType, null: true diff --git a/app/graphql/resolvers/ml/find_models_resolver.rb b/app/graphql/resolvers/ml/find_models_resolver.rb new file mode 100644 index 00000000000..b9901100e22 --- /dev/null +++ b/app/graphql/resolvers/ml/find_models_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + module Ml + class FindModelsResolver < Resolvers::BaseResolver + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + + type ::Types::Ml::ModelType.connection_type, null: true + + argument :name, GraphQL::Types::String, + required: false, + description: 'Search for names that include the string.' + + argument :order_by, ::Types::Ml::ModelsOrderByEnum, + required: false, + description: 'Ordering column. Default is created_at.' + + argument :sort, ::Types::SortDirectionEnum, + required: false, + description: 'Ordering column. Default is desc.' + + def resolve(**args) + return unless current_user.can?(:read_model_registry, object) + + find_params = { + name: args[:name], + order_by: args[:order_by].to_s, + sort: args[:sort].to_s + } + + ::Projects::Ml::ModelFinder.new(object, find_params).execute + end + end + end +end diff --git a/app/graphql/resolvers/namespace_resolver.rb b/app/graphql/resolvers/namespace_resolver.rb index 17b3800d151..a0b16758625 100644 --- a/app/graphql/resolvers/namespace_resolver.rb +++ b/app/graphql/resolvers/namespace_resolver.rb @@ -2,7 +2,11 @@ module Resolvers class NamespaceResolver < BaseResolver - prepend FullPathResolver + def self.target_type + 'namespace' + end + + include FullPathResolver type Types::NamespaceType, null: true diff --git a/app/graphql/resolvers/organizations/organizations_resolver.rb b/app/graphql/resolvers/organizations/organizations_resolver.rb new file mode 100644 index 00000000000..ab21a84645b --- /dev/null +++ b/app/graphql/resolvers/organizations/organizations_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class OrganizationsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::Organizations::OrganizationType.connection_type, null: true + authorize :read_organization + + def resolve + # For the Organization MVC, all the organizations are public. We need to change this to only accessible + # organizations once we start supporting private organizations. + # See https://gitlab.com/groups/gitlab-org/-/epics/10649. + ::Organizations::Organization.all + end + end + end +end diff --git a/app/graphql/resolvers/organizations/projects_resolver.rb b/app/graphql/resolvers/organizations/projects_resolver.rb new file mode 100644 index 00000000000..836fe0ae059 --- /dev/null +++ b/app/graphql/resolvers/organizations/projects_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class ProjectsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::ProjectType, null: true + + authorize :read_project + + alias_method :organization, :object + + def resolve + ::ProjectsFinder.new(current_user: current_user, params: { organization: organization }).execute + end + end + end +end diff --git a/app/graphql/resolvers/project_resolver.rb b/app/graphql/resolvers/project_resolver.rb index 2132447da5e..931fefcea50 100644 --- a/app/graphql/resolvers/project_resolver.rb +++ b/app/graphql/resolvers/project_resolver.rb @@ -2,7 +2,11 @@ module Resolvers class ProjectResolver < BaseResolver - prepend FullPathResolver + def self.target_type + 'project' + end + + include FullPathResolver type Types::ProjectType, null: true diff --git a/app/graphql/resolvers/projects/fork_targets_resolver.rb b/app/graphql/resolvers/projects/fork_targets_resolver.rb index 5e8be325d43..27797b9f0af 100644 --- a/app/graphql/resolvers/projects/fork_targets_resolver.rb +++ b/app/graphql/resolvers/projects/fork_targets_resolver.rb @@ -3,7 +3,7 @@ module Resolvers module Projects class ForkTargetsResolver < BaseResolver - include ResolvesGroups + include LooksAhead include Gitlab::Graphql::Authorize::AuthorizeResource type Types::NamespaceType.connection_type, null: true @@ -17,10 +17,15 @@ module Resolvers required: false, description: 'Search query for path or name.' + def resolve_with_lookahead(**args) + fork_targets = ForkTargetsFinder.new(project, current_user).execute(args) + apply_lookahead(fork_targets) + end + private - def resolve_groups(**args) - ForkTargetsFinder.new(project, current_user).execute(args) + def preloads + ResolvesGroups::PRELOADS end end end diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb index 90a6bd3e6b2..a512c6bafe1 100644 --- a/app/graphql/resolvers/users_resolver.rb +++ b/app/graphql/resolvers/users_resolver.rb @@ -28,10 +28,19 @@ module Resolvers default_value: false, description: 'Return only admin users.' - def resolve(ids: nil, usernames: nil, sort: nil, search: nil, admins: nil) + argument :group_id, ::Types::GlobalIDType[::Group], + required: false, + description: 'Return users member of a given group.' + + def resolve(ids: nil, usernames: nil, sort: nil, search: nil, admins: nil, group_id: nil) authorize!(usernames) - ::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort, search, admins)).execute + group = group_id ? find_authorized_group!(group_id) : nil + + ::UsersFinder.new( + context[:current_user], + finder_params(ids, usernames, sort, search, admins, group) + ).execute end def ready?(**args) @@ -52,16 +61,27 @@ module Resolvers private - def finder_params(ids, usernames, sort, search, admins) + def finder_params(ids, usernames, sort, search, admins, group) params = {} params[:sort] = sort if sort params[:username] = usernames if usernames params[:id] = parse_gids(ids) if ids params[:search] = search if search params[:admins] = admins if admins + params[:group] = group if group params end + def find_authorized_group!(group_id) + group = GitlabSchema.find_by_gid(group_id).sync + + unless Ability.allowed?(current_user, :read_group, group) + raise_resource_not_available_error! "Could not find a Group with ID #{group_id}" + end + + group + end + def parse_gids(gids) gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::User).model_id } end diff --git a/app/graphql/types/ci/catalog/resources/component_type.rb b/app/graphql/types/ci/catalog/resources/component_type.rb index 3b4771446cb..71ed31725a6 100644 --- a/app/graphql/types/ci/catalog/resources/component_type.rb +++ b/app/graphql/types/ci/catalog/resources/component_type.rb @@ -16,7 +16,8 @@ module Types description: 'Name of the component.', alpha: { milestone: '16.7' } - field :path, GraphQL::Types::String, null: true, + field :include_path, GraphQL::Types::String, null: true, + method: :path, description: 'Path used to include the component.', alpha: { milestone: '16.7' } diff --git a/app/graphql/types/ci/catalog/resources/version_type.rb b/app/graphql/types/ci/catalog/resources/version_type.rb index 689f649afc5..b52a1c6b13d 100644 --- a/app/graphql/types/ci/catalog/resources/version_type.rb +++ b/app/graphql/types/ci/catalog/resources/version_type.rb @@ -20,13 +20,13 @@ module Types field :released_at, Types::TimeType, null: true, description: 'Timestamp of when the version was released.', alpha: { milestone: '16.7' } - field :tag_name, GraphQL::Types::String, null: true, method: :name, - description: 'Name of the tag associated with the version.', - alpha: { milestone: '16.7' } + field :name, GraphQL::Types::String, null: true, + description: 'Name that uniquely identifies the version within the catalog resource.', + alpha: { milestone: '16.8' } - field :tag_path, GraphQL::Types::String, null: true, - description: 'Relative web path to the tag associated with the version.', - alpha: { milestone: '16.7' } + field :path, GraphQL::Types::String, null: true, + description: 'Relative web path to the version.', + alpha: { milestone: '16.8' } field :author, Types::UserType, null: true, description: 'User that created the version.', alpha: { milestone: '16.7' } @@ -39,12 +39,22 @@ module Types description: 'Components belonging to the catalog resource.', alpha: { milestone: '16.7' } + field :readme_html, GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'GitLab Flavored Markdown rendering of README.md. This field ' \ + 'can only be resolved for one version in any single request.', + alpha: { milestone: '16.8' } do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 # To avoid N+1 calls to Gitaly + end + def author Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find end - def tag_path - Gitlab::Routing.url_helpers.project_tag_path(object.project, object.name) + def readme_html + return unless Ability.allowed?(current_user, :read_code, object.project) + + markdown_context = context.to_h.dup.merge(project: object.project) + ::MarkupHelper.markdown(object.readme&.data, markdown_context) end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index f01c63d717b..0c2d1b788af 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -27,7 +27,7 @@ module Types field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true, - description: 'Whether merge pipelines are enabled.', + description: 'Whether merged results pipelines are enabled.', method: :merge_pipelines_enabled? field :project, Types::ProjectType, diff --git a/app/graphql/types/ci/inherited_ci_variable_type.rb b/app/graphql/types/ci/inherited_ci_variable_type.rb index 2d8dcdaeefe..c90e34b25dd 100644 --- a/app/graphql/types/ci/inherited_ci_variable_type.rb +++ b/app/graphql/types/ci/inherited_ci_variable_type.rb @@ -15,6 +15,10 @@ module Types null: true, description: 'Name of the variable.' + field :description, GraphQL::Types::String, + null: true, + description: 'Description of the variable.' + field :raw, GraphQL::Types::Boolean, null: true, description: 'Indicates whether the variable is raw.' diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb index e3230556769..457cd8a1ba2 100644 --- a/app/graphql/types/ci/instance_variable_type.rb +++ b/app/graphql/types/ci/instance_variable_type.rb @@ -13,6 +13,10 @@ module Types null: false, description: 'ID of the variable.' + field :description, GraphQL::Types::String, + null: true, + description: 'Description of the variable.' + field :environment_scope, GraphQL::Types::String, null: true, deprecated: { diff --git a/app/graphql/types/commit_signatures/verification_status_enum.rb b/app/graphql/types/commit_signatures/verification_status_enum.rb index 9df1b7abd82..d0d8f6670c3 100644 --- a/app/graphql/types/commit_signatures/verification_status_enum.rb +++ b/app/graphql/types/commit_signatures/verification_status_enum.rb @@ -6,10 +6,10 @@ module Types module CommitSignatures class VerificationStatusEnum < BaseEnum graphql_name 'VerificationStatus' - description 'Verification status of a GPG or X.509 signature for a commit.' + description 'Verification status of a GPG, X.509 or SSH signature for a commit.' - ::CommitSignatures::GpgSignature.verification_statuses.each do |status, _| - value status.upcase, value: status, description: "#{status} verification status." + ::Enums::CommitSignature.verification_statuses.each_key do |status| + value status.to_s.upcase, value: status.to_s, description: "#{status} verification status." end end end diff --git a/app/graphql/types/container_repository_referrer_type.rb b/app/graphql/types/container_repository_referrer_type.rb new file mode 100644 index 00000000000..d9d4d150b95 --- /dev/null +++ b/app/graphql/types/container_repository_referrer_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class ContainerRepositoryReferrerType < BaseObject + graphql_name 'ContainerRepositoryReferrer' + + description 'A referrer for a container repository tag' + + authorize :read_container_image + + expose_permissions Types::PermissionTypes::ContainerRepositoryTag + + field :artifact_type, GraphQL::Types::String, description: 'Artifact type of the referrer.' + field :digest, GraphQL::Types::String, description: 'Digest of the referrer.' + end +end diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb index cf8796410d3..7691844645a 100644 --- a/app/graphql/types/container_repository_tag_type.rb +++ b/app/graphql/types/container_repository_tag_type.rb @@ -22,6 +22,8 @@ module Types 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 :published_at, Types::TimeType, null: true, description: 'Timestamp when the tag was published.' + field :referrers, [Types::ContainerRepositoryReferrerType], null: true, description: 'Referrers for this 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.' diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 7234948033b..01b741b5a98 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -214,6 +214,21 @@ module Types complexity: 5, resolver: Resolvers::NestedGroupsResolver + field :descendant_groups_count, + GraphQL::Types::Int, + null: false, + description: 'Count of direct descendant groups of this group.' + + field :group_members_count, + GraphQL::Types::Int, + null: false, + description: 'Count of direct members of this group.' + + field :projects_count, + GraphQL::Types::Int, + null: false, + description: 'Count of direct projects in this group.' + field :ci_variables, Types::Ci::GroupVariableType.connection_type, null: true, @@ -339,6 +354,27 @@ module Types group.dependency_proxy_setting || group.create_dependency_proxy_setting end + def descendant_groups_count + BatchLoader::GraphQL.for(object.id).batch do |group_ids, loader| + descendants_counts = Group.id_in(group_ids).descendant_groups_counts + descendants_counts.each { |group_id, count| loader.call(group_id, count) } + end + end + + def projects_count + BatchLoader::GraphQL.for(object.id).batch do |group_ids, loader| + projects_counts = Group.id_in(group_ids).projects_counts + projects_counts.each { |group_id, count| loader.call(group_id, count) } + end + end + + def group_members_count + BatchLoader::GraphQL.for(object.id).batch do |group_ids, loader| + members_counts = Group.id_in(group_ids).group_members_counts + members_counts.each { |group_id, count| loader.call(group_id, count) } + end + end + private def group diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index d7c3b313f84..3572cfd346b 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -248,6 +248,18 @@ module Types 'if `sast_reports_in_inline_diff` feature flag is disabled.', resolver: ::Resolvers::CodequalityReportsComparerResolver + field :allows_multiple_assignees, + GraphQL::Types::Boolean, + method: :allows_multiple_assignees?, + description: 'Allows assigning multiple users to a merge request.', + null: false + + field :allows_multiple_reviewers, + GraphQL::Types::Boolean, + method: :allows_multiple_reviewers?, + description: 'Allows assigning multiple reviewers to a merge request.', + null: false + markdown_field :title_html, null: true markdown_field :description_html, null: true diff --git a/app/graphql/types/ml/model_links_type.rb b/app/graphql/types/ml/model_links_type.rb new file mode 100644 index 00000000000..9d18efb2e17 --- /dev/null +++ b/app/graphql/types/ml/model_links_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ml + # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver + class ModelLinksType < BaseObject + graphql_name 'MLModelLinks' + description 'Represents links to perform actions on the model' + + present_using ::Ml::ModelPresenter + + field :show_path, GraphQL::Types::String, + null: true, description: 'Path to the details page of the model.', method: :path + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ml/model_type.rb b/app/graphql/types/ml/model_type.rb index ca63918b370..a26d50cbdc4 100644 --- a/app/graphql/types/ml/model_type.rb +++ b/app/graphql/types/ml/model_type.rb @@ -7,10 +7,25 @@ module Types graphql_name 'MlModel' description 'Machine learning model in the model registry' + connection_type_class Types::LimitedCountableConnectionType + + present_using ::Ml::ModelPresenter + field :id, ::Types::GlobalIDType[::Ml::Model], null: false, description: 'ID of the model.' field :name, ::GraphQL::Types::String, null: false, description: 'Name of the model.' + field :created_at, Types::TimeType, null: false, description: 'Date of creation.' + + field :description, ::GraphQL::Types::String, null: false, description: 'Description of the model.' + + field :latest_version, ::Types::Ml::ModelVersionType, null: true, description: 'Latest version of the model.' + + field :version_count, ::GraphQL::Types::Int, null: true, description: 'Count of versions in the model.' + + field :_links, ::Types::Ml::ModelLinksType, null: false, method: :itself, + description: 'Map of links to perform actions on the model.' + field :versions, ::Types::Ml::ModelVersionType.connection_type, null: true, description: 'Versions of the model.' diff --git a/app/graphql/types/ml/model_version_links_type.rb b/app/graphql/types/ml/model_version_links_type.rb index 142f62bfad2..a8497334fc6 100644 --- a/app/graphql/types/ml/model_version_links_type.rb +++ b/app/graphql/types/ml/model_version_links_type.rb @@ -11,6 +11,9 @@ module Types field :show_path, GraphQL::Types::String, null: true, description: 'Path to the details page of the model version.', method: :path + + field :package_path, GraphQL::Types::String, + null: true, description: 'Path to the package of the model version.', method: :package_path end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ml/models_order_by_enum.rb b/app/graphql/types/ml/models_order_by_enum.rb new file mode 100644 index 00000000000..db96a2e2d7d --- /dev/null +++ b/app/graphql/types/ml/models_order_by_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ml + class ModelsOrderByEnum < BaseEnum + graphql_name 'MlModelsOrderBy' + description 'Values for ordering machine learning models by a specific field' + + value 'NAME', 'Ordered by name.', value: :name + value 'CREATED_AT', 'Ordered by creation time.', value: :created_at + value 'UPDATED_AT', 'Ordered by update time.', value: :updated_at + value 'ID', 'Ordered by id.', value: :id + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 590bc0ed282..0a725c2e0a7 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -111,6 +111,7 @@ module Types mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' } mount_mutation Mutations::Projects::Star, alpha: { milestone: '16.7' } mount_mutation Mutations::BranchRules::Update, alpha: { milestone: '16.7' } + mount_mutation Mutations::BranchRules::Create, alpha: { milestone: '16.7' } mount_mutation Mutations::Releases::Create mount_mutation Mutations::Releases::Update mount_mutation Mutations::Releases::Delete @@ -202,6 +203,7 @@ module Types mount_mutation Mutations::Users::SetNamespaceCommitEmail mount_mutation Mutations::WorkItems::Subscribe, alpha: { milestone: '16.3' } mount_mutation Mutations::Admin::AbuseReportLabels::Create, alpha: { milestone: '16.4' } + mount_mutation Mutations::Ml::Models::Create, alpha: { milestone: '16.8' } end end diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index 7bf76ae7de5..621cb091019 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -35,6 +35,12 @@ module Types field :pypi_package_requests_forwarding, GraphQL::Types::Boolean, null: true, description: 'Indicates whether PyPI package forwarding is allowed for this namespace.' + field :terraform_module_duplicate_exception_regex, Types::UntrustedRegexp, + null: true, + description: 'When terraform_module_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' + field :terraform_module_duplicates_allowed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether duplicate Terraform packages are allowed for this namespace.' field :lock_maven_package_requests_forwarding, GraphQL::Types::Boolean, null: false, diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 85bda507ff7..3420f16213f 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -4,7 +4,7 @@ module Types class NamespaceType < BaseObject graphql_name 'Namespace' - authorize :read_namespace_via_membership + authorize :read_namespace field :id, GraphQL::Types::ID, null: false, description: 'ID of the namespace.' diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb index 379bf9956a3..d36c92541ef 100644 --- a/app/graphql/types/organizations/organization_type.rb +++ b/app/graphql/types/organizations/organization_type.rb @@ -43,6 +43,10 @@ module Types null: false, description: 'Path of the organization.', alpha: { milestone: '16.4' } + field :projects, Types::ProjectType.connection_type, null: false, + description: 'Projects within this organization that the user has access to.', + alpha: { milestone: '16.8' }, + resolver: ::Resolvers::Organizations::ProjectsResolver field :web_url, GraphQL::Types::String, null: false, diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb index a76dc88adfc..65586b384be 100644 --- a/app/graphql/types/permission_types/issue.rb +++ b/app/graphql/types/permission_types/issue.rb @@ -8,7 +8,7 @@ module Types abilities :read_issue, :admin_issue, :update_issue, :reopen_issue, :read_design, :create_design, :destroy_design, - :create_note, :update_design + :create_note, :update_design, :admin_issue_relation end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 8e84605cb05..7f49c717c78 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -663,6 +663,24 @@ module Types null: true, resolver: Resolvers::Analytics::CycleAnalytics::ValueStreamsResolver + field :ml_models, ::Types::Ml::ModelType.connection_type, + null: true, + alpha: { milestone: '16.8' }, + description: 'Finds machine learning models', + resolver: Resolvers::Ml::FindModelsResolver + + field :allows_multiple_merge_request_assignees, + GraphQL::Types::Boolean, + method: :allows_multiple_merge_request_assignees?, + description: 'Project allows assigning multiple users to a merge request.', + null: false + + field :allows_multiple_merge_request_reviewers, + GraphQL::Types::Boolean, + method: :allows_multiple_merge_request_reviewers?, + description: 'Project allows assigning multiple reviewers to a merge request.', + null: false + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 0e39ff2c030..47a049fe10c 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -86,7 +86,8 @@ module Types field :jobs, ::Types::Ci::JobType.connection_type, null: true, - description: 'All jobs on this GitLab instance.', + description: 'All jobs on this GitLab instance.' \ + ' Returns an empty result for users without administrator access.', resolver: ::Resolvers::Ci::AllJobsResolver field :merge_request, Types::MergeRequestType, null: true, @@ -122,6 +123,11 @@ module Types resolver: Resolvers::Organizations::OrganizationResolver, description: "Find an organization.", alpha: { milestone: '16.4' } + field :organizations, Types::Organizations::OrganizationType.connection_type, + null: true, + resolver: Resolvers::Organizations::OrganizationsResolver, + description: "List organizations.", + alpha: { milestone: '16.8' } field :package, description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.', resolver: Resolvers::PackageDetailsResolver diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index 7f33f77ec14..5a90a65f50f 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -63,6 +63,10 @@ module Types field :merge_request_approval_state_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when approval state of a merge request is updated.' + + field :merge_request_diff_generated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when a merge request diff is generated.' end end diff --git a/app/graphql/types/work_items/widgets/notes_input_type.rb b/app/graphql/types/work_items/widgets/notes_input_type.rb new file mode 100644 index 00000000000..fc7f4c84658 --- /dev/null +++ b/app/graphql/types/work_items/widgets/notes_input_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class NotesInputType < BaseInputObject + graphql_name 'WorkItemWidgetNotesInput' + + argument :discussion_locked, GraphQL::Types::Boolean, + required: true, + description: 'Discussion lock attribute for notes widget of the work item.' + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/notes_type.rb b/app/graphql/types/work_items/widgets/notes_type.rb index 199001649bb..4f7f1c3b4cc 100644 --- a/app/graphql/types/work_items/widgets/notes_type.rb +++ b/app/graphql/types/work_items/widgets/notes_type.rb @@ -12,6 +12,10 @@ module Types implements Types::WorkItems::WidgetInterface + field :discussion_locked, GraphQL::Types::Boolean, + null: true, + description: 'Discussion lock attribute of the work item.' + # This field loads user comments, system notes and resource events as a discussion for an work item, # raising the complexity considerably. In order to discourage fetching this field as part of fetching # a list of issues we raise the complexity diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 07a5e711d1c..81aa4757862 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -44,7 +44,7 @@ module AppearancesHelper end def brand_image - image_tag(brand_image_path, alt: brand_title, class: 'gl-visibility-hidden gl-h-9 js-portrait-logo-detection') + image_tag(brand_image_path, alt: brand_title, class: 'gl-visibility-hidden gl-h-10 js-portrait-logo-detection') end def brand_image_path diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 49230e558a8..892b046e410 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -295,10 +295,6 @@ module ApplicationHelper end end - def truncate_first_line(message, length = 50) - truncate(message.each_line.first.chomp, length: length) if message - end - # While similarly named to Rails's `link_to_if`, this method behaves quite differently. # If `condition` is truthy, a link will be returned with the result of the block # as its body. If `condition` is falsy, only the result of the block will be returned. diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 655fdf8b8ec..1affdd8f433 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -353,6 +353,7 @@ module ApplicationSettingsHelper :repository_checks_enabled, :repository_storages_weighted, :require_admin_approval_after_user_signup, + :require_admin_two_factor_authentication, :require_two_factor_authentication, :remember_me_enabled, :restricted_visibility_levels, @@ -449,6 +450,7 @@ module ApplicationSettingsHelper :issues_create_limit, :notes_create_limit, :notes_create_limit_allowlist_raw, + :members_delete_limit, :raw_blob_request_limit, :project_import_limit, :project_export_limit, @@ -511,7 +513,8 @@ module ApplicationSettingsHelper :namespace_aggregation_schedule_lease_duration_in_seconds, :ci_max_total_yaml_size_bytes, :project_jobs_api_rate_limit, - :security_txt_content + :security_txt_content, + :allow_project_creation_for_guest_and_below ].tap do |settings| next if Gitlab.com? @@ -564,10 +567,6 @@ module ApplicationSettingsHelper can?(current_user, :read_cluster, clusterable) end - def omnibus_protected_paths_throttle? - Rack::Attack.throttles.key?('protected paths') - end - def valid_runner_registrars Gitlab::CurrentSettings.valid_runner_registrars end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index b21c8687d69..dff1123f10b 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -113,7 +113,7 @@ module AvatarsHelper when Namespaces::UserNamespace user_avatar_without_link(options.merge(user: resource.first_owner)) when Group - group_icon(resource, options.merge(class: 'avatar')) + render Pajamas::AvatarComponent.new(resource, class: 'gl-avatar-circle gl-mr-3', size: 32) end end diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb index da8310995cc..a1094027291 100644 --- a/app/helpers/breadcrumbs_helper.rb +++ b/app/helpers/breadcrumbs_helper.rb @@ -22,7 +22,7 @@ module BreadcrumbsHelper end def breadcrumb_list_item(link) - content_tag :li, link, class: 'gl-breadcrumb-item' + content_tag :li, link, class: 'gl-breadcrumb-item gl-display-inline-flex' end def add_to_breadcrumb_collapsed_links(link, location: :before) diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index e6212ee7d8d..851de133a38 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -8,7 +8,9 @@ module ButtonHelper # :gfm - GitLab Flavored Markdown to copy, if different from `text` (optional) # :target - Selector for target element to copy from (optional) # :class - CSS classes to be applied to the button (optional) - # :title - Button's title attribute (used for the tooltip) (optional) + # :title - Button's title attribute (used for the tooltip) (optional, default: Copy) + # :aria_label - Button's aria-label attribute (optional) + # :aria_keyshortcuts - Button's aria-keyshortcuts attribute (optional) # :button_text - Button's displayed label (optional) # :hide_tooltip - Whether the tooltip should be hidden (optional, default: false) # :hide_button_icon - Whether the icon should be hidden (optional, default: false) @@ -31,6 +33,8 @@ module ButtonHelper def clipboard_button(data = {}) css_class = data.delete(:class) title = data.delete(:title) || _('Copy') + aria_keyshortcuts = data.delete(:aria_keyshortcuts) || nil + aria_label = data.delete(:aria_label) || title button_text = data[:button_text] || nil hide_tooltip = data[:hide_tooltip] || false hide_button_icon = data[:hide_button_icon] || false @@ -54,7 +58,7 @@ module ButtonHelper data[:clipboard_target] = target if target unless hide_tooltip - data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data) + data = { toggle: 'tooltip', placement: 'bottom', container: 'body', html: 'true' }.merge(data) end render ::Pajamas::ButtonComponent.new( @@ -62,7 +66,7 @@ module ButtonHelper variant: variant, category: category, size: size, - button_options: { class: css_class, title: title, aria: { label: title, live: 'polite' }, data: data, itemprop: item_prop }) do + button_options: { class: css_class, title: title, aria: { keyshortcuts: aria_keyshortcuts, label: aria_label, live: 'polite' }, data: data, itemprop: item_prop }) do button_text end end diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb index 001b316fcf2..6fa3840fac0 100644 --- a/app/helpers/ci/builds_helper.rb +++ b/app/helpers/ci/builds_helper.rb @@ -2,13 +2,6 @@ module Ci module BuildsHelper - def sidebar_build_class(build, current_build) - build_class = [] - build_class << 'active' if build.id === current_build.id - build_class << 'retried' if build.retried? - build_class.join(' ') - end - def build_failed_issue_options { title: _("Job Failed #%{build_id}") % { build_id: @build.id }, diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb index 21d982d42bc..5ff8ce74866 100644 --- a/app/helpers/ci/status_helper.rb +++ b/app/helpers/ci/status_helper.rb @@ -9,11 +9,6 @@ # module Ci module StatusHelper - def ci_status_for_statuseable(subject) - status = subject.try(:status) || 'not found' - status.humanize - end - # rubocop:disable Metrics/CyclomaticComplexity def ci_icon_for_status(status, size: 24) icon_name = @@ -56,10 +51,6 @@ module Ci end # rubocop:enable Metrics/CyclomaticComplexity - def pipeline_status_cache_key(pipeline_status) - "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" - end - def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left') project = commit.project path = pipelines_project_commit_path(project, commit, ref: ref) diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb index 0dbd1adeb71..86e2667c7bb 100644 --- a/app/helpers/ci/variables_helper.rb +++ b/app/helpers/ci/variables_helper.rb @@ -32,21 +32,6 @@ module Ci end end - def ci_variable_masked?(variable, only_key_value) - if variable && !only_key_value - variable.masked - else - false - end - end - - def ci_variable_type_options - [ - %w[Variable env_var], - %w[File file] - ] - end - def ci_variable_maskable_raw_regex Ci::Maskable::MASK_AND_RAW_REGEX.inspect.sub('\\A', '^').sub('\\z', '$')[1...-1] end diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb index 62bb2e4da23..aed730850fd 100644 --- a/app/helpers/count_helper.rb +++ b/app/helpers/count_helper.rb @@ -11,7 +11,7 @@ module CountHelper # This will approximate the fork count by checking all counting all fork network # memberships, and deducting 1 for each root of the fork network. - # This might be inacurate as the root of the fork network might have been deleted. + # This might be inaccurate as the root of the fork network might have been deleted. # # This makes querying this information a lot more efficient and it should be # accurate enough for the instance wide statistics diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 9031d0556da..6069e4e64a1 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -30,7 +30,7 @@ module DiffHelper def diff_options options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded?, use_extra_viewer_as_main: true } - if action_name == 'diff_for_path' + if action_name == 'diff_for_path' || action_name == 'diff_by_file_hash' options[:expanded] = true options[:paths] = params.values_at(:old_path, :new_path) options[:use_extra_viewer_as_main] = false diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index fa47a12a72c..adb2b03cd0a 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -1,14 +1,6 @@ # frozen_string_literal: true module EnvironmentHelper - # rubocop: disable CodeReuse/ActiveRecord - def environment_for_build(project, build) - return unless build.environment - - project.environments.find_by(name: build.expanded_environment_name) - end - # rubocop: enable CodeReuse/ActiveRecord - def deployment_path(deployment) [deployment.project, deployment.deployable] end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 6b1e3075968..ac34f429508 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -9,13 +9,14 @@ module EnvironmentsHelper } end - def environments_folder_list_view_data + def environments_folder_list_view_data(project, folder) { - "endpoint" => folder_project_environments_path(@project, @folder, format: :json), - "folder_name" => @folder, - "project_path" => project_path(@project), + "endpoint" => folder_project_environments_path(project, folder, format: :json), + "folder_name" => folder, + "project_path" => project.full_path, "help_page_path" => help_page_path("ci/environments/index"), - "can_read_environment" => can?(current_user, :read_environment, @project).to_s + "can_read_environment" => can?(current_user, :read_environment, @project).to_s, + "kas_tunnel_url" => ::Gitlab::Kas.tunnel_url } end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index d5f38debae4..bbcf408650d 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -24,7 +24,7 @@ module FormHelper # user"), use the message as-is message = error.message if custom_message.include?(attribute) - message = html_escape_once(message).html_safe + message = ERB::Util.html_escape_once(message).html_safe message = tag.span(message, class: 'str-truncated-100') if truncate.include?(attribute) message = append_help_page_link(message, error.options) if error.options[:help_page_url].present? diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 60011e31d43..07672343384 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -20,7 +20,7 @@ module Groups::GroupMembersHelper end def group_member_header_subtext(group) - html_escape(_("You're viewing members of %{strong_start}%{group_name}%{strong_end}.").html_safe) % { + ERB::Util.html_escape(_("You're viewing members of %{strong_start}%{group_name}%{strong_end}.").html_safe) % { group_name: group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 25a2cc8a5ae..96ae7be5fdc 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -37,7 +37,7 @@ module GroupsHelper group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png') end - def group_title(group, name = nil, url = nil) + def group_title(group) @has_group_title = true full_title = [] @@ -56,11 +56,6 @@ module GroupsHelper full_title << breadcrumb_list_item(group_title_link(group)) push_to_schema_breadcrumb(simple_sanitize(group.name), group_path(group)) - if name - full_title << ' · '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text') - push_to_schema_breadcrumb(simple_sanitize(name), url) - end - full_title.join.html_safe end @@ -160,6 +155,7 @@ module GroupsHelper new_project_illustration: image_path('illustrations/project-create-new-sm.svg'), empty_projects_illustration: image_path('illustrations/empty-state/empty-projects-md.svg'), empty_subgroup_illustration: image_path('illustrations/empty-state/empty-subgroup-md.svg'), + empty_search_illustration: image_path('illustrations/empty-state/empty-search-md.svg'), render_empty_state: 'true', can_create_subgroups: can?(current_user, :create_subgroup, group).to_s, can_create_projects: can?(current_user, :create_projects, group).to_s @@ -241,8 +237,8 @@ module GroupsHelper private - def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) - link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do + def group_title_link(group, hidable: false, show_avatar: false) + link_to(group_path(group), class: "group-path js-breadcrumb-item-text #{'hidable' if hidable}") do icon = render Pajamas::AvatarComponent.new(group, alt: group.name, class: "avatar-tile", size: 16) if group.try(:avatar_url) || show_avatar [icon, simple_sanitize(group.name)].join.html_safe end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 2ec11b8a9ed..312807c004a 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -88,7 +88,6 @@ module IdeHelper 'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s, 'default-branch' => project && project.default_branch, 'project' => convert_to_project_entity_json(project), - 'enable-environments-guidance' => enable_environments_guidance?(project).to_s, 'preview-markdown-path' => project && preview_markdown_path(project), 'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'), 'web-terminal-help-path' => help_page_path('user/project/web_ide/index', anchor: 'interactive-web-terminals-for-the-web-ide'), @@ -103,14 +102,6 @@ module IdeHelper API::Entities::Project.represent(project, current_user: current_user).to_json end - def enable_environments_guidance?(project) - experiment(:in_product_guidance_environments_webide, project: project) do |e| - e.candidate { !has_dismissed_ide_environments_callout? } - - e.run - end - end - def has_dismissed_ide_environments_callout? current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance') end diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 81b881592d0..1eac140c216 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -56,7 +56,7 @@ module ImportHelper link_url = 'https://github.com/settings/tokens' link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url } - html_escape(_('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the %{code_open}repo%{code_close} scope, so we can display a list of your public and private repositories which are available to import.')) % { link_start: link_start, link_end: '</a>'.html_safe, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + ERB::Util.html_escape(_('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the %{code_open}repo%{code_close} scope, so we can display a list of your public and private repositories which are available to import.')) % { link_start: link_start, link_end: '</a>'.html_safe, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } end def import_configure_github_admin_message diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb index 6a7e09f75e4..ba292f560c4 100644 --- a/app/helpers/listbox_helper.rb +++ b/app/helpers/listbox_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ListboxHelper - DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-dropdown btn-group js-redirect-listbox].freeze + DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-dropdown js-redirect-listbox].freeze DROPDOWN_BUTTON_CLASSES = %w[btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle].freeze DROPDOWN_INNER_CLASS = 'gl-dropdown-button-text' DROPDOWN_ICON_CLASS = 'gl-button-icon dropdown-chevron gl-icon' diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 2f042ea6417..75a41054ace 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -204,7 +204,8 @@ module MergeRequestsHelper is_forked: project.forked?.to_s, new_comment_template_path: profile_comment_templates_path, iid: merge_request.iid, - per_page: DIFF_BATCH_ENDPOINT_PER_PAGE + per_page: DIFF_BATCH_ENDPOINT_PER_PAGE, + pinned_file_url: @pinned_file_url } end @@ -253,13 +254,13 @@ module MergeRequestsHelper end branch = if merge_request.for_fork? - html_escape(_('%{fork_icon} %{source_project_path}:%{source_branch}')) % { fork_icon: fork_icon.html_safe, source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch } + ERB::Util.html_escape(_('%{fork_icon} %{source_project_path}:%{source_branch}')) % { fork_icon: fork_icon.html_safe, source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch } else merge_request.source_branch end branch_title = if merge_request.for_fork? - html_escape(_('%{source_project_path}:%{source_branch}')) % { source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch } + ERB::Util.html_escape(_('%{source_project_path}:%{source_branch}')) % { source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch } else merge_request.source_branch end @@ -275,7 +276,10 @@ module MergeRequestsHelper def merge_request_header(project, merge_request) link_to_author = link_to_member(project, merge_request.author, size: 24, extra_class: 'gl-font-weight-bold gl-mr-2', avatar: false) - copy_button = clipboard_button(text: merge_request.source_branch, title: _('Copy branch name'), class: 'gl-display-none! gl-md-display-inline-block! js-source-branch-copy') + copy_action_description = _('Copy branch name') + copy_action_shortcut = 'b' + copy_button_title = "#{copy_action_description} <kbd class='flat ml-1'>#{copy_action_shortcut}</kbd>" + copy_button = clipboard_button(text: merge_request.source_branch, title: copy_button_title, aria_keyshortcuts: copy_action_shortcut, aria_label: copy_action_description, class: 'gl-display-none! gl-md-display-inline-block! js-source-branch-copy') target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'ref-container gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' @@ -289,6 +293,7 @@ module MergeRequestsHelper sourceProjectPath: @merge_request.source_project_path, title: markdown_field(@merge_request, :title), isFluidLayout: fluid_layout.to_s, + blocksMerge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s, tabs: [ ['show', _('Overview'), project_merge_request_path(@project, @merge_request), @merge_request.related_notes.user.count], ['commits', _('Commits'), commits_project_merge_request_path(@project, @merge_request), @commits_count], diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index 158aa5e0944..1df79fb2083 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -12,7 +12,7 @@ module MirrorHelper docs_link_url = help_page_path('topics/git/lfs/index') docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } - html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) % + ERB::Util.html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index cb9a270253f..424a3f5f8c5 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -53,13 +53,6 @@ module NavHelper %w[system_info background_migrations background_jobs health_check] end - def show_super_sidebar?(_user = current_user) - # The new navigation is now enabled for everyone. - # We are working on cleaning up the use of this helper and other related code. - # See https://gitlab.com/groups/gitlab-org/-/epics/11875 - true - end - private def get_header_links diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb index d0dd9dc5aea..445dd3a1f6f 100644 --- a/app/helpers/organizations/organization_helper.rb +++ b/app/helpers/organizations/organization_helper.rb @@ -4,7 +4,8 @@ module Organizations module OrganizationHelper def organization_show_app_data(organization) { - organization: organization.slice(:id, :name), + organization: organization.slice(:id, :name, :description_html) + .merge({ avatar_url: organization.avatar_url(size: 128) }), groups_and_projects_organization_path: groups_and_projects_organization_path(organization), # TODO: Update counts to use real data # https://gitlab.com/gitlab-org/gitlab/-/issues/424531 @@ -17,18 +18,14 @@ module Organizations end def organization_new_app_data - { - organizations_path: organizations_path, - root_url: root_url - }.to_json + shared_new_settings_general_app_data.to_json end def organization_settings_general_app_data(organization) { - organization: organization.slice(:id, :name, :path), - organizations_path: organizations_path, - root_url: root_url - }.to_json + organization: organization.slice(:id, :name, :path, :description) + .merge({ avatar: organization.avatar_url(size: 192) }) + }.merge(shared_new_settings_general_app_data).to_json end def organization_groups_and_projects_app_data @@ -66,6 +63,14 @@ module Organizations } end + def shared_new_settings_general_app_data + { + preview_markdown_path: preview_markdown_organizations_path, + organizations_path: organizations_path, + root_url: root_url + } + end + # See UsersHelper#admin_users_paths for inspiration to this method def organizations_users_paths { diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 204e3b149b9..da8ef2277f1 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -75,10 +75,6 @@ module PreferencesHelper 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 - def user_theme_primary_color Gitlab::Themes.for_user(current_user).primary_color end diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb index 634f6e8ba59..20af643d159 100644 --- a/app/helpers/projects/project_members_helper.rb +++ b/app/helpers/projects/project_members_helper.rb @@ -19,7 +19,7 @@ module Projects::ProjectMembersHelper if can?(current_user, :admin_project_member, project) share_project_description(project) else - html_escape(_("Members can be added by project " \ + ERB::Util.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 } @@ -41,7 +41,7 @@ module Projects::ProjectMembersHelper _("You can invite a new member to %{project_name}.") end - html_escape(description) % { project_name: tag.strong(project.name) } + ERB::Util.html_escape(description) % { project_name: tag.strong(project.name) } end def project_members_serialized(project, members) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c2014508f4f..7ef6e0f5d02 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -191,7 +191,7 @@ module ProjectsHelper end def autodeploy_flash_notice(branch_name) - html_escape(_("Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}")) % + ERB::Util.html_escape(_("Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}")) % { branch_name: tag.strong(truncate(sanitize(branch_name))), link_to_autodeploy_doc: link_to_autodeploy_doc } end @@ -252,7 +252,7 @@ module ProjectsHelper _('Your account is authenticated with SSO or SAML. To %{push_pull_link_start}push and pull%{link_end} over %{protocol} with Git using this account, you must %{set_up_pat_link_start}set up a Personal Access Token%{link_end} to use instead of a password. For more information, see %{clone_with_https_link_start}Clone with HTTPS%{link_end}.') end - html_escape(message) % { + ERB::Util.html_escape(message) % { push_pull_link_start: push_pull_link_start, protocol: gitlab_config.protocol.upcase, clone_with_https_link_start: clone_with_https_link_start, @@ -768,7 +768,7 @@ module ProjectsHelper message = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end} in %{strong_start}%{group_name}%{strong_end}.") end - html_escape(message) % { strong_start: strong_start, strong_end: strong_end, project_name: project.name, group_name: project.group ? project.group.name : nil } + ERB::Util.html_escape(message) % { strong_start: strong_start, strong_end: strong_end, project_name: project.name, group_name: project.group ? project.group.name : nil } end def visibility_confirm_modal_data(project, target_form_id = nil) @@ -789,15 +789,15 @@ module ProjectsHelper push_to_schema_breadcrumb(project_name, project_path(project)) - link_to project_path(project) do + link_to project_path(project), class: 'gl-display-inline-flex!' do icon = render Pajamas::AvatarComponent.new(project, alt: project.name, size: 16, class: 'avatar-tile') if project.avatar_url && !Rails.env.test? - [icon, content_tag("span", project_name, class: "breadcrumb-item-text js-breadcrumb-item-text")].join.html_safe + [icon, content_tag("span", project_name, class: "js-breadcrumb-item-text")].join.html_safe end end def build_namespace_breadcrumb_link(project) if project.group - group_title(project.group, nil, nil) + group_title(project.group) else owner = project.namespace.owner name = simple_sanitize(owner.name) diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb index 363c38ffe59..12f7bd41968 100644 --- a/app/helpers/registrations_helper.rb +++ b/app/helpers/registrations_helper.rb @@ -11,10 +11,6 @@ module RegistrationsHelper } end - def signup_box_template - 'devise/shared/signup_box' - end - # overridden in EE def oauth_tracking_label; end diff --git a/app/helpers/reminder_emails_helper.rb b/app/helpers/reminder_emails_helper.rb index e46d9273100..f53ebd51380 100644 --- a/app/helpers/reminder_emails_helper.rb +++ b/app/helpers/reminder_emails_helper.rb @@ -41,7 +41,7 @@ module ReminderEmailsHelper body = invitation_reminder_body_text(reminder_index) - (format == :html ? html_escape(body) : body) % options + (format == :html ? ERB::Util.html_escape(body) : body) % options end def invitation_reminder_accept_link(token, format: nil) diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb index 9f8c5082c26..281bd783c93 100644 --- a/app/helpers/safe_format_helper.rb +++ b/app/helpers/safe_format_helper.rb @@ -3,8 +3,8 @@ module SafeFormatHelper # Returns a HTML-safe String. # - # @param [String] format is escaped via `html_escape_once` - # @param [Array<Hash>] args are escaped via `html_escape` if they are not marked as HTML-safe + # @param [String] format is escaped via `ERB::Util.html_escape_once` + # @param [Array<Hash>] args are escaped via `ERB::Util.html_escape` if they are not marked as HTML-safe # # @example # safe_format('See %{user_input}', user_input: '<b>bold</b>') diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index f002a0c454d..2ee20887129 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -136,15 +136,15 @@ module SearchHelper # - group # - group: nil, project: nil if project - html_escape(_("We couldn't find any %{scope} matching %{term} in project %{project}")) % options.merge( + ERB::Util.html_escape(_("We couldn't find any %{scope} matching %{term} in project %{project}")) % options.merge( project: link_to(project.full_name, project_path(project), target: '_blank', rel: 'noopener noreferrer').html_safe ) elsif group - html_escape(_("We couldn't find any %{scope} matching %{term} in group %{group}")) % options.merge( + ERB::Util.html_escape(_("We couldn't find any %{scope} matching %{term} in group %{group}")) % options.merge( group: link_to(group.full_name, group_path(group), target: '_blank', rel: 'noopener noreferrer').html_safe ) else - html_escape(_("We couldn't find any %{scope} matching %{term}")) % options + ERB::Util.html_escape(_("We couldn't find any %{scope} matching %{term}")) % options end end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index cf5cc92587f..7dccaa6cd73 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -3,43 +3,10 @@ module SessionsHelper include Gitlab::Utils::StrongMemoize - def recently_confirmed_com? - strong_memoize(:recently_confirmed_com) do - ::Gitlab.com? && - !!flash[:notice]&.include?(t(:confirmed, scope: [:devise, :confirmations])) - end - end - def unconfirmed_email? flash[:alert] == t(:unconfirmed, scope: [:devise, :failure]) end - # By default, all sessions are given the same expiration time configured in - # the session store (e.g. 1 week). However, unauthenticated users can - # generate a lot of sessions, primarily for CSRF verification. It makes - # sense to reduce the TTL for unauthenticated to something much lower than - # the default (e.g. 1 hour) to limit Redis memory. In addition, Rails - # creates a new session after login, so the short TTL doesn't even need to - # be extended. - def limit_session_time - set_session_time(Settings.gitlab['unauthenticated_session_expire_delay']) - end - - def ensure_authenticated_session_time - set_session_time(nil) - end - - def set_session_time(expiry_s) - # Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259 - return unless request.env['rack.session.options'] - - # This works because Rack uses these options every time a request is handled, and redis-store - # uses the Rack setting first: - # 1. https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342 - # 2. https://github.com/redis-store/redis-store/blob/3acfa95f4eb6260c714fdb00a3d84be8eedc13b2/lib/redis/store/ttl.rb#L32 - request.env['rack.session.options'][:expire_after] = expiry_s - end - def obfuscated_email(email) # Moved to Gitlab::Utils::Email in 15.9 Gitlab::Utils::Email.obfuscated_email(email) diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 9933fa8e4d9..92a4f32dfda 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -68,6 +68,16 @@ module SidebarsHelper name: user.name, username: user.username, admin_url: admin_root_url, + admin_mode: { + admin_mode_feature_enabled: Gitlab::CurrentSettings.admin_mode, + admin_mode_active: current_user_mode.admin_mode?, + enter_admin_mode_url: new_admin_session_path, + leave_admin_mode_url: destroy_admin_session_path, + # Usually, using current_user.admin? is discouraged because it does not + # check for admin mode, but since here we want to check admin? and admin mode + # separately, we'll have to ignore the cop rule. + user_is_admin: user.admin? # rubocop: disable Cop/UserAdmin + }, avatar_url: user.avatar_url, has_link_to_profile: current_user_menu?(:profile), link_to_profile: user_path(user), @@ -353,38 +363,12 @@ module SidebarsHelper ({ title: s_('Navigation|Preferences'), link: profile_preferences_path, icon: 'preferences' } if current_user) ] - # Usually, using current_user.admin? is discouraged because it does not - # check for admin mode, but since here we want to check admin? and admin mode - # separately, we'll have to ignore the cop rule. - # rubocop: disable Cop/UserAdmin if current_user&.can_admin_all_resources? links.append( { title: s_('Navigation|Admin Area'), link: admin_root_path, icon: 'admin' } ) end - if Gitlab::CurrentSettings.admin_mode - if header_link?(:admin_mode) - links.append( - { - title: s_('Navigation|Leave admin mode'), - link: destroy_admin_session_path, - icon: 'lock-open', - data_method: 'post' - } - ) - elsif current_user&.admin? - links.append( - { - title: s_('Navigation|Enter admin mode'), - link: new_admin_session_path, - icon: 'lock' - } - ) - end - end - # rubocop: enable Cop/UserAdmin - links.compact end diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb index 29bd5a84651..3ea043557b8 100644 --- a/app/helpers/time_zone_helper.rb +++ b/app/helpers/time_zone_helper.rb @@ -33,6 +33,19 @@ module TimeZoneHelper end end + # The identifiers in `timezone_data` are not unique. Some cities (e.g. London and Edinburgh) have + # the same `identifier` value (e.g. "Europe/London"). + # This method merges such entries into one, joining the city names. + # This unique list is better suited for selectboxes etc. + def timezone_data_with_unique_identifiers(format: :short) + timezone_data(format: format) + .group_by { |entry| entry[:identifier] } + .map do |_identifier, entries| + names = entries.map { |entry| entry[:name] }.sort.join(', ') # rubocop:disable Rails/Pluck -- Not a ActiveRecord object + entries.first.merge({ name: names }) + end + end + def local_timezone_instance(timezone) return Time.zone if timezone.blank? diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 84a809bc510..c0658859cc1 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -326,7 +326,7 @@ module UsersHelper job_title = '<span itemprop="jobTitle">'.html_safe + job_title + "</span>".html_safe organization = '<span itemprop="worksFor">'.html_safe + organization + "</span>".html_safe - html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization } + ERB::Util.html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization } else s_('Profile|%{job_title} at %{organization}') % { job_title: job_title, organization: organization } end diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb index adb9ffa39e0..1d2ff400995 100644 --- a/app/helpers/vite_helper.rb +++ b/app/helpers/vite_helper.rb @@ -4,11 +4,7 @@ module ViteHelper def vite_enabled? # vite is not production ready yet return false if Rails.env.production? - # Enable vite if explicitly turned on in the GDK - return Gitlab::Utils.to_boolean(ViteRuby.env['VITE_ENABLED'], default: false) if ViteRuby.env.key?('VITE_ENABLED') - # Enable vite the legacy way (in case GDK hasn't been updated) - # This is going to be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/431041 - Rails.env.development? ? Feature.enabled?(:vite) : false + Gitlab::Utils.to_boolean(ViteRuby.env['VITE_ENABLED'], default: false) end end diff --git a/app/helpers/wiki_page_version_helper.rb b/app/helpers/wiki_page_version_helper.rb index ae20717ad99..1771681c9b1 100644 --- a/app/helpers/wiki_page_version_helper.rb +++ b/app/helpers/wiki_page_version_helper.rb @@ -15,6 +15,6 @@ module WikiPageVersionHelper name = "<strong>".html_safe + wiki_page_version.author_name + "</strong>".html_safe link_start = "<a href='".html_safe + wiki_page_version_author_url(wiki_page_version) + "'>".html_safe - html_escape(_("Last edited by %{link_start}%{avatar} %{name}%{link_end}")) % { avatar: avatar, name: name, link_start: link_start, link_end: '</a>'.html_safe } + ERB::Util.html_escape(_("Last edited by %{link_start}%{avatar} %{name}%{link_end}")) % { avatar: avatar, name: name, link_start: link_start, link_end: '</a>'.html_safe } end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index f859294960c..bec37610594 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -37,8 +37,10 @@ module Emails def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id, reason = nil) setup_issue_mail(issue_id, recipient_id) - @previous_assignees = [] - @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + previous_assignees = [] + previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + @added_assignees = @issue.assignees.map(&:name) - previous_assignees.map(&:name) + @removed_assignees = previous_assignees.map(&:name) - @issue.assignees.map(&:name) mail_answer_thread( @issue, diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index c702b107b7e..07d033ec53c 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -42,8 +42,10 @@ module Emails def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_ids, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) - @previous_assignees = [] - @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + previous_assignees = [] + previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + @added_assignees = @merge_request.assignees.map(&:name) - previous_assignees.map(&:name) + @removed_assignees = previous_assignees.map(&:name) - @merge_request.assignees.map(&:name) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 19dc0e40564..e19a75a68e8 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -205,11 +205,12 @@ class AbuseReport < ApplicationRecord return if links_to_spam.blank? links_to_spam.each do |link| - Gitlab::UrlBlocker.validate!( + Gitlab::HTTP_V2::UrlBlocker.validate!( link, schemes: %w[http https], allow_localhost: true, - dns_rebind_protection: true + dns_rebind_protection: true, + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) next unless link.length > MAX_CHAR_LIMIT_URL diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb index 46dfbe9078c..d2d64079c74 100644 --- a/app/models/ai/service_access_token.rb +++ b/app/models/ai/service_access_token.rb @@ -2,11 +2,8 @@ module Ai class ServiceAccessToken < ApplicationRecord - include IgnorableColumns self.table_name = 'service_access_tokens' - ignore_column :category, remove_with: '16.8', remove_after: '2024-01-22' - scope :expired, -> { where('expires_at < :now', now: Time.current) } scope :active, -> { where('expires_at > :now', now: Time.current) } diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb index 0f8e184933e..5ac5437a442 100644 --- a/app/models/analytics/cycle_analytics/aggregation.rb +++ b/app/models/analytics/cycle_analytics/aggregation.rb @@ -59,19 +59,26 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord estimation < 1 ? nil : estimation.from_now end - def self.safe_create_for_namespace(group_or_project_namespace) + def self.safe_create_for_namespace(target_namespace) # Namespaces::ProjectNamespace has no root_ancestor # Related: https://gitlab.com/gitlab-org/gitlab/-/issues/386124 - group = group_or_project_namespace.is_a?(Group) ? group_or_project_namespace : group_or_project_namespace.parent - top_level_group = group.root_ancestor - aggregation = find_by(group_id: top_level_group.id) + namespace = if target_namespace.is_a?(Group) || target_namespace.is_a?(Namespaces::UserNamespace) + target_namespace + else + target_namespace.parent + end + # personal namespace projects and associated ProjectNamespace respond to `namespace` + # and this is close enough to "root ancestor" + top_level_namespace = + target_namespace.respond_to?(:root_ancestor) ? namespace.root_ancestor : namespace.namespace + aggregation = find_by(group_id: top_level_namespace.id) return aggregation if aggregation&.enabled? # At this point we're sure that the group is licensed, we can always enable the aggregation. # This re-enables the aggregation in case the group downgraded and later upgraded the license. - upsert({ group_id: top_level_group.id, enabled: true }) + upsert({ group_id: top_level_namespace.id, enabled: true }) - find(top_level_group.id) + find(top_level_namespace.id) end private diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb index 6f152e7749e..4686dc3aedd 100644 --- a/app/models/analytics/cycle_analytics/stage.rb +++ b/app/models/analytics/cycle_analytics/stage.rb @@ -7,7 +7,6 @@ module Analytics self.table_name = :analytics_cycle_analytics_group_stages - include DatabaseEventTracking include Analytics::CycleAnalytics::Stageable include Analytics::CycleAnalytics::Parentable @@ -38,22 +37,6 @@ module Analytics .select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*") end - SNOWPLOW_ATTRIBUTES = %i[ - id - created_at - updated_at - relative_position - start_event_identifier - end_event_identifier - group_id - start_event_label_id - end_event_label_id - hidden - custom - name - group_value_stream_id - ].freeze - private def max_stages_count diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index cb533a5e99d..35d4722b711 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -99,7 +99,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' } validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } } - validates :failed_login_attempts_unlock_period_in_minutes, + validates :external_pipeline_validation_service_timeout, + :failed_login_attempts_unlock_period_in_minutes, + :max_login_attempts, allow_nil: true, numericality: { only_integer: true, greater_than: 0 } @@ -118,10 +120,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, qualified_domain_array: true - validates :session_expire_delay, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :minimum_password_length, presence: true, numericality: { @@ -222,38 +220,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord hostname: true, length: { maximum: 255 } - validates :max_attachment_size, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :max_artifacts_size, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :max_export_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_import_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_import_remote_file_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :bulk_import_max_download_file_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_decompressed_archive_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_login_attempts, - allow_nil: true, - numericality: { only_integer: true, greater_than: 0 } - validates :max_pages_size, presence: true, numericality: { @@ -261,31 +227,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte } - validates :max_pages_custom_domains_per_project, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :jobs_per_stage_page_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_terraform_state_size_bytes, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :default_artifacts_expire_in, presence: true, duration: true validates :container_expiration_policies_enable_historic_entries, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :container_registry_token_expire_delay, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :decompress_archive_file_timeout, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validate :check_repository_storages_weighted validates :auto_devops_domain, @@ -300,14 +246,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' }, if: :domain_denylist_enabled? - validates :housekeeping_optimize_repository_period, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :terminal_max_session_time, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :polling_interval_multiplier, presence: true, numericality: { greater_than_or_equal_to: 0 } @@ -413,59 +351,26 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false - validates :push_event_hooks_limit, - numericality: { greater_than_or_equal_to: 0 } - validates :push_event_activities_limit, + :push_event_hooks_limit, numericality: { greater_than_or_equal_to: 0 } - validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 } validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes } validates :wiki_asciidoc_allow_uri_includes, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true - validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true - - validates :ci_max_total_yaml_size_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true - - validates :ci_max_includes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true validates :email_restrictions, untrusted_regexp: true validates :hashed_storage_enabled, inclusion: { in: [true], message: N_("Hashed storage can't be disabled anymore for new projects") } - validates :container_registry_delete_tags_service_timeout, - :container_registry_cleanup_tags_service_max_list_size, - :container_registry_data_repair_detail_worker_max_concurrency, - :container_registry_expiration_policies_worker_capacity, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :container_registry_expiration_policies_caching, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :container_registry_import_max_tags_count, - :container_registry_import_max_retries, - :container_registry_import_start_max_retries, - :container_registry_import_max_step_duration, - :container_registry_pre_import_timeout, - :container_registry_import_timeout, - allow_nil: false, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :container_registry_pre_import_tags_rate, allow_nil: false, numericality: { greater_than_or_equal_to: 0 } validates :container_registry_import_target_plan, presence: true validates :container_registry_import_created_before, presence: true - validates :dependency_proxy_ttl_group_policy_worker_capacity, - allow_nil: false, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :packages_cleanup_package_file_worker_capacity, - :package_registry_cleanup_policies_worker_capacity, - allow_nil: false, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :invisible_captcha_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -584,15 +489,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 255 }, allow_blank: true - validates :issues_create_limit, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :raw_blob_request_limit, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :pipeline_limit_per_project_user_sha, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :ci_jwt_signing_key, rsa_key: true, allow_nil: true @@ -619,41 +515,90 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :slack_app_verification_token end - with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do - validates :throttle_unauthenticated_api_requests_per_period - validates :throttle_unauthenticated_api_period_in_seconds - validates :throttle_unauthenticated_requests_per_period - validates :throttle_unauthenticated_period_in_seconds - validates :throttle_unauthenticated_packages_api_requests_per_period - validates :throttle_unauthenticated_packages_api_period_in_seconds - validates :throttle_unauthenticated_files_api_requests_per_period - validates :throttle_unauthenticated_files_api_period_in_seconds - validates :throttle_unauthenticated_deprecated_api_requests_per_period - validates :throttle_unauthenticated_deprecated_api_period_in_seconds - validates :throttle_authenticated_api_requests_per_period - validates :throttle_authenticated_api_period_in_seconds - validates :throttle_authenticated_git_lfs_requests_per_period - validates :throttle_authenticated_git_lfs_period_in_seconds - validates :throttle_authenticated_web_requests_per_period - validates :throttle_authenticated_web_period_in_seconds - validates :throttle_authenticated_packages_api_requests_per_period - validates :throttle_authenticated_packages_api_period_in_seconds - validates :throttle_authenticated_files_api_requests_per_period - validates :throttle_authenticated_files_api_period_in_seconds - validates :throttle_authenticated_deprecated_api_requests_per_period - validates :throttle_authenticated_deprecated_api_period_in_seconds - validates :throttle_protected_paths_requests_per_period - validates :throttle_protected_paths_period_in_seconds - validates :project_jobs_api_rate_limit + with_options(numericality: { only_integer: true, greater_than: 0 }) do + validates :bulk_import_concurrent_pipeline_batch_limit, + :container_registry_token_expire_delay, + :housekeeping_optimize_repository_period, + :inactive_projects_delete_after_months, + :max_artifacts_size, + :max_attachment_size, + :max_yaml_depth, + :max_yaml_size_bytes, + :namespace_aggregation_schedule_lease_duration_in_seconds, + :project_jobs_api_rate_limit, + :snippet_size_limit, + :throttle_authenticated_api_period_in_seconds, + :throttle_authenticated_api_requests_per_period, + :throttle_authenticated_deprecated_api_period_in_seconds, + :throttle_authenticated_deprecated_api_requests_per_period, + :throttle_authenticated_files_api_period_in_seconds, + :throttle_authenticated_files_api_requests_per_period, + :throttle_authenticated_git_lfs_period_in_seconds, + :throttle_authenticated_git_lfs_requests_per_period, + :throttle_authenticated_packages_api_period_in_seconds, + :throttle_authenticated_packages_api_requests_per_period, + :throttle_authenticated_web_period_in_seconds, + :throttle_authenticated_web_requests_per_period, + :throttle_protected_paths_period_in_seconds, + :throttle_protected_paths_requests_per_period, + :throttle_unauthenticated_api_period_in_seconds, + :throttle_unauthenticated_api_requests_per_period, + :throttle_unauthenticated_deprecated_api_period_in_seconds, + :throttle_unauthenticated_deprecated_api_requests_per_period, + :throttle_unauthenticated_files_api_period_in_seconds, + :throttle_unauthenticated_files_api_requests_per_period, + :throttle_unauthenticated_packages_api_period_in_seconds, + :throttle_unauthenticated_packages_api_requests_per_period, + :throttle_unauthenticated_period_in_seconds, + :throttle_unauthenticated_requests_per_period end with_options(numericality: { only_integer: true, greater_than_or_equal_to: 0 }) do - validates :notes_create_limit - validates :search_rate_limit - validates :search_rate_limit_unauthenticated - validates :projects_api_rate_limit_unauthenticated - validates :gitlab_shell_operation_limit - end + validates :bulk_import_max_download_file_size, + :ci_max_includes, + :ci_max_total_yaml_size_bytes, + :container_registry_cleanup_tags_service_max_list_size, + :container_registry_data_repair_detail_worker_max_concurrency, + :container_registry_delete_tags_service_timeout, + :container_registry_expiration_policies_worker_capacity, + :container_registry_import_max_retries, + :container_registry_import_max_step_duration, + :container_registry_import_max_tags_count, + :container_registry_import_start_max_retries, + :container_registry_import_timeout, + :container_registry_pre_import_timeout, + :decompress_archive_file_timeout, + :dependency_proxy_ttl_group_policy_worker_capacity, + :gitlab_shell_operation_limit, + :inactive_projects_min_size_mb, + :issues_create_limit, + :jobs_per_stage_page_size, + :max_decompressed_archive_size, + :max_export_size, + :max_import_remote_file_size, + :max_import_size, + :max_pages_custom_domains_per_project, + :max_terraform_state_size_bytes, + :members_delete_limit, + :notes_create_limit, + :package_registry_cleanup_policies_worker_capacity, + :packages_cleanup_package_file_worker_capacity, + :pipeline_limit_per_project_user_sha, + :projects_api_rate_limit_unauthenticated, + :raw_blob_request_limit, + :search_rate_limit, + :search_rate_limit_unauthenticated, + :session_expire_delay, + :sidekiq_job_limiter_compression_threshold_bytes, + :sidekiq_job_limiter_limit_bytes, + :terminal_max_session_time, + :users_get_by_id_limit + end + + jsonb_accessor :rate_limits, + members_delete_limit: [:integer, { default: 60 }] + + validates :rate_limits, json_schema: { filename: "application_setting_rate_limits" } validates :search_rate_limit_allowlist, length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, @@ -669,10 +614,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :external_pipeline_validation_service_url, addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true - validates :external_pipeline_validation_service_timeout, - allow_nil: true, - numericality: { only_integer: true, greater_than: 0 } - validates :whats_new_variant, inclusion: { in: ApplicationSetting.whats_new_variants.keys } @@ -686,10 +627,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :sidekiq_job_limiter_mode, inclusion: { in: self.sidekiq_job_limiter_modes } - validates :sidekiq_job_limiter_compression_threshold_bytes, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :sidekiq_job_limiter_limit_bytes, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :sentry_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -711,8 +648,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 255 }, if: :error_tracking_enabled? - validates :users_get_by_id_limit, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :users_get_by_id_limit_allowlist, length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false @@ -724,20 +659,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: true, if: :update_runner_versions_enabled? - validates :inactive_projects_min_size_mb, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :inactive_projects_delete_after_months, - numericality: { only_integer: true, greater_than: 0 } - validates :inactive_projects_send_warning_email_after_months, numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months } validates :prometheus_alert_db_indicators_settings, json_schema: { filename: 'application_setting_prometheus_alert_db_indicators_settings' }, allow_nil: true - validates :namespace_aggregation_schedule_lease_duration_in_seconds, - numericality: { only_integer: true, greater_than: 0 } - validates :sentry_clientside_traces_sample_rate, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1, message: N_('must be a value between 0 and 1') } @@ -815,10 +741,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :bulk_import_concurrent_pipeline_batch_limit, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - validates :allow_runner_registration_token, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -835,6 +757,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :math_rendering_limits_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :require_admin_two_factor_authentication, + inclusion: { in: [true, false], message: N_('must be a boolean value') } + before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? before_validation :normalize_default_branch_name @@ -982,7 +907,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord end def parsed_kroki_url - @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w[http https], enforce_sanitization: true)[0] + @parsed_kroki_url ||= Gitlab::HTTP_V2::UrlBlocker.validate!( + kroki_url, schemes: %w[http https], + enforce_sanitization: true, + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?)[0] rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e self.errors.add( :kroki_url, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 851b65055d0..d1899b18a4f 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -79,6 +79,7 @@ module ApplicationSettingImplementation ecdsa_sk_key_restriction: default_min_key_size(:ecdsa_sk), ed25519_key_restriction: default_min_key_size(:ed25519), ed25519_sk_key_restriction: default_min_key_size(:ed25519_sk), + require_admin_two_factor_authentication: false, eks_access_key_id: nil, eks_account_id: nil, eks_integration_enabled: false, @@ -136,6 +137,7 @@ module ApplicationSettingImplementation mirror_available: true, notes_create_limit: 300, notes_create_limit_allowlist: [], + members_delete_limit: 60, notify_on_unknown_sign_in: true, outbound_local_requests_whitelist: [], password_authentication_enabled_for_git: true, @@ -275,7 +277,8 @@ module ApplicationSettingImplementation allow_account_deletion: true, gitlab_shell_operation_limit: 600, project_jobs_api_rate_limit: 600, - security_txt_content: nil + security_txt_content: nil, + allow_project_creation_for_guest_and_below: true }.tap do |hsh| hsh.merge!(non_production_defaults) unless Rails.env.production? end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 894e28dd88a..a6969ce6f76 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -150,9 +150,9 @@ class BulkImports::Entity < ApplicationRecord File.join(base_resource_path, 'export_relations') end - def export_relations_url_path(batched: false) - if batched && bulk_import.supports_batched_export? - Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: batched) + def export_relations_url_path + if bulk_import.supports_batched_export? + Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: true) else export_relations_url_path_base end diff --git a/app/models/bulk_imports/failure.rb b/app/models/bulk_imports/failure.rb index 8a6077b523c..e23e49c6396 100644 --- a/app/models/bulk_imports/failure.rb +++ b/app/models/bulk_imports/failure.rb @@ -19,6 +19,14 @@ class BulkImports::Failure < ApplicationRecord super(::Projects::ImportErrorFilter.filter_message(message).truncate(255)) end + def source_title=(title) + super(title&.truncate(255, omission: '')) + end + + def source_url=(url) + super(url&.truncate(255, omission: '')) + end + private def pipeline_relation diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e56f3d2536c..d4c70a294ff 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -27,6 +27,7 @@ module Ci foreign_key: :commit_id, partition_foreign_key: :partition_id, inverse_of: :builds + belongs_to :project_mirror, primary_key: :project_id, foreign_key: :project_id, inverse_of: :builds RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, @@ -42,6 +43,8 @@ module Ci DEPLOYMENT_NAMES = %w[deploy release rollout].freeze + TOKEN_PREFIX = 'glcbt-' + has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build @@ -98,6 +101,7 @@ module Ci delegate :harbor_integration, to: :project delegate :apple_app_store_integration, to: :project delegate :google_play_integration, to: :project + delegate :diffblue_cover_integration, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true delegate :ensure_persistent_ref, to: :pipeline delegate :enable_debug_trace!, to: :metadata @@ -188,6 +192,10 @@ module Ci # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123131 scope :with_runner_type, -> (runner_type) { joins(:runner).where(runner: { runner_type: runner_type }) } + scope :belonging_to_runner_manager, -> (runner_machine_id) { + joins(:runner_manager_build).where(p_ci_runner_machine_builds: { runner_machine_id: runner_machine_id }) + } + scope :with_secure_reports_from_config_options, -> (job_types) do joins(:metadata).where("#{Ci::BuildMetadata.quoted_table_name}.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) end @@ -204,7 +212,7 @@ module Ci add_authentication_token_field :token, encrypted: :required, - format_with_prefix: :partition_id_prefix_in_16_bit_encode + format_with_prefix: :prefix_and_partition_for_token after_save :stick_build_if_status_changed @@ -516,6 +524,7 @@ module Ci .concat(harbor_variables) .concat(apple_app_store_variables) .concat(google_play_variables) + .concat(diffblue_cover_variables) end end @@ -568,6 +577,12 @@ module Ci Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables(protected_ref: pipeline.protected_ref?)) end + def diffblue_cover_variables + return [] unless diffblue_cover_integration.try(:activated?) + + Gitlab::Ci::Variables::Collection.new(diffblue_cover_integration.ci_variables) + end + def features { trace_sections: true, @@ -1232,6 +1247,14 @@ module Ci def partition_id_prefix_in_16_bit_encode "#{partition_id.to_s(16)}_" end + + def prefix_and_partition_for_token + if Feature.enabled?(:prefix_ci_build_tokens, project, type: :beta) + TOKEN_PREFIX + partition_id_prefix_in_16_bit_encode + else + partition_id_prefix_in_16_bit_encode + end + end end end diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb index 4273c4515bc..0ea2735b030 100644 --- a/app/models/ci/catalog/resources/version.rb +++ b/app/models/ci/catalog/resources/version.rb @@ -19,6 +19,7 @@ module Ci scope :for_catalog_resources, ->(catalog_resources) { where(catalog_resource_id: catalog_resources) } scope :preloaded, -> { includes(:catalog_resource, project: [:route, { namespace: :route }], release: :author) } + scope :by_name, ->(name) { joins(:release).merge(Release.where(tag: name)) } scope :order_by_created_at_asc, -> { reorder(created_at: :asc) } scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } @@ -122,6 +123,14 @@ module Ci project.commit_by(oid: sha) end + def path + Gitlab::Routing.url_helpers.project_tag_path(project, name) + end + + def readme + project.repository.tree(sha).readme + end + private def update_catalog_resource diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 179befb8469..6a2fb1132c0 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -13,6 +13,7 @@ module Ci alias_attribute :secret_value, :value + validates :description, length: { maximum: 255 }, allow_blank: true validates :key, uniqueness: { message: -> (object, data) { _("(%{value}) has already been taken") } } diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb index ff7e681217a..5f55713b436 100644 --- a/app/models/ci/namespace_mirror.rb +++ b/app/models/ci/namespace_mirror.rb @@ -7,6 +7,7 @@ module Ci include FromUnion belongs_to :namespace + has_many :project_mirrors, primary_key: :namespace_id, foreign_key: :namespace_id, inverse_of: :namespace_mirror scope :by_group_and_descendants, -> (id) do where('traversal_ids @> ARRAY[?]::int[]', id) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 9d5b2e5a0b1..1bf4d585e1c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -20,7 +20,6 @@ module Ci include IgnorableColumns ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' - ignore_column :auto_canceled_by_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' MAX_OPEN_MERGE_REQUESTS_REFS = 4 @@ -439,7 +438,7 @@ module Ci where_exists(Ci::Build.latest.scoped_pipeline.with_artifacts(reports_scope)) end - scope :with_only_interruptible_builds, -> do + scope :conservative_interruptible, -> do where_not_exists( Ci::Build.scoped_pipeline.with_status(STARTED_STATUSES).not_interruptible ) @@ -621,7 +620,7 @@ module Ci end def valid_commit_sha - if self.sha == Gitlab::Git::BLANK_SHA + if self.sha == Gitlab::Git::SHA1_BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") end end @@ -675,7 +674,7 @@ module Ci end def before_sha - super || Gitlab::Git::BLANK_SHA + super || Gitlab::Git::SHA1_BLANK_SHA end def short_sha @@ -1394,6 +1393,10 @@ module Ci merge_request.merge_request_diff_for(merge_request_diff_sha) end + def auto_cancel_on_new_commit + pipeline_metadata&.auto_cancel_on_new_commit || 'conservative' + end + private def add_message(severity, content) diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 6d22a875aab..e0e6906f211 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -4,6 +4,7 @@ module Ci class PipelineArtifact < Ci::ApplicationRecord + include Ci::Partitionable include UpdateProjectStatistics include Artifactable include FileStoreMounter @@ -31,6 +32,8 @@ module Ci validates :size, presence: true, numericality: { less_than_or_equal_to: FILE_SIZE_LIMIT } validates :file_type, presence: true + partitionable scope: :pipeline + mount_file_store_uploader Ci::PipelineArtifactUploader update_project_statistics project_statistics_name: :pipeline_artifacts_size diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb index ba20c993e36..1a2bc37d17d 100644 --- a/app/models/ci/pipeline_chat_data.rb +++ b/app/models/ci/pipeline_chat_data.rb @@ -2,14 +2,21 @@ module Ci class PipelineChatData < Ci::ApplicationRecord + include Ci::Partitionable include Ci::NamespacedModelName + include SafelyChangeColumnDefault + + columns_changing_default :partition_id self.table_name = 'ci_pipeline_chat_data' belongs_to :chat_name + belongs_to :pipeline validates :pipeline_id, presence: true validates :chat_name_id, presence: true validates :response_url, presence: true + + partitionable scope: :pipeline end end diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb index e2dcad653d7..11decd3fc66 100644 --- a/app/models/ci/pipeline_config.rb +++ b/app/models/ci/pipeline_config.rb @@ -2,11 +2,15 @@ module Ci class PipelineConfig < Ci::ApplicationRecord + include Ci::Partitionable + self.table_name = 'ci_pipelines_config' self.primary_key = :pipeline_id belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_config validates :pipeline, presence: true validates :content, presence: true + + partitionable scope: :pipeline end end diff --git a/app/models/ci/pipeline_metadata.rb b/app/models/ci/pipeline_metadata.rb index 37fa3e32ad8..21d102374f0 100644 --- a/app/models/ci/pipeline_metadata.rb +++ b/app/models/ci/pipeline_metadata.rb @@ -2,12 +2,15 @@ module Ci class PipelineMetadata < Ci::ApplicationRecord + include Ci::Partitionable + include Importable + self.primary_key = :pipeline_id enum auto_cancel_on_new_commit: { conservative: 0, interruptible: 1, - disabled: 2 + none: 2 }, _prefix: true enum auto_cancel_on_job_failure: { @@ -21,5 +24,7 @@ module Ci validates :pipeline, presence: true validates :project, presence: true validates :name, length: { minimum: 1, maximum: 255 }, allow_nil: true + + partitionable scope: :pipeline end end diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index b1831e365b1..4fddb3e053e 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -5,9 +5,6 @@ module Ci include Ci::Partitionable include Ci::HasVariable include Ci::RawVariable - include IgnorableColumns - - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' belongs_to :pipeline diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 414d36da7c3..989d6337ab7 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -33,6 +33,10 @@ module Ci where('NOT EXISTS (?)', needs) end + scope :interruptible, -> do + joins(:metadata).merge(Ci::BuildMetadata.with_interruptible) + end + scope :not_interruptible, -> do joins(:metadata).where.not( Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) } diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb index 23cd5d92730..c6828f827b5 100644 --- a/app/models/ci/project_mirror.rb +++ b/app/models/ci/project_mirror.rb @@ -7,6 +7,8 @@ module Ci include FromUnion belongs_to :project + belongs_to :namespace_mirror, primary_key: :namespace_id, foreign_key: :namespace_id, inverse_of: :project_mirrors + has_many :builds, primary_key: :project_id, foreign_key: :project_id, inverse_of: :project_mirror scope :by_namespace_id, -> (namespace_id) { where(namespace_id: namespace_id) } scope :by_project_id, -> (project_id) { where(project_id: project_id) } diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 9c30beeeb59..5fb982ee21e 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -14,6 +14,7 @@ module Ci include Presentable include EachBatch include Ci::HasRunnerExecutor + include Ci::HasRunnerStatus extend ::Gitlab::Utils::Override @@ -85,22 +86,22 @@ module Ci before_save :ensure_token - scope :active, -> (value = true) { where(active: value) } + scope :active, ->(value = true) { where(active: value) } scope :paused, -> { active(false) } - scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) } scope :recent, -> do timestamp = stale_deadline where(arel_table[:created_at].gteq(timestamp).or(arel_table[:contacted_at].gteq(timestamp))) end scope :stale, -> do - timestamp = stale_deadline + stale_timestamp = stale_deadline + + created_before_stale_deadline = arel_table[:created_at].lteq(stale_timestamp) + contacted_before_stale_deadline = arel_table[:contacted_at].lteq(stale_timestamp) + never_contacted = arel_table[:contacted_at].eq(nil) - where(arel_table[:created_at].lteq(timestamp)) - .where(arel_table[:contacted_at].eq(nil).or(arel_table[:contacted_at].lteq(timestamp))) + where(created_before_stale_deadline).where(never_contacted.or(contacted_before_stale_deadline)) end - scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } - scope :never_contacted, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where(arel_table[:contacted_at].gt(recent_queue_deadline)) } @@ -220,6 +221,11 @@ module Ci validate :exactly_one_group, if: :group_type? scope :with_version_prefix, ->(value) { joins(:runner_managers).merge(RunnerManager.with_version_prefix(value)) } + scope :with_runner_type, ->(runner_type) do + return all if AVAILABLE_TYPES.exclude?(runner_type.to_s) + + where(runner_type: runner_type) + end acts_as_taggable @@ -348,23 +354,6 @@ module Ci description end - def online? - contacted_at && contacted_at > self.class.online_contact_time_deadline - end - - def stale? - return false unless created_at - - [created_at, contacted_at].compact.max <= self.class.stale_deadline - end - - def status - return :stale if stale? - return :never_contacted unless contacted_at - - online? ? :online : :offline - end - # DEPRECATED # TODO Remove in v5 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 def deprecated_rest_status @@ -475,6 +464,21 @@ module Ci end end + def clear_heartbeat + cleared_attributes = { + version: nil, + revision: nil, + platform: nil, + architecture: nil, + ip_address: nil, + executor_type: nil, + config: {}, + contacted_at: nil + } + merge_cache_attributes(cleared_attributes) + update_columns(cleared_attributes) + end + def pick_build!(build) tick_runner_queue if matches_build?(build) end diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb index e6576859827..44fe1bdd67d 100644 --- a/app/models/ci/runner_manager.rb +++ b/app/models/ci/runner_manager.rb @@ -5,10 +5,13 @@ module Ci include FromUnion include RedisCacheable include Ci::HasRunnerExecutor + include Ci::HasRunnerStatus # For legacy reasons, the table name is ci_runner_machines in the database self.table_name = 'ci_runner_machines' + AVAILABLE_STATUSES = %w[online offline never_contacted stale].freeze + # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner Machine DB entry can be updated UPDATE_CONTACT_COLUMN_EVERY = (40.minutes)..(55.minutes) @@ -36,19 +39,26 @@ module Ci STALE_TIMEOUT = 7.days scope :stale, -> do - created_some_time_ago = arel_table[:created_at].lteq(STALE_TIMEOUT.ago) - contacted_some_time_ago = arel_table[:contacted_at].lteq(STALE_TIMEOUT.ago) + stale_timestamp = stale_deadline + + created_before_stale_deadline = arel_table[:created_at].lteq(stale_timestamp) + contacted_before_stale_deadline = arel_table[:contacted_at].lteq(stale_timestamp) from_union( - where(contacted_at: nil), - where(contacted_some_time_ago), - remove_duplicates: false).where(created_some_time_ago) + never_contacted, + where(contacted_before_stale_deadline), + remove_duplicates: false + ).where(created_before_stale_deadline) end scope :for_runner, ->(runner_id) do where(runner_id: runner_id) end + scope :with_system_xid, ->(system_xid) do + where(system_xid: system_xid) + end + scope :with_running_builds, -> do where('EXISTS(?)', Ci::Build.select(1) @@ -114,25 +124,8 @@ module Ci end end - def status - return :stale if stale? - return :never_contacted unless contacted_at - - online? ? :online : :offline - end - private - def online? - contacted_at && contacted_at > self.class.online_contact_time_deadline - end - - def stale? - return false unless created_at - - [created_at, contacted_at].compact.max <= self.class.stale_deadline - end - def persist_cached_data? # Use a random threshold to prevent beating DB updates. contacted_at_max_age = Random.rand(UPDATE_CONTACT_COLUMN_EVERY) diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index becb8f204bf..ba1a0a46247 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -7,9 +7,6 @@ module Ci include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable - include IgnorableColumns - - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' partitionable scope: :pipeline diff --git a/app/models/commit.rb b/app/models/commit.rb index 886e6e9fbd7..9c8d7604031 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -359,7 +359,7 @@ class Commit def diff_refs Gitlab::Diff::DiffRefs.new( - base_sha: self.parent_id || Gitlab::Git::BLANK_SHA, + base_sha: self.parent_id || Gitlab::Git::SHA1_BLANK_SHA, head_sha: self.sha ) end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index f1aeb7e528f..3a9b1465682 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -86,7 +86,7 @@ class CommitStatus < Ci::ApplicationRecord scope :for_project_paths, -> (paths) do # Pluck is used to split this query. Splitting the query is required for database decomposition for `ci_*` tables. # https://docs.gitlab.com/ee/development/database/transaction_guidelines.html#database-decomposition-and-sharding - project_ids = Project.where_full_path_in(Array(paths), use_includes: false).pluck(:id) + project_ids = Project.where_full_path_in(Array(paths), preload_routes: false).pluck(:id) for_project(project_ids) end diff --git a/app/models/compare.rb b/app/models/compare.rb index 58279cb58aa..d80f3f72ca7 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -require 'set' +require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby 3.1 and earlier needs this. Drop this line after Ruby 3.2+ is only supported. class Compare include Gitlab::Utils::StrongMemoize include ActsAsPaginatedDiff - delegate :same, :head, :base, to: :@compare + delegate :same, :head, :base, :generated_files, to: :@compare attr_reader :project diff --git a/app/models/concerns/analytics/cycle_analytics/parentable.rb b/app/models/concerns/analytics/cycle_analytics/parentable.rb index 785f6eea6bf..90a38e3c58c 100644 --- a/app/models/concerns/analytics/cycle_analytics/parentable.rb +++ b/app/models/concerns/analytics/cycle_analytics/parentable.rb @@ -6,16 +6,7 @@ module Analytics extend ActiveSupport::Concern included do - belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf - - validate :ensure_namespace_type - - def ensure_namespace_type - return if namespace.nil? - return if namespace.is_a?(::Namespaces::ProjectNamespace) || namespace.is_a?(::Group) - - errors.add(:namespace, s_('CycleAnalytics|the assigned object is not supported')) - end + belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf -- this relation is not present on Namespace end end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index ec4ee7985fe..f51b0967968 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -219,8 +219,8 @@ module AtomicInternalId ::AtomicInternalId.scope_usage(self.class) end - def self.scope_usage(including_class) - including_class.table_name.to_sym + def self.scope_usage(klass) + klass.respond_to?(:internal_id_scope_usage) ? klass.internal_id_scope_usage : klass.table_name.to_sym end def self.project_init(klass, column_name = :iid) diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 6a855198697..7c7fd882228 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -40,8 +40,6 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE - if Feature.enabled?(:personal_snippet_reference_filters, context[:author]) context[:user] = self.parent_user end diff --git a/app/models/concerns/ci/has_runner_status.rb b/app/models/concerns/ci/has_runner_status.rb new file mode 100644 index 00000000000..f6fb9940b44 --- /dev/null +++ b/app/models/concerns/ci/has_runner_status.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Ci + module HasRunnerStatus + extend ActiveSupport::Concern + + included do + scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } + scope :never_contacted, -> { where(contacted_at: nil) } + scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) } + + scope :with_status, ->(status) do + return all if available_statuses.exclude?(status.to_s) + + public_send(status) # rubocop:disable GitlabSecurity/PublicSend -- safe to call + end + end + + class_methods do + def available_statuses + self::AVAILABLE_STATUSES + end + + def online_contact_time_deadline + raise NotImplementedError + end + + def stale_deadline + raise NotImplementedError + end + end + + def status + return :stale if stale? + return :never_contacted unless contacted_at + + online? ? :online : :offline + end + + def online? + contacted_at && contacted_at > self.class.online_contact_time_deadline + end + + def stale? + return false unless created_at + + [created_at, contacted_at].compact.max <= self.class.stale_deadline + end + end +end diff --git a/app/models/concerns/ci/partitionable/testing.rb b/app/models/concerns/ci/partitionable/testing.rb index b961d72db94..9f0d55329ad 100644 --- a/app/models/concerns/ci/partitionable/testing.rb +++ b/app/models/concerns/ci/partitionable/testing.rb @@ -21,6 +21,10 @@ module Ci Ci::PendingBuild Ci::RunningBuild Ci::RunnerManagerBuild + Ci::PipelineArtifact + Ci::PipelineChatData + Ci::PipelineConfig + Ci::PipelineMetadata Ci::PipelineVariable Ci::Sources::Pipeline Ci::Stage diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb index 201994cb321..12e4a5a0ee0 100644 --- a/app/models/concerns/commit_signature.rb +++ b/app/models/concerns/commit_signature.rb @@ -9,17 +9,7 @@ module CommitSignature sha_attribute :commit_sha - enum verification_status: { - unverified: 0, - verified: 1, - same_user_different_email: 2, - other_user: 3, - unverified_key: 4, - unknown_key: 5, - multiple_signatures: 6, - revoked_key: 7, - verified_system: 8 - } + enum verification_status: Enums::CommitSignature.verification_statuses belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb deleted file mode 100644 index 7e2f445189e..00000000000 --- a/app/models/concerns/database_event_tracking.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module DatabaseEventTracking - extend ActiveSupport::Concern - - included do - after_create_commit :publish_database_create_event - after_destroy_commit :publish_database_destroy_event - after_update_commit :publish_database_update_event - end - - def publish_database_create_event - publish_database_event('create') - end - - def publish_database_destroy_event - publish_database_event('destroy') - end - - def publish_database_update_event - publish_database_event('update') - end - - def publish_database_event(name) - # Gitlab::Tracking#event is triggering Snowplow event - # Snowplow events are sent with usage of - # https://snowplow.github.io/snowplow-ruby-tracker/SnowplowTracker/AsyncEmitter.html - # that reports data asynchronously and does not impact performance nor carries a risk of - # rollback in case of error - - Gitlab::Tracking.database_event( - self.class.to_s, - "database_event_#{name}", - label: self.class.table_name, - project: try(:project), - namespace: (try(:group) || try(:namespace)) || try(:project)&.namespace, - property: name, - **filtered_record_attributes - ) - rescue StandardError => err - # this rescue should be a dead code due to utilization of AsyncEmitter, however - # since this concern is expected to be included in every model, it is better to - # prevent against any unexpected outcome - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) - end - - def filtered_record_attributes - attributes - .with_indifferent_access - .slice(*self.class::SNOWPLOW_ATTRIBUTES) - end -end diff --git a/app/models/concerns/enums/commit_signature.rb b/app/models/concerns/enums/commit_signature.rb new file mode 100644 index 00000000000..92625af58ef --- /dev/null +++ b/app/models/concerns/enums/commit_signature.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Enums + class CommitSignature + VERIFICATION_STATUSES = { + unverified: 0, + verified: 1, + same_user_different_email: 2, + other_user: 3, + unverified_key: 4, + unknown_key: 5, + multiple_signatures: 6, + revoked_key: 7, + verified_system: 8 + # EE adds more values in ee/app/models/concerns/ee/enums/commit_signature.rb + }.freeze + + def self.verification_statuses + VERIFICATION_STATUSES + end + end +end + +Enums::CommitSignature.prepend_mod diff --git a/app/models/concerns/integrations/enable_ssl_verification.rb b/app/models/concerns/integrations/enable_ssl_verification.rb index cb20955488a..1dffe183475 100644 --- a/app/models/concerns/integrations/enable_ssl_verification.rb +++ b/app/models/concerns/integrations/enable_ssl_verification.rb @@ -9,7 +9,8 @@ module Integrations type: :checkbox, title: -> { s_('Integrations|SSL verification') }, checkbox_label: -> { s_('Integrations|Enable SSL verification') }, - help: -> { s_('Integrations|Clear if using a self-signed certificate.') } + help: -> { s_('Integrations|Clear if using a self-signed certificate.') }, + description: -> { s_('Enable SSL verification. Defaults to `true` (enabled).') } end def initialize_properties diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb index 223191fb963..3ce1dd36a5e 100644 --- a/app/models/concerns/integrations/has_issue_tracker_fields.rb +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -10,16 +10,16 @@ module Integrations field :project_url, required: true, title: -> { _('Project URL') }, - help: -> do - s_('IssueTracker|The URL to the project in the external issue tracker.') - end + description: -> { s_('URL of the project.') }, + help: -> { s_('IssueTracker|URL of the project in the external issue tracker.') } field :issues_url, required: true, title: -> { s_('IssueTracker|Issue URL') }, + description: -> { s_('URL of the issue.') }, help: -> do ERB::Util.html_escape( - s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') + s_('IssueTracker|URL to view an issue in the external issue tracker. Must contain %{colon_id}.') ) % { colon_id: '<code>:id</code>'.html_safe } @@ -28,9 +28,8 @@ module Integrations field :new_issue_url, required: true, title: -> { s_('IssueTracker|New issue URL') }, - help: -> do - s_('IssueTracker|The URL to create an issue in the external issue tracker.') - end + description: -> { s_('URL of the new issue.') }, + help: -> { s_('IssueTracker|URL to create an issue in the external issue tracker.') } end end end diff --git a/app/models/concerns/integrations/slack_mattermost_fields.rb b/app/models/concerns/integrations/slack_mattermost_fields.rb index a8e63c4e405..08f86813cc1 100644 --- a/app/models/concerns/integrations/slack_mattermost_fields.rb +++ b/app/models/concerns/integrations/slack_mattermost_fields.rb @@ -7,26 +7,40 @@ module Integrations included do field :webhook, help: -> { webhook_help }, + description: -> do + Kernel.format(_("%{title} webhook (for example, `%{example}`)."), title: title, example: webhook_help) + end, required: true, if: -> { requires_webhook? } field :username, placeholder: 'GitLab-integration', + description: -> { Kernel.format(_("%{title} username."), title: title) }, if: -> { requires_webhook? } + field :channel, + description: -> { _('Default channel to use if no other channel is configured.') }, + api_only: true + field :notify_only_broken_pipelines, type: :checkbox, section: Integration::SECTION_TYPE_CONFIGURATION, + description: -> { _('Send notifications for broken pipelines.') }, help: 'Do not send notifications for successful pipelines.' field :branches_to_be_notified, type: :select, section: Integration::SECTION_TYPE_CONFIGURATION, title: -> { s_('Integration|Branches for which notifications are to be sent') }, + description: -> { + _('Branches to send notifications for. Valid options are `all`, `default`, `protected`, ' \ + 'and `default_and_protected`. The default value is `default`.') + }, choices: -> { branch_choices } field :labels_to_be_notified, section: Integration::SECTION_TYPE_CONFIGURATION, + description: -> { _('Labels to send notifications for. Leave blank to receive notifications for all events.') }, placeholder: '~backend,~frontend', help: 'Send notifications for issue, merge request, and comment events with the listed labels only. ' \ 'Leave blank to receive notifications for all events.' @@ -34,6 +48,10 @@ module Integrations field :labels_to_be_notified_behavior, type: :select, section: Integration::SECTION_TYPE_CONFIGURATION, + description: -> { + _('Labels to be notified for. Valid options are `match_any` and `match_all`. ' \ + 'The default value is `match_any`.') + }, choices: [ ['Match any of the labels', Integrations::BaseChatNotification::MATCH_ANY_LABEL], ['Match all of the labels', Integrations::BaseChatNotification::MATCH_ALL_LABELS] diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb index c322a736e79..8feb162207d 100644 --- a/app/models/concerns/partitioned_table.rb +++ b/app/models/concerns/partitioned_table.rb @@ -9,7 +9,8 @@ module PartitionedTable PARTITIONING_STRATEGIES = { monthly: Gitlab::Database::Partitioning::MonthlyStrategy, sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy, - ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy + ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy, + int_range: Gitlab::Database::Partitioning::IntRangeStrategy }.freeze def partitioned_by(partitioning_key, strategy:, **kwargs) diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb index 87b62214529..8fcf0532151 100644 --- a/app/models/concerns/restricted_signup.rb +++ b/app/models/concerns/restricted_signup.rb @@ -31,10 +31,10 @@ module RestrictedSignup def error_message { admin: { - allowlist: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe, - denylist: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check the 'Domain denylist'.")).html_safe, - restricted: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe, - group_setting: html_escape_once(_("Go to the group’s 'Settings > General' page, and check 'Restrict membership by email domain'.")).html_safe + allowlist: ERB::Util.html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe, + denylist: ERB::Util.html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check the 'Domain denylist'.")).html_safe, + restricted: ERB::Util.html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe, + group_setting: ERB::Util.html_escape_once(_("Go to the group’s 'Settings > General' page, and check 'Restrict membership by email domain'.")).html_safe }, nonadmin: { allowlist: error_nonadmin, diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 242194be440..43874d0211c 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -87,37 +87,27 @@ module Routable # Klass.where_full_path_in(%w{gitlab-org/gitlab-foss gitlab-org/gitlab}) # # Returns an ActiveRecord::Relation. - def where_full_path_in(paths, use_includes: true) + def where_full_path_in(paths, preload_routes: true) return none if paths.empty? - wheres = paths.map do |path| + path_condition = paths.map do |path| "(LOWER(routes.path) = LOWER(#{connection.quote(path)}))" - end + end.join(' OR ') - if Feature.enabled?(:optimize_where_full_path_in, Feature.current_request) - route_scope = all - source_type_condition = { source_type: route_scope.klass.base_class } + route_scope = all + source_type_condition = { source_type: route_scope.klass.base_class } - routes_matching_condition = Route.where(source_type_condition).where(wheres.join(' OR ')) + routes_matching_condition = Route + .where(source_type_condition) + .where(path_condition) - result = route_scope.where(id: routes_matching_condition.pluck(:source_id)) + source_ids = routes_matching_condition.pluck(:source_id) + result = route_scope.where(id: source_ids) - if use_includes - result.preload(:route) - else - result - end + if preload_routes + result.preload(:route) else - route = - if use_includes - includes(:route).references(:routes) - else - joins(:route) - end - - route - .where(wheres.join(' OR ')) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + result end end end diff --git a/app/models/container_registry/protection/rule.rb b/app/models/container_registry/protection/rule.rb index a7324b3b3b8..34d00bdef2f 100644 --- a/app/models/container_registry/protection/rule.rb +++ b/app/models/container_registry/protection/rule.rb @@ -19,6 +19,23 @@ module ContainerRegistry validates :repository_path_pattern, presence: true, uniqueness: { scope: :project_id }, length: { maximum: 255 } validates :delete_protected_up_to_access_level, presence: true validates :push_protected_up_to_access_level, presence: true + + scope :for_repository_path, ->(repository_path) do + return none if repository_path.blank? + + where( + ":repository_path ILIKE #{::Gitlab::SQL::Glob.to_like('repository_path_pattern')}", + repository_path: repository_path + ) + end + + def self.for_push_exists?(access_level:, repository_path:) + return false if access_level.blank? || repository_path.blank? + + where(push_protected_up_to_access_level: access_level..) + .for_repository_path(repository_path) + .exists? + end end end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 6bcfd23e69c..3b1c10c0259 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -482,7 +482,7 @@ class ContainerRepository < ApplicationRecord raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES end - def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100) + def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100, referrers: nil) raise ArgumentError, 'not a migrated repository' unless migrated? page = gitlab_api_client.tags( @@ -491,7 +491,8 @@ class ContainerRepository < ApplicationRecord before: before, last: last, sort: sort, - name: name + name: name, + referrers: referrers ) { @@ -618,12 +619,11 @@ class ContainerRepository < ApplicationRecord self.new(project: path.repository_project, name: path.repository_name) end - def self.find_or_create_from_path(path) - repository = safe_find_or_create_by( - project: path.repository_project, + def self.find_or_create_from_path!(path) + ContainerRepository.upsert({ + project_id: path.repository_project.id, name: path.repository_name - ) - return repository if repository.persisted? + }, unique_by: %i[project_id name]) find_by_path!(path) end @@ -657,6 +657,8 @@ class ContainerRepository < ApplicationRecord tag.total_size = raw_tag['size_bytes'] tag.manifest_digest = raw_tag['digest'] tag.revision = raw_tag['config_digest'].to_s.split(':')[1] || '' + tag.referrers = raw_tag['referrers'] + tag.published_at = raw_tag['published_at'] tag end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 36f4a0ef426..1fff089451d 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -9,6 +9,7 @@ class Deployment < ApplicationRecord include Gitlab::Utils::StrongMemoize include FastDestroyAll include IgnorableColumns + include EachBatch StatusUpdateError = Class.new(StandardError) StatusSyncError = Class.new(StandardError) @@ -230,7 +231,7 @@ class Deployment < ApplicationRecord ## # FastDestroyAll concerns def begin_fast_destroy - preload(:project).find_each.map do |deployment| + preload(:project, :environment).find_each.map do |deployment| [deployment.project, deployment.ref_path] end end diff --git a/app/models/group.rb b/app/models/group.rb index ac843f392fd..bbf34ce21c0 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -37,8 +37,8 @@ class Group < Namespace has_many :all_group_members, -> { non_request }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :all_owner_members, -> { non_request.all_owners }, as: :source, class_name: 'GroupMember' - has_many :group_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent - has_many :namespace_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }).unscope(where: %i[source_id source_type]) }, + has_many :group_members, -> { non_request.non_minimal_access }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent + has_many :namespace_members, -> { non_request.non_minimal_access.unscope(where: %i[source_id source_type]) }, foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember' alias_method :members, :group_members @@ -338,6 +338,18 @@ class Group < Namespace by_ids_or_paths(ids, paths).pluck(:id) end + def descendant_groups_counts + left_joins(:children).group(:id).count(:children_namespaces) + end + + def projects_counts + left_joins(:non_archived_projects).group(:id).count(:projects) + end + + def group_members_counts + left_joins(:group_members).group(:id).count(:members) + end + private def public_to_user_arel(user) @@ -434,7 +446,9 @@ class Group < Namespace end def owned_by?(user) - owners.include?(user) + return false unless user + + all_owner_members.non_invite.exists?(user: user) end def add_members(users, access_level, current_user: nil, expires_at: nil) @@ -593,6 +607,14 @@ class Group < Namespace end end + # Only for direct and not requested members with higher access level than MIMIMAL_ACCESS + # It returns true for non-active users + def has_user?(user) + return false unless user + + group_members.non_invite.exists?(user: user) + end + def direct_members GroupMember.active_without_invites_and_requests .non_minimal_access @@ -685,7 +707,11 @@ class Group < Namespace end def highest_group_member(user) - GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last + GroupMember + .where(source_id: self_and_ancestors_ids, user_id: user.id) + .non_request + .order(:access_level) + .last end def bots diff --git a/app/models/integration.rb b/app/models/integration.rb index 618f9f986e8..8ebf24b1663 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -19,8 +19,8 @@ class Integration < ApplicationRecord self.inheritance_column = :type_new INTEGRATION_NAMES = %w[ - asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker datadog discord - drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira + asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker + datadog diffblue_cover discord drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram unify_circuit webex_teams youtrack zentao @@ -638,7 +638,9 @@ class Integration < ApplicationRecord end def validate_belongs_to_project_or_group - errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level? + return unless project_level? && group_level? + + errors.add(:project_id, 'The integration cannot belong to both a project and a group') end def validate_recipients? diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index a248a1aa561..152bcf934ae 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -45,7 +45,7 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, title: -> { s_('AppleAppStore|Protected branches and tags only') }, description: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') }, - checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') } + checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only') } def self.title 'Apple App Store Connect' diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 9fe73f86be3..1c68d09aa2f 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -8,12 +8,14 @@ module Integrations field :bamboo_url, title: -> { s_('BambooService|Bamboo URL') }, placeholder: -> { s_('https://bamboo.example.com') }, - help: -> { s_('BambooService|Bamboo service root URL.') }, + help: -> { s_('BambooService|Bamboo root URL.') }, + description: -> { s_('Bamboo root URL (for example, `https://bamboo.example.com`).') }, exposes_secrets: true, required: true field :build_key, help: -> { s_('BambooService|Bamboo build plan key.') }, + description: -> { s_('Bamboo build plan key (for example, `KEY`).') }, non_empty_password_title: -> { s_('BambooService|Enter new build key') }, non_empty_password_help: -> { s_('BambooService|Leave blank to use your current build key.') }, placeholder: -> { _('KEY') }, @@ -21,12 +23,16 @@ module Integrations is_secret: true field :username, - help: -> { s_('BambooService|The user with API access to the Bamboo server.') } + help: -> { s_('BambooService|User with API access to the Bamboo server.') }, + description: -> { s_('User with API access to the Bamboo server.') }, + required: true field :password, type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new password') }, - non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') } + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') }, + description: -> { s_('Password of the user.') }, + required: true with_options if: :activated? do validates :bamboo_url, presence: true, public_url: true diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 18268ed18f4..783311ca18d 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -15,6 +15,9 @@ module Integrations field :token, type: :password, title: -> { _('Campfire token') }, + description: -> do + _('API authentication token from Campfire. To get the token, sign in to Campfire and select **My info**.') + end, help: -> { s_('CampfireService|API authentication token from Campfire.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, @@ -23,18 +26,22 @@ module Integrations field :subdomain, title: -> { _('Campfire subdomain (optional)') }, + description: -> do + _("`.campfirenow.com` subdomain when you're signed in.") + end, placeholder: '', exposes_secrets: true, help: -> do format(ERB::Util.html_escape( - s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') + s_('CampfireService|%{code_open}.campfirenow.com%{code_close} subdomain.') ), code_open: '<code>'.html_safe, code_close: '</code>'.html_safe) end field :room, title: -> { _('Campfire room ID (optional)') }, + description: -> { _("ID portion of the Campfire room URL.") }, placeholder: '123456', - help: -> { s_('CampfireService|From the end of the room URL.') } + help: -> { s_('CampfireService|ID portion of the Campfire room URL.') } def self.title 'Campfire' diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb index 25287b53300..1737aa7ff61 100644 --- a/app/models/integrations/clickup.rb +++ b/app/models/integrations/clickup.rb @@ -32,8 +32,8 @@ module Integrations 'clickup' end - def fields - super.select { _1.name.in?(%w[project_url issues_url]) } + def self.fields + super.select { %w[project_url issues_url].include?(_1.name) } end end end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index f97f1fd25c9..fcdc908ca67 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -10,7 +10,8 @@ module Integrations validate :validate_confluence_url_is_cloud, if: :activated? field :confluence_url, - title: -> { _('Confluence Cloud Workspace URL') }, + title: -> { _('Confluence Workspace URL') }, + description: -> { _("URL of the Confluence Workspace hosted on `atlassian.net`.") }, placeholder: 'https://example.atlassian.net/wiki', required: true diff --git a/app/models/integrations/diffblue_cover.rb b/app/models/integrations/diffblue_cover.rb new file mode 100644 index 00000000000..c0e0cae2b33 --- /dev/null +++ b/app/models/integrations/diffblue_cover.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Integrations + class DiffblueCover < Integration + field :diffblue_license_key, + section: SECTION_TYPE_CONNECTION, + type: :password, + title: -> { s_('DiffblueCover|License key') }, + description: -> { s_('DiffblueCover|Diffblue Cover license key.') }, + non_empty_password_title: -> { s_('DiffblueCover|License key') }, + non_empty_password_help: -> { + s_( + 'DiffblueCover|Leave blank to use your current license key.' + ) + }, + exposes_secrets: true, + required: true, + is_secret: true, + placeholder: 'XXXX-XXXX-XXXX-XXXX', + help: -> { + format( + s_( + 'DiffblueCover|Enter your Diffblue Cover license key or ' \ + 'go to %{diffblue_link} to obtain a free trial license.' + ), + diffblue_link: diffblue_link + ) + } + + field :diffblue_access_token_name, + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('DiffblueCover|Name') }, + description: -> { s_('DiffblueCover|Access token name used by Diffblue Cover in pipelines.') }, + required: true, + placeholder: -> { s_('DiffblueCover|My token name') } + + field :diffblue_access_token_secret, + section: SECTION_TYPE_CONFIGURATION, + type: :password, + title: -> { s_('DiffblueCover|Secret') }, + description: -> { s_('DiffblueCover|Access token secret used by Diffblue Cover in pipelines.') }, + non_empty_password_title: -> { s_('DiffblueCover|Secret') }, + non_empty_password_help: -> { s_('DiffblueCover|Leave blank to use your current secret value.') }, + required: true, + is_secret: true, + placeholder: 'glpat-XXXXXXXXXXXXXXXXXXXX' # gitleaks:allow + + with_options if: :activated? do + validates :diffblue_license_key, presence: true + validates :diffblue_access_token_name, presence: true + validates :diffblue_access_token_secret, presence: true + end + + def self.title + 'Diffblue Cover' + end + + def self.description + s_('DiffblueCover|Automatically write comprehensive, human-like Java unit tests.') + end + + def self.to_param + 'diffblue_cover' + end + + def self.help + s_('DiffblueCover|Automatically write comprehensive, human-like Java unit tests.') + end + + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/diffblue.svg') + end + + def self.supported_events + [] + end + + def sections + [ + { + type: SECTION_TYPE_CONNECTION, + title: s_('DiffblueCover|Integration details'), + description: + s_( + 'DiffblueCover|Diffblue Cover is a generative AI platform that automatically ' \ + 'writes comprehensive, human-like Java unit tests. Integrate Diffblue ' \ + 'Cover into your CI/CD workflow for fully autonomous operation.' + ) + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: s_('DiffblueCover|Access token'), + description: + 'You must have a GitLab access token for Diffblue Cover to access your project. ' \ + 'Use a GitLab access token with at least the Developer role and ' \ + 'the <code>api</code> and <code>write_repository</code> permissions.' + } + ] + end + + def execute(_data) end + + def ci_variables + return [] unless activated? + + [ + { key: 'DIFFBLUE_LICENSE_KEY', value: diffblue_license_key, public: false, masked: true }, + { key: 'DIFFBLUE_ACCESS_TOKEN_NAME', value: diffblue_access_token_name, public: false, masked: true }, + { key: 'DIFFBLUE_ACCESS_TOKEN', value: diffblue_access_token_secret, public: false, masked: true } + ] + end + + def testable? + false + end + + def self.diffblue_link + ActionController::Base.helpers.link_to( + s_('DiffblueCover|Try Diffblue Cover'), + 'https://www.diffblue.com/try-cover/gitlab/', + target: '_blank', + rel: 'noopener noreferrer' + ) + end + end +end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 7ce597389f0..f36170f91d0 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -8,17 +8,20 @@ module Integrations field :webhook, section: SECTION_TYPE_CONNECTION, + description: -> { _('Discord webhook (for example, `https://discord.com/api/webhooks/…`).') }, help: 'e.g. https://discord.com/api/webhooks/…', required: true field :notify_only_broken_pipelines, type: :checkbox, - section: SECTION_TYPE_CONFIGURATION + section: SECTION_TYPE_CONFIGURATION, + description: -> { _('Send notifications for broken pipelines.') } field :branches_to_be_notified, type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + description: -> { _('Branches to send notifications for. Valid options are `all`, `default`, `protected`, and `default_and_protected`. The default value is `default`.') }, choices: -> { branch_choices } def self.title diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index 7408f86d231..e5360e58426 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -7,6 +7,7 @@ module Integrations field :external_wiki_url, section: SECTION_TYPE_CONNECTION, title: -> { s_('ExternalWikiService|External wiki URL') }, + description: -> { s_('ExternalWikiService|URL of the external wiki.') }, placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') }, help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') }, required: true diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb index 746f68fdc4c..1d6d563e37f 100644 --- a/app/models/integrations/google_play.rb +++ b/app/models/integrations/google_play.rb @@ -18,19 +18,25 @@ module Integrations field :package_name, section: SECTION_TYPE_CONNECTION, placeholder: 'com.example.myapp', + description: -> { _('Package name of the app in Google Play.') }, required: true field :service_account_key_file_name, section: SECTION_TYPE_CONNECTION, - required: true + required: true, + description: -> { _('File name of the Google Play service account key.') } - field :service_account_key, api_only: true + field :service_account_key, + required: true, + description: -> { _('Google Play service account key.') }, + api_only: true field :google_play_protected_refs, type: :checkbox, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('GooglePlayStore|Protected branches and tags only') }, - checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') } + description: -> { _('Set variables on protected branches and tags only.') }, + checkbox_label: -> { s_('GooglePlayStore|Set variables on protected branches and tags only') } def self.title s_('GooglePlay|Google Play') @@ -48,10 +54,10 @@ module Integrations # rubocop:disable Layout/LineLength texts = [ - s_("Use the Google Play integration to connect to Google Play with fastlane in CI/CD pipelines."), - s_("After you enable the integration, the following protected variable is created for CI/CD use:"), + s_("Use this integration to connect to Google Play with fastlane in CI/CD pipelines."), + s_("After you enable the integration, the following protected variables are created for CI/CD use:"), variable_list.join('<br>'), - s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe + s_(format("For more information, see the <a href='%{url}' target='_blank'>documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe ] # rubocop:enable Layout/LineLength diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index cc570e49e36..a1621588cd6 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -10,6 +10,7 @@ module Integrations field :url, title: -> { s_('HarborIntegration|Harbor URL') }, + description: -> { _('The base URL to the Harbor instance linked to the GitLab project. For example, `https://demo.goharbor.io`.') }, placeholder: 'https://demo.goharbor.io', help: -> { s_('HarborIntegration|Base URL of the Harbor instance.') }, exposes_secrets: true, @@ -17,16 +18,19 @@ module Integrations field :project_name, title: -> { s_('HarborIntegration|Harbor project name') }, + description: -> { s_('HarborIntegration|The name of the project in the Harbor instance. For example, `testproject`.') }, help: -> { s_('HarborIntegration|The name of the project in Harbor.') }, required: true field :username, title: -> { s_('HarborIntegration|Harbor username') }, + description: -> { s_('HarborIntegration|The username created in the Harbor interface.') }, required: true field :password, type: :password, title: -> { s_('HarborIntegration|Harbor password') }, + description: -> { s_('HarborIntegration|The password of the user.') }, help: -> { s_('HarborIntegration|Password for your Harbor username.') }, non_empty_password_title: -> { s_('HarborIntegration|Enter new Harbor password') }, non_empty_password_help: -> { s_('HarborIntegration|Leave blank to use your current password.') }, diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index 361ff4afce8..e7be2b2a454 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -27,7 +27,7 @@ module Integrations end def self.webhook_help - 'http://mattermost.example.com/hooks/' + 'http://mattermost.example.com/hooks/...' end override :configurable_channels? diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb index 29ed563a902..dcbda8d1ed0 100644 --- a/app/models/integrations/mattermost_slash_commands.rb +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -8,8 +8,10 @@ module Integrations field :token, type: :password, + description: -> { _('The Mattermost token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true, placeholder: '' def testable? diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 9f9614a84fd..0c1fd34fccf 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -18,7 +18,7 @@ module Integrations end def self.webhook_help - 'https://hooks.slack.com/services/…' + 'https://hooks.slack.com/services/...' end private diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb index 1b4ab152b1d..7aaef0c22cc 100644 --- a/app/models/integrations/squash_tm.rb +++ b/app/models/integrations/squash_tm.rb @@ -7,12 +7,14 @@ module Integrations field :url, placeholder: 'https://your-instance.squashcloud.io/squash/plugin/xsquash4gitlab/webhook/issue', title: -> { s_('SquashTmIntegration|Squash TM webhook URL') }, + description: -> { s_('URL of the Squash TM webhook.') }, exposes_secrets: true, required: true field :token, type: :password, title: -> { s_('SquashTmIntegration|Secret token (optional)') }, + description: -> { s_('Secret token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, required: false diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index 932e588a829..4d825adb961 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -31,8 +31,8 @@ module Integrations 'youtrack' end - def fields - super.select { _1.name.in?(%w[project_url issues_url]) } + def self.fields + super.select { %w[project_url issues_url].include?(_1.name) } end end end diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb index 9d7e2afa1d9..bb03b3d72e6 100644 --- a/app/models/issue_email_participant.rb +++ b/app/models/issue_email_participant.rb @@ -3,6 +3,7 @@ class IssueEmailParticipant < ApplicationRecord include BulkInsertSafe include Presentable + include CaseSensitivity belongs_to :issue @@ -10,6 +11,8 @@ class IssueEmailParticipant < ApplicationRecord validates :issue, presence: true validate :validate_email_format + scope :with_emails, ->(emails) { iwhere(email: emails) } + def validate_email_format self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) end diff --git a/app/models/jira_connect_subscription.rb b/app/models/jira_connect_subscription.rb index c74f75b2d8e..8ff89560f09 100644 --- a/app/models/jira_connect_subscription.rb +++ b/app/models/jira_connect_subscription.rb @@ -8,5 +8,5 @@ class JiraConnectSubscription < ApplicationRecord validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' } scope :preload_namespace_route, -> { preload(namespace: :route) } - scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestors) } + scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestor_ids) } end diff --git a/app/models/label.rb b/app/models/label.rb index d0d278b68fd..8fff42abd58 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -46,7 +46,6 @@ class Label < ApplicationRecord scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } scope :with_lock_on_merge, -> { where(lock_on_merge: true) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } - scope :on_board, ->(board_id) { with_lists_and_board.where(boards: { id: board_id }) } scope :order_name_asc, -> { reorder(title: :asc) } scope :order_name_desc, -> { reorder(title: :desc) } scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) } @@ -152,10 +151,6 @@ class Label < ApplicationRecord nil end - def self.ids_on_board(board_id) - on_board(board_id).pluck(:label_id) - end - # Searches for labels with a matching title or description. # # This method uses ILIKE on PostgreSQL. diff --git a/app/models/member.rb b/app/models/member.rb index 25dae518406..8bec64932b3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -276,9 +276,11 @@ class Member < ApplicationRecord after_create :send_invite, if: :invite?, unless: :importing? after_create :create_notification_setting, unless: [:pending?, :importing?] after_create :post_create_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met? + after_create :update_two_factor_requirement, unless: :invite? after_update :post_update_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met? after_destroy :destroy_notification_setting after_destroy :post_destroy_hook, unless: :pending?, if: :hook_prerequisites_met? + after_destroy :update_two_factor_requirement, unless: :invite? after_save :log_invitation_token_cleanup after_commit :send_request, if: :request?, unless: :importing?, on: [:create] @@ -286,6 +288,14 @@ class Member < ApplicationRecord refresh_member_authorized_projects end + after_create if: :update_organization_user? do + Organizations::OrganizationUser.upsert( + { organization_id: source.organization_id, user_id: user_id, access_level: :default }, + unique_by: [:organization_id, :user_id], + on_duplicate: :skip # Do not change access_level, could make :owner :default + ) + end + attribute :notification_level, default: -> { NotificationSetting.levels[:global] } class << self @@ -486,7 +496,10 @@ class Member < ApplicationRecord strong_memoize(:highest_group_member) do next unless user_id && source&.ancestors&.any? - GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last + GroupMember + .where(source: source.ancestors, user_id: user_id) + .non_request + .order(:access_level).last end end @@ -498,6 +511,17 @@ class Member < ApplicationRecord created_by&.name end + def update_two_factor_requirement + return unless source.is_a?(Group) + return unless user + + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288' + ) do + user.update_two_factor_requirement + end + end + private # TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054 @@ -513,7 +537,7 @@ class Member < ApplicationRecord end def send_invite - # override in subclass + run_after_commit_or_now { notification_service.invite_member(self, @raw_invite_token) } end def send_request @@ -522,10 +546,26 @@ class Member < ApplicationRecord end def post_create_hook + # The creator of a personal project gets added as a `ProjectMember` + # with `OWNER` access during creation of a personal project, + # but we do not want to trigger notifications to the same person who created the personal project. + unless source.is_a?(Project) && source.personal_namespace_holder?(user) + event_service.join_source(source, user) + run_after_commit_or_now { notification_service.new_member(self) } + end + system_hook_service.execute_hooks_for(self, :create) end def post_update_hook + if saved_change_to_access_level? + run_after_commit { notification_service.updated_member_access_level(self) } + end + + if saved_change_to_expires_at? + run_after_commit { notification_service.updated_member_expiration(self) } + end + system_hook_service.execute_hooks_for(self, :update) end @@ -548,6 +588,12 @@ class Member < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def after_accept_invite + run_after_commit_or_now do + notification_service.accept_invite(self) + end + + update_two_factor_requirement + post_create_hook end @@ -578,7 +624,12 @@ class Member < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def notifiable_options - {} + case source + when Group + { group: source } + when Project + { project: source } + end end def higher_access_level_than_group @@ -617,12 +668,22 @@ class Member < ApplicationRecord user&.project_bot? end + def update_organization_user? + return false unless Feature.enabled?(:update_organization_users, source.root_ancestor, type: :gitlab_com_derisk) + + !invite? && source.organization.present? + end + def log_invitation_token_cleanup return true unless Gitlab.com? && invite? && invite_accepted_at? error = StandardError.new("Invitation token is present but invite was already accepted!") Gitlab::ErrorTracking.track_exception(error, attributes.slice(%w["invite_accepted_at created_at source_type source_id user_id id"])) end + + def event_service + EventCreateService.new # rubocop:todo CodeReuse/ServiceClass -- Legacy, convert to value object eventually + end end Member.prepend_mod_with('Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index e3ead1b04d0..b04fb1f6768 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -18,25 +18,12 @@ class GroupMember < Member default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope - scope :of_groups, ->(groups) { where(source_id: groups&.select(:id)) } + scope :of_groups, ->(groups) { where(source_id: groups) } scope :of_ldap_type, -> { where(ldap: true) } scope :count_users_by_group_id, -> { group(:source_id).count } - after_create :update_two_factor_requirement, unless: :invite? - after_destroy :update_two_factor_requirement, unless: :invite? - attr_accessor :last_owner - def update_two_factor_requirement - return unless user - - Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( - %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288' - ) do - user.update_two_factor_requirement - end - end - # For those who get to see a modal with a role dropdown, here are the options presented def self.permissible_access_level_roles(_, _) # This method is a stopgap in preparation for https://gitlab.com/gitlab-org/gitlab/-/issues/364087 @@ -56,10 +43,6 @@ class GroupMember < Member Group.sti_name end - def notifiable_options - { group: group } - end - def last_owner_of_the_group? return false unless access_level == Gitlab::Access::OWNER return last_owner unless last_owner.nil? @@ -87,40 +70,6 @@ class GroupMember < Member super end - - def send_invite - run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) } - - super - end - - def post_create_hook - run_after_commit_or_now { notification_service.new_group_member(self) } - - super - end - - def post_update_hook - if saved_change_to_access_level? - run_after_commit { notification_service.update_group_member(self) } - end - - if saved_change_to_expires_at? - run_after_commit { notification_service.updated_group_member_expiration(self) } - end - - super - end - - def after_accept_invite - run_after_commit_or_now do - notification_service.accept_group_invite(self) - end - - update_two_factor_requirement - - super - end end GroupMember.prepend_mod_with('GroupMember') diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index f52fef9e247..a2927238e54 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -72,10 +72,6 @@ class ProjectMember < Member source end - def notifiable_options - { project: project } - end - def holder_of_the_personal_namespace? project.personal_namespace_holder?(user) end @@ -116,32 +112,6 @@ class ProjectMember < Member self.member_namespace_id = project&.project_namespace_id end - def send_invite - run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) } - - super - end - - def post_create_hook - # The creator of a personal project gets added as a `ProjectMember` - # with `OWNER` access during creation of a personal project, - # but we do not want to trigger notifications to the same person who created the personal project. - unless project.personal_namespace_holder?(user) - event_service.join_project(self.project, self.user) - run_after_commit_or_now { notification_service.new_project_member(self) } - end - - super - end - - def post_update_hook - if saved_change_to_access_level? - run_after_commit { notification_service.update_project_member(self) } - end - - super - end - def post_destroy_hook if expired? event_service.expired_leave_project(self.project, self.user) @@ -151,20 +121,6 @@ class ProjectMember < Member super end - - def after_accept_invite - run_after_commit_or_now do - notification_service.accept_project_invite(self) - end - - super - end - - # rubocop: disable CodeReuse/ServiceClass - def event_service - EventCreateService.new - end - # rubocop: enable CodeReuse/ServiceClass end ProjectMember.prepend_mod_with('ProjectMember') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index f9af342f47f..ae68a36c8d2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1716,8 +1716,6 @@ class MergeRequest < ApplicationRecord actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)) end - # rubocop: disable Metrics/AbcSize - # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) @@ -1730,14 +1728,9 @@ class MergeRequest < ApplicationRecord variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s) variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) - if ::Feature.enabled?(:truncate_ci_merge_request_description) - mr_description, mr_description_truncated = truncate_mr_description - variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description) - variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated) - else - variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: description) - end - + mr_description, mr_description_truncated = truncate_mr_description + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description) + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated) variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present? variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present? @@ -1745,8 +1738,6 @@ class MergeRequest < ApplicationRecord variables.concat(source_project_variables) end end - # rubocop: enable Metrics/AbcSize - # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up def compare_test_reports unless has_test_reports? @@ -2102,8 +2093,12 @@ class MergeRequest < ApplicationRecord true end + def allows_multiple_assignees? + project.allows_multiple_merge_request_assignees? + end + def allows_multiple_reviewers? - false + project.allows_multiple_merge_request_reviewers? end def supports_assignee? @@ -2198,6 +2193,8 @@ class MergeRequest < ApplicationRecord attr_accessor :skip_fetch_ref def merge_base_pipelines + return ::Ci::Pipeline.none unless actual_head_pipeline&.target_sha + target_branch_pipelines_for(sha: actual_head_pipeline.target_sha) end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 3c592c0008f..6d6c0ee07af 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class MergeRequest::Metrics < ApplicationRecord - include DatabaseEventTracking - belongs_to :merge_request, inverse_of: :metrics belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :latest_closed_by, class_name: 'User' @@ -33,8 +31,7 @@ class MergeRequest::Metrics < ApplicationRecord RETURNING id, #{inserted_columns.join(', ')} SQL - result = connection.execute(sql).first - new(result).publish_database_create_event + connection.execute(sql) end end @@ -48,31 +45,6 @@ class MergeRequest::Metrics < ApplicationRecord with_valid_time_to_merge .pick(time_to_merge_expression) end - - SNOWPLOW_ATTRIBUTES = %i[ - id - merge_request_id - latest_build_started_at - latest_build_finished_at - first_deployed_to_production_at - merged_at - created_at - updated_at - pipeline_id - merged_by_id - latest_closed_by_id - latest_closed_at - first_comment_at - first_commit_at - last_commit_at - diff_size - modified_paths_size - commits_count - first_approved_at - first_reassigned_at - added_lines - removed_lines - ].freeze end MergeRequest::Metrics.prepend_mod_with('MergeRequest::Metrics') diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 0b183131a47..47102418152 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -196,6 +196,7 @@ class MergeRequestDiff < ApplicationRecord # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? after_create_commit :set_as_latest_diff, unless: :importing? + after_create_commit :trigger_diff_generated_subscription, unless: :importing? after_save :update_external_diff_store after_save :set_count_columns @@ -258,6 +259,12 @@ class MergeRequestDiff < ApplicationRecord .update_all(latest_merge_request_diff_id: self.id) end + def trigger_diff_generated_subscription + return unless Feature.enabled?(:merge_request_diff_generated_subscription, merge_request.project) + + GraphqlTriggers.merge_request_diff_generated(merge_request) + end + def ensure_commit_shas self.start_commit_sha ||= merge_request.target_branch_sha @@ -439,6 +446,8 @@ class MergeRequestDiff < ApplicationRecord ) end + diff_options[:generated_files] = comparison.generated_files if diff_options[:collapse_generated] + Gitlab::Metrics.measure(:diffs_comparison) do comparison.diffs(diff_options) end @@ -452,18 +461,25 @@ class MergeRequestDiff < ApplicationRecord fetching_repository_diffs({}) do |comparison| reorder_diff_files! + collapse_generated = Feature.enabled?(:collapse_generated_diff_files, project) + diff_options = { collapse_generated: collapse_generated } + collection = Gitlab::Diff::FileCollection::PaginatedMergeRequestDiff.new( self, page, - per_page + per_page, + diff_options ) if comparison + diff_options[:generated_files] = comparison.generated_files if collapse_generated + comparison.diffs( - paths: collection.diff_paths, - page: collection.current_page, - per_page: collection.limit_value, - count: collection.total_count + diff_options.merge( + paths: collection.diff_paths, + page: collection.current_page, + per_page: collection.limit_value, + count: collection.total_count) ) else collection diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index ad6c6b7b3bf..456c23df0e0 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -3,6 +3,7 @@ module Ml class Experiment < ApplicationRecord include AtomicInternalId + include Sortable PACKAGE_PREFIX = 'ml_experiment_' @@ -15,6 +16,8 @@ module Ml has_many :candidates, class_name: 'Ml::Candidate' has_many :metadata, class_name: 'Ml::ExperimentMetadata' + scope :including_project, -> { includes(:project) } + scope :by_project, ->(project) { where(project: project) } scope :with_candidate_count, -> { left_outer_joins(:candidates) .select("ml_experiments.*, count(ml_candidates.id) as candidate_count") diff --git a/app/models/ml/model_metadata.rb b/app/models/ml/model_metadata.rb index 9c4273c629c..9695621e47d 100644 --- a/app/models/ml/model_metadata.rb +++ b/app/models/ml/model_metadata.rb @@ -3,7 +3,7 @@ module Ml class ModelMetadata < ApplicationRecord validates :name, - length: { maximum: 250 }, + length: { maximum: 255 }, presence: true, uniqueness: { scope: :model, message: ->(metadata, _) { "'#{metadata.name}' already taken" } } validates :value, length: { maximum: 5000 }, presence: true diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 58da57f27d6..1b3313c803a 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -21,12 +21,25 @@ module Ml belongs_to :project belongs_to :package, class_name: 'Packages::MlModel::Package', optional: true has_one :candidate, class_name: 'Ml::Candidate' + has_many :metadata, class_name: 'Ml::ModelVersionMetadata' delegate :name, to: :model scope :order_by_model_id_id_desc, -> { order('model_id, id DESC') } scope :latest_by_model, -> { order_by_model_id_id_desc.select('DISTINCT ON (model_id) *') } + def add_metadata(metadata_key_value) + return unless metadata_key_value.present? + + metadata_key_value.each do |entry| + metadata.create!( + project_id: project_id, + name: entry[:key], + value: entry[:value] + ) + end + end + class << self def find_or_create!(model, version, package, description) create_with(package: package, description: description) diff --git a/app/models/ml/model_version_metadata.rb b/app/models/ml/model_version_metadata.rb new file mode 100644 index 00000000000..61810786091 --- /dev/null +++ b/app/models/ml/model_version_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ml + class ModelVersionMetadata < ApplicationRecord + validates :name, + length: { maximum: 255 }, + presence: true, + uniqueness: { scope: :model_version, message: ->(metadata, _) { "'#{metadata.name}' already taken" } } + validates :value, length: { maximum: 5000 }, presence: true + + belongs_to :project, optional: false + belongs_to :model_version, class_name: 'Ml::ModelVersion', optional: false + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c665c2278a5..238556f0cf0 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -12,6 +12,7 @@ class Namespace < ApplicationRecord include Gitlab::Utils::StrongMemoize include Namespaces::Traversal::Recursive include Namespaces::Traversal::Linear + include Namespaces::Traversal::Cached include EachBatch include BlocksUnsafeSerialization include Ci::NamespaceSettings @@ -45,6 +46,7 @@ class Namespace < ApplicationRecord cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :non_archived_projects, -> { where.not(archived: true) }, class_name: 'Project' has_many :project_statistics has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true has_one :ci_cd_settings, inverse_of: :namespace, class_name: 'NamespaceCiCdSetting', autosave: true @@ -55,6 +57,9 @@ class Namespace < ApplicationRecord has_one :namespace_ldap_settings, inverse_of: :namespace, class_name: 'Namespaces::LdapSetting', autosave: true + has_one :namespace_descendants, class_name: 'Namespaces::Descendants' + accepts_nested_attributes_for :namespace_descendants, allow_destroy: true + has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' has_many :pending_builds, class_name: 'Ci::PendingBuild' @@ -263,6 +268,28 @@ class Namespace < ApplicationRecord end end + # This should be kept in sync with the frontend filtering in + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053 + def gfm_autocomplete_search(query) + without_project_namespaces + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + .joins(:route) + .where( + "REPLACE(routes.name, ' ', '') ILIKE :pattern OR routes.path ILIKE :pattern", + pattern: "%#{sanitize_sql_like(query)}%" + ) + .order( + Arel.sql(sanitize_sql( + [ + "CASE WHEN starts_with(REPLACE(routes.name, ' ', ''), :pattern) OR starts_with(routes.path, :pattern) THEN 1 ELSE 2 END", + { pattern: query } + ] + )), + 'routes.path' + ) + end + def clean_path(path, limited_to: Namespace.all) slug = Gitlab::Slug::Path.new(path).generate path = Namespaces::RandomizedSuffixPath.new(slug) diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index a5a393ad8a2..5f5bef4409c 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -12,7 +12,7 @@ class Namespace::PackageSetting < ApplicationRecord PackageSettingNotImplemented = Class.new(StandardError) - PACKAGES_WITH_SETTINGS = %w[maven generic nuget].freeze + PACKAGES_WITH_SETTINGS = %w[maven generic nuget terraform_module].freeze belongs_to :namespace, inverse_of: :package_setting_relation @@ -24,6 +24,14 @@ class Namespace::PackageSetting < ApplicationRecord validates :nuget_duplicates_allowed, inclusion: { in: [true, false] } validates :nuget_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } validates :nuget_symbol_server_enabled, inclusion: { in: [true, false] } + validates :terraform_module_duplicates_allowed, inclusion: { in: [true, false] } + validates :terraform_module_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + + scope :namespace_id_in, ->(namespace_ids) { where(namespace_id: namespace_ids) } + scope :with_terraform_module_duplicates_allowed_or_exception_regex, -> do + where(terraform_module_duplicates_allowed: true) + .or(where.not(terraform_module_duplicate_exception_regex: '')) + end class << self def duplicates_allowed?(package) diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 0263942116d..e61e5a7f37e 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -4,9 +4,13 @@ class NamespaceSetting < ApplicationRecord include CascadingNamespaceSettingAttribute include Sanitizable include ChronicDurationAttribute + include IgnorableColumns + + ignore_column :project_import_level, remove_with: '16.10', remove_after: '2024-02-22' cascading_attr :delayed_project_removal cascading_attr :toggle_security_policy_custom_ci + cascading_attr :toggle_security_policies_policy_scope belongs_to :namespace, inverse_of: :namespace_settings diff --git a/app/models/namespaces/descendants.rb b/app/models/namespaces/descendants.rb new file mode 100644 index 00000000000..8444cea9848 --- /dev/null +++ b/app/models/namespaces/descendants.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Namespaces + class Descendants < ApplicationRecord + self.table_name = :namespace_descendants + + belongs_to :namespace + + validates :namespace_id, uniqueness: true + + def self.expire_for(namespace_ids) + # Union: + # - Look up all parent ids including the given ids via traversal_ids + # - Include the given ids to handle the case when the namespaces records are already deleted + sql = <<~SQL + WITH namespace_ids AS MATERIALIZED ( + ( + SELECT ids.id + FROM namespaces, UNNEST(traversal_ids) ids(id) + WHERE namespaces.id IN (?) + ) UNION + (SELECT UNNEST(ARRAY[?]) AS id) + ) + UPDATE namespace_descendants SET outdated_at = ? FROM namespace_ids WHERE namespace_descendants.namespace_id = namespace_ids.id + SQL + + connection.execute(sanitize_sql_array([sql, namespace_ids, namespace_ids, Time.current])) + end + end +end diff --git a/app/models/namespaces/traversal/cached.rb b/app/models/namespaces/traversal/cached.rb new file mode 100644 index 00000000000..55eaaa4667e --- /dev/null +++ b/app/models/namespaces/traversal/cached.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Namespaces + module Traversal + module Cached + extend ActiveSupport::Concern + extend Gitlab::Utils::Override + + included do + after_destroy :invalidate_descendants_cache + end + + private + + override :sync_traversal_ids + def sync_traversal_ids + super + return if is_a?(Namespaces::UserNamespace) + return unless Feature.enabled?(:namespace_descendants_cache_expiration, self, type: :gitlab_com_derisk) + + ids = [id] + ids.concat((saved_changes[:parent_id] - [parent_id]).compact) if saved_changes[:parent_id] + Namespaces::Descendants.expire_for(ids) + end + + def invalidate_descendants_cache + return if is_a?(Namespaces::UserNamespace) + return unless Feature.enabled?(:namespace_descendants_cache_expiration, self, type: :gitlab_com_derisk) + + Namespaces::Descendants.expire_for([parent_id, id].compact) + end + end + end +end diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb index afbd671f82e..53781e112ae 100644 --- a/app/models/onboarding/completion.rb +++ b/app/models/onboarding/completion.rb @@ -3,7 +3,6 @@ module Onboarding class Completion include Gitlab::Utils::StrongMemoize - include Gitlab::Experiment::Dsl ACTION_PATHS = [ :pipeline_created, @@ -12,6 +11,7 @@ module Onboarding :code_owners_enabled, :issue_created, :git_write, + :code_added, :merge_request_created, :user_added, :license_scanning_run, @@ -35,20 +35,11 @@ module Onboarding end def completed?(column) - if column == :code_added - repository.commit_count > 1 || repository.branch_count > 1 - else - attributes[column].present? - end + attributes[column].present? end private - def repository - project.repository - end - strong_memoize_attr :repository - def attributes onboarding_progress.attributes.symbolize_keys end @@ -60,8 +51,7 @@ module Onboarding strong_memoize_attr :onboarding_progress def action_columns - [:code_added] + - ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) } + ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) } end strong_memoize_attr :action_columns diff --git a/app/models/onboarding/progress.rb b/app/models/onboarding/progress.rb index 83030732c6a..b6628843821 100644 --- a/app/models/onboarding/progress.rb +++ b/app/models/onboarding/progress.rb @@ -32,7 +32,8 @@ module Onboarding :secure_api_fuzzing_run, :secure_cluster_image_scanning_run, :license_scanning_run, - :promote_ultimate_features + :promote_ultimate_features, + :code_added ].freeze scope :incomplete_actions, ->(actions) do diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 764378a5d19..df6f0109d57 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Organizations - class Organization < ApplicationRecord + class Organization < MainClusterwide::ApplicationRecord DEFAULT_ORGANIZATION_ID = 1 scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) } @@ -16,6 +16,8 @@ module Organizations has_one :organization_detail, inverse_of: :organization, autosave: true has_many :organization_users, inverse_of: :organization + # if considering disable_joins on the below see: + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140343#note_1705047949 has_many :users, through: :organization_users, inverse_of: :organizations validates :name, @@ -28,7 +30,7 @@ module Organizations 'organizations/path': true, length: { minimum: 2, maximum: 255 } - delegate :description, :avatar, :avatar_url, to: :organization_detail + delegate :description, :description_html, :avatar, :avatar_url, :remove_avatar!, to: :organization_detail accepts_nested_attributes_for :organization_detail @@ -52,6 +54,10 @@ module Organizations organization_users.exists?(user: user) end + def owner?(user) + organization_users.owners.exists?(user: user) + end + def web_url(only_path: nil) Gitlab::UrlBuilder.build(self, only_path: only_path) end diff --git a/app/models/organizations/organization_detail.rb b/app/models/organizations/organization_detail.rb index b69ec5eae76..018e7579c5b 100644 --- a/app/models/organizations/organization_detail.rb +++ b/app/models/organizations/organization_detail.rb @@ -6,7 +6,7 @@ module Organizations include Avatarable include WithUploads - cache_markdown_field :description + cache_markdown_field :description, pipeline: :description belongs_to :organization, inverse_of: :organization_detail diff --git a/app/models/organizations/organization_user.rb b/app/models/organizations/organization_user.rb index 5aa1133b017..9e06870dcc6 100644 --- a/app/models/organizations/organization_user.rb +++ b/app/models/organizations/organization_user.rb @@ -4,5 +4,17 @@ module Organizations class OrganizationUser < ApplicationRecord belongs_to :organization, inverse_of: :organization_users, optional: false belongs_to :user, inverse_of: :organization_users, optional: false + + validates :user, uniqueness: { scope: :organization_id } + validates :access_level, presence: true + + enum access_level: { + # Until we develop more access_levels, we really don't know if the default access_level will be what we think of + # as a guest. For now, we'll set to same value as guest, but call it default to denote the current ambivalence. + default: Gitlab::Access::GUEST, + owner: Gitlab::Access::OWNER + } + + scope :owners, -> { where(access_level: Gitlab::Access::OWNER) } end end diff --git a/app/models/pages/project_settings.rb b/app/models/pages/project_settings.rb new file mode 100644 index 00000000000..96e5bb8e98e --- /dev/null +++ b/app/models/pages/project_settings.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Pages + class ProjectSettings + def initialize(project) + @project = project + end + + def url = url_builder.pages_url(with_unique_domain: true) + + def deployments = project.pages_deployments.active + + def unique_domain_enabled? = project.project_setting.pages_unique_domain_enabled? + + def force_https? = project.pages_https_only? + + private + + attr_reader :project + + def url_builder + @url_builder ||= ::Gitlab::Pages::UrlBuilder.new(project) + end + end +end diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index e8b186234af..a360b705805 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -68,6 +68,14 @@ class PagesDeployment < ApplicationRecord update(deleted_at: Time.now.utc) end + def url + base_url = ::Gitlab::Pages::UrlBuilder + .new(project) + .pages_url(with_unique_domain: true) + + File.join(base_url.to_s, path_prefix.to_s) + end + private def set_size diff --git a/app/models/project.rb b/app/models/project.rb index 7b996457c0d..8f82a947ba6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -208,6 +208,7 @@ class Project < ApplicationRecord has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker' has_one :datadog_integration, class_name: 'Integrations::Datadog' has_one :container_registry_data_repair_detail, class_name: 'ContainerRegistry::DataRepairDetail' + has_one :diffblue_cover_integration, class_name: 'Integrations::DiffblueCover' has_one :discord_integration, class_name: 'Integrations::Discord' has_one :drone_ci_integration, class_name: 'Integrations::DroneCi' has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush' @@ -334,7 +335,7 @@ class Project < ApplicationRecord has_many :authorized_users, -> { allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') }, through: :project_authorizations, source: :user, class_name: 'User' - has_many :project_members, -> { where(requested_at: nil) }, + has_many :project_members, -> { non_request }, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :project_members has_many :namespace_members, ->(project) { where(requested_at: nil).unscope(where: %i[source_id source_type]) }, @@ -508,6 +509,7 @@ class Project < ApplicationRecord delegate :members, prefix: true delegate :add_member, :add_members, :member? delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role + delegate :has_user? end with_options to: :namespace do @@ -749,6 +751,7 @@ class Project < ApplicationRecord preload(:project_feature, :route, namespace: [:route, :owner]) } + scope :with_name, -> (name) { where(name: name) } scope :created_by, -> (user) { where(creator: user) } scope :imported_from, -> (type) { where(import_type: type) } scope :imported, -> { where.not(import_type: nil) } @@ -3205,6 +3208,21 @@ class Project < ApplicationRecord end strong_memoize_attr :code_suggestions_enabled? + # Overridden in EE + def allows_multiple_merge_request_assignees? + false + end + + # Overridden in EE + def allows_multiple_merge_request_reviewers? + false + end + + # Overridden in EE + def on_demand_dast_available? + false + end + private # overridden in EE @@ -3226,8 +3244,11 @@ class Project < ApplicationRecord if @topic_list != self.topic_list self.topics.delete_all - self.topics = @topic_list.map do |topic| - Projects::Topic.where('lower(name) = ?', topic.downcase).order(total_projects_count: :desc).first_or_create(name: topic, title: topic) + self.topics = @topic_list.map do |topic_name| + Projects::Topic + .where('lower(name) = ?', topic_name.downcase) + .order(total_projects_count: :desc) + .first_or_create(name: topic_name, title: topic_name, slug: Gitlab::Slug::Path.new(topic_name).generate) end end @@ -3438,7 +3459,7 @@ class Project < ApplicationRecord def check_project_export_limit! return if Gitlab::CurrentSettings.current_application_settings.max_export_size == 0 - if self.statistics.storage_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes + if self.statistics.export_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes raise ExportLimitExceeded, _('The project size exceeds the export limit.') end end diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb index ac52bdfdb07..26f5366ad5e 100644 --- a/app/models/project_authorizations/changes.rb +++ b/app/models/project_authorizations/changes.rb @@ -21,6 +21,7 @@ module ProjectAuthorizations @authorizations_to_add = [] @affected_project_ids = Set.new @removed_user_ids = Set.new + @added_user_ids = Set.new yield self end @@ -61,6 +62,7 @@ module ProjectAuthorizations def add_authorizations insert_all_in_batches(authorizations_to_add) @affected_project_ids += authorizations_to_add.pluck(:project_id) + @added_user_ids += authorizations_to_add.pluck(:user_id) end def delete_authorizations_for_user @@ -139,23 +141,51 @@ module ProjectAuthorizations end def publish_events + publish_changed_event + publish_removed_event + publish_added_event + end + + def publish_changed_event + # This event is used to add policy approvers to approval rules by re-syncing all project policies which is costly. + # If the feature flag below is enabled, the policies won't be re-synced and + # the approvers will be added via `AuthorizationsAddedEvent`. + return if ::Feature.enabled?(:add_policy_approvers_to_rules) + @affected_project_ids.each do |project_id| ::Gitlab::EventStore.publish( ::ProjectAuthorizations::AuthorizationsChangedEvent.new(data: { project_id: project_id }) ) end - return if ::Feature.disabled?(:user_approval_rules_removal) || @removed_user_ids.blank? + end - @affected_project_ids.each do |project_id| - @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).each do |user_ids_batch| - ::Gitlab::EventStore.publish( - ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: { - project_id: project_id, - user_ids: user_ids_batch - }) - ) + def publish_removed_event + return if @removed_user_ids.none? + + events = @affected_project_ids.flat_map do |project_id| + @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).map do |user_ids_batch| + ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: { + project_id: project_id, + user_ids: user_ids_batch + }) + end + end + ::Gitlab::EventStore.publish_group(events) + end + + def publish_added_event + return if ::Feature.disabled?(:add_policy_approvers_to_rules) + return if @added_user_ids.none? + + events = @affected_project_ids.flat_map do |project_id| + @added_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).map do |user_ids_batch| + ::ProjectAuthorizations::AuthorizationsAddedEvent.new(data: { + project_id: project_id, + user_ids: user_ids_batch + }) end end + ::Gitlab::EventStore.publish_group(events) end end end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 942f20f6e5e..f89894b77a8 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -145,6 +145,11 @@ class ProjectStatistics < ApplicationRecord bulk_increment_counter(key, increments) end + # Build artifacts & packages are not included in the project export + def export_size + storage_size - build_artifacts_size - packages_size + end + private def incrementable_attribute?(key) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 5078642ea3a..3af9f946243 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -172,6 +172,13 @@ class ProjectTeam max_member_access(user.id) >= min_access_level end + # Only for direct and not invited members + def has_user?(user) + return false unless user + + project.project_members.non_invite.exists?(user: user) + end + def human_max_access(user_id) Gitlab::Access.human_access(max_member_access(user_id)) end diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb index 7021a48646a..7833c1ebf24 100644 --- a/app/models/projects/project_topic.rb +++ b/app/models/projects/project_topic.rb @@ -4,5 +4,7 @@ module Projects class ProjectTopic < ApplicationRecord belongs_to :project belongs_to :topic, counter_cache: :total_projects_count + + validates :topic_id, uniqueness: { scope: [:project_id] } end end diff --git a/app/models/projects/repository_storage_move.rb b/app/models/projects/repository_storage_move.rb index ae815bf366d..95fd78e8941 100644 --- a/app/models/projects/repository_storage_move.rb +++ b/app/models/projects/repository_storage_move.rb @@ -17,11 +17,7 @@ module Projects override :schedule_repository_storage_update_worker def schedule_repository_storage_update_worker - Projects::UpdateRepositoryStorageWorker.perform_async( - project_id, - destination_storage_name, - id - ) + Projects::UpdateRepositoryStorageWorker.perform_async(id) end private diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index 347d65841ed..a3622150351 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -7,9 +7,18 @@ module Projects include Avatarable include Gitlab::SQL::Pattern + SLUG_ALLOWED_REGEX = %r{\A[a-zA-Z0-9_\-.]+\z} + validates :name, presence: true, length: { maximum: 255 } validates :name, uniqueness: { case_sensitive: false }, if: :name_changed? validate :validate_name_format, if: :name_changed? + + validates :slug, + length: { maximum: 255 }, + uniqueness: { case_sensitive: false }, + format: { with: SLUG_ALLOWED_REGEX, message: "can contain only letters, digits, '_', '-', '.'" }, + if: :slug_changed? + validates :title, presence: true, length: { maximum: 255 }, on: :create validates :description, length: { maximum: 1024 } diff --git a/app/models/release.rb b/app/models/release.rb index 1cd623e1254..7bacc69f038 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -54,6 +54,7 @@ class Release < ApplicationRecord scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } + scope :unpublished, -> { where(release_published_at: nil) } scope :for_projects, ->(projects) { where(project_id: projects) } scope :by_tag, ->(tag) { where(tag: tag) } @@ -66,6 +67,7 @@ class Release < ApplicationRecord delegate :repository, to: :project MAX_NUMBER_TO_DISPLAY = 3 + MAX_NUMBER_TO_PUBLISH = 5000 class << self # In the future, we should support `order_by=semver`; @@ -97,6 +99,10 @@ class Release < ApplicationRecord .from("(VALUES #{project_ids_list}) projects (id)") .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{Release.table_name} ON TRUE") end + + def waiting_for_publish_event + unpublished.released_within_2hrs.joins(:project).merge(Project.with_feature_enabled(:releases)).limit(MAX_NUMBER_TO_PUBLISH) + end end def to_param diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index ad1ce740c89..e912e57f39e 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -45,7 +45,7 @@ class ResourceLabelEvent < ResourceEvent end def group - issuable.group if issuable.respond_to?(:group) + issuable.resource_parent if issuable.resource_parent.is_a?(Group) end def outdated_markdown? @@ -93,7 +93,9 @@ class ResourceLabelEvent < ResourceEvent end def label_url_method - issuable.is_a?(MergeRequest) ? :project_merge_requests_url : :project_issues_url + return :project_merge_requests_url if issuable.is_a?(MergeRequest) + + issuable.project_id.nil? ? :group_work_items_url : :project_issues_url end def broadcast_notes_changed diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index d305a4ace51..2b93334f721 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ResourceMilestoneEvent < ResourceTimeboxEvent + include EachBatch + belongs_to :milestone scope :include_relations, -> { includes(:user, milestone: [:project, :group]) } diff --git a/app/models/route.rb b/app/models/route.rb index 652c33a673c..1fa0005ffb4 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -3,6 +3,7 @@ class Route < MainClusterwide::ApplicationRecord include CaseSensitivity include Gitlab::SQL::Pattern + include EachBatch belongs_to :source, polymorphic: true, inverse_of: :route # rubocop:disable Cop/PolymorphicAssociations belongs_to :namespace, inverse_of: :namespace_route @@ -26,30 +27,39 @@ class Route < MainClusterwide::ApplicationRecord def rename_descendants return unless saved_change_to_path? || saved_change_to_name? - descendant_routes = self.class.inside_path(path_before_last_save) + if Feature.disabled?(:batch_route_updates, Feature.current_request, type: :gitlab_com_derisk) + descendant_routes = self.class.inside_path(path_before_last_save) - descendant_routes.each do |route| - attributes = {} + descendant_routes.each do |route| + attributes = {} - if saved_change_to_path? && route.path.present? - attributes[:path] = route.path.sub(path_before_last_save, path) - end + if saved_change_to_path? && route.path.present? + attributes[:path] = route.path.sub(path_before_last_save, path) + end - if saved_change_to_name? && name_before_last_save.present? && route.name.present? - attributes[:name] = route.name.sub(name_before_last_save, name) - end + if saved_change_to_name? && name_before_last_save.present? && route.name.present? + attributes[:name] = route.name.sub(name_before_last_save, name) + end - next if attributes.empty? + next if attributes.empty? - old_path = route.path + old_path = route.path - # Callbacks must be run manually - route.update_columns(attributes.merge(updated_at: Time.current)) + # Callbacks must be run manually + route.update_columns(attributes.merge(updated_at: Time.current)) + + # We are not calling route.delete_conflicting_redirects here, in hopes + # of avoiding deadlocks. The parent (self, in this method) already + # called it, which deletes conflicts for all descendants. + route.create_redirect(old_path) if attributes[:path] + end + else + changes = { + path: { saved: saved_change_to_path?, old_value: path_before_last_save }, + name: { saved: saved_change_to_name?, old_value: name_before_last_save } + } - # We are not calling route.delete_conflicting_redirects here, in hopes - # of avoiding deadlocks. The parent (self, in this method) already - # called it, which deletes conflicts for all descendants. - route.create_redirect(old_path) if attributes[:path] + Routes::RenameDescendantsService.new(self).execute(changes) # rubocop: disable CodeReuse/ServiceClass -- Need a service class to encapsulate all the logic. end end diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb index 7ae44ac6aa1..6955f178bea 100644 --- a/app/models/service_desk/custom_email_credential.rb +++ b/app/models/service_desk/custom_email_credential.rb @@ -61,12 +61,13 @@ module ServiceDesk def validate_smtp_address # Addressable::URI always needs a scheme otherwise it interprets the host as the path - Gitlab::UrlBlocker.validate!("smtp://#{smtp_address}", + Gitlab::HTTP_V2::UrlBlocker.validate!("smtp://#{smtp_address}", schemes: %w[smtp], ascii_only: true, enforce_sanitization: true, allow_localhost: false, - allow_local_network: !::Gitlab.com? # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network + allow_local_network: !::Gitlab.com?, # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e errors.add(:smtp_address, e) diff --git a/app/models/snippets/repository_storage_move.rb b/app/models/snippets/repository_storage_move.rb index 9db25ef4fc5..794caefb77d 100644 --- a/app/models/snippets/repository_storage_move.rb +++ b/app/models/snippets/repository_storage_move.rb @@ -16,11 +16,7 @@ module Snippets override :schedule_repository_storage_update_worker def schedule_repository_storage_update_worker - Snippets::UpdateRepositoryStorageWorker.perform_async( - snippet_id, - destination_storage_name, - id - ) + Snippets::UpdateRepositoryStorageWorker.perform_async(id) end private diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 672a6d64127..f0855fc9f1c 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -137,12 +137,13 @@ class SshHostKey end def normalize_url(url) - url, real_hostname = Gitlab::UrlBlocker.validate!( + url, real_hostname = Gitlab::HTTP_V2::UrlBlocker.validate!( url, schemes: %w[ssh], allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - dns_rebind_protection: Gitlab::CurrentSettings.dns_rebinding_protection_enabled? + dns_rebind_protection: Gitlab::CurrentSettings.dns_rebinding_protection_enabled?, + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) # When DNS rebinding protection is required, the hostname is replaced by the diff --git a/app/models/time_tracking/timelog_category.rb b/app/models/time_tracking/timelog_category.rb index 67565039acd..295304f6e99 100644 --- a/app/models/time_tracking/timelog_category.rb +++ b/app/models/time_tracking/timelog_category.rb @@ -9,6 +9,8 @@ module TimeTracking belongs_to :namespace, foreign_key: 'namespace_id' + has_many :timelogs + strip_attributes! :name validates :namespace, presence: true diff --git a/app/models/timelog.rb b/app/models/timelog.rb index 0ae7790eef9..ffb88b7ebea 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -20,6 +20,7 @@ class Timelog < ApplicationRecord belongs_to :project belongs_to :user belongs_to :note + belongs_to :timelog_category, optional: true, class_name: 'TimeTracking::TimelogCategory' scope :in_group, -> (group) do joins(:project).where(projects: { namespace: group.self_and_descendants }) diff --git a/app/models/tree.rb b/app/models/tree.rb index 030e7d9e85f..d62e5c1b368 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -18,8 +18,15 @@ class Tree ref = ExtractsRef::RefExtractor.qualify_ref(@sha, ref_type) - @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, rescue_not_found, - pagination_params) + @entries, @cursor = Gitlab::Git::Tree.tree_entries( + repository: git_repo, + sha: ref, + path: @path, + recursive: recursive, + skip_flat_paths: skip_flat_paths, + rescue_not_found: rescue_not_found, + pagination_params: pagination_params + ) @entries.each do |entry| entry.ref_type = self.ref_type diff --git a/app/models/user.rb b/app/models/user.rb index c36898aaf70..c9873975cc9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -151,7 +151,7 @@ class User < MainClusterwide::ApplicationRecord # Namespace for personal projects has_one :namespace, -> { where(type: Namespaces::UserNamespace.sti_name) }, - required: true, + required: false, dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent foreign_key: :owner_id, inverse_of: :owner, @@ -270,7 +270,8 @@ class User < MainClusterwide::ApplicationRecord belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user - has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users + has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users, + disable_joins: true has_one :status, class_name: 'UserStatus' has_one :user_preference @@ -284,8 +285,6 @@ class User < MainClusterwide::ApplicationRecord has_many :reviews, foreign_key: :author_id, inverse_of: :author - has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail' - has_many :timelogs has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent @@ -304,6 +303,10 @@ class User < MainClusterwide::ApplicationRecord # Validations # # Note: devise :validatable above adds validations for :email and :password + validates :username, + presence: true, + exclusion: { in: Gitlab::PathRegex::TOP_LEVEL_ROUTES, message: N_('%{value} is a reserved name') } + validates :username, uniqueness: true, unless: :namespace validates :name, presence: true, length: { maximum: 255 } validates :first_name, length: { maximum: 127 } validates :last_name, length: { maximum: 127 } @@ -314,10 +317,9 @@ class User < MainClusterwide::ApplicationRecord validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } - validates :username, presence: true validate :check_password_weakness, if: :encrypted_password_changed? - validates :namespace, presence: true + validates :namespace, presence: true, unless: :optional_namespace? validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record? validate :unique_email, if: :email_changed? @@ -591,6 +593,8 @@ class User < MainClusterwide::ApplicationRecord scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } + scope :ordered_by_id_desc, -> { reorder(arel_table[:id].desc) } + scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) } scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } @@ -847,6 +851,25 @@ class User < MainClusterwide::ApplicationRecord scope.reorder(order) end + # This should be kept in sync with the frontend filtering in + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053 + def gfm_autocomplete_search(query) + where( + "REPLACE(users.name, ' ', '') ILIKE :pattern OR users.username ILIKE :pattern", + pattern: "%#{sanitize_sql_like(query)}%" + ).order( + Arel.sql(sanitize_sql( + [ + "CASE WHEN starts_with(REPLACE(users.name, ' ', ''), :pattern) OR starts_with(users.username, :pattern) THEN 1 ELSE 2 END", + { pattern: query } + ] + )), + :username, + :id + ) + end + # Limits the result set to users _not_ in the given query/list of IDs. # # users - The list of users to ignore. This can be an @@ -1302,7 +1325,13 @@ class User < MainClusterwide::ApplicationRecord end def can_create_project? - projects_limit_left > 0 + projects_limit_left > 0 && allow_user_to_create_group_and_project? + end + + def allow_user_to_create_group_and_project? + return true if Gitlab::CurrentSettings.allow_project_creation_for_guest_and_below + + highest_role > Gitlab::Access::GUEST end def can_create_group? @@ -1596,12 +1625,6 @@ class User < MainClusterwide::ApplicationRecord if namespace namespace.path = username if username_changed? namespace.name = name if name_changed? - elsif Feature.disabled?(:create_personal_ns_outside_model, Feature.current_request) - # TODO: we should no longer need the `type` parameter once we can make the - # the `has_one :namespace` association use the correct class. - # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name) - namespace.build_namespace_settings end end @@ -1623,6 +1646,9 @@ class User < MainClusterwide::ApplicationRecord self.errors.add(:base, :username_exists_as_a_different_namespace) else namespace_path_errors.each do |msg| + # Already handled by username validation. + next if msg.ends_with?('is a reserved name') + self.errors.add(:username, msg) end end @@ -2300,6 +2326,10 @@ class User < MainClusterwide::ApplicationRecord private + def optional_namespace? + Feature.enabled?(:optional_personal_namespace, self) + end + def block_or_ban user_scores = Abuse::UserTrustScore.new(self) if user_scores.spammer? && account_age_in_days < 7 diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index c32414be312..8d330e4eb6e 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -79,7 +79,9 @@ module Users vulnerability_report_grouping: 77, # EE-only new_nav_for_everyone_callout: 78, code_suggestions_ga_non_owner_alert: 79, # EE-only - duo_chat_callout: 80 # EE-only + duo_chat_callout: 80, # EE-only + code_suggestions_ga_owner_alert: 81, # EE-only + product_analytics_dashboard_feedback: 82 # EE-only } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 6d0a22c8b0a..33e7ba72d5a 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -8,7 +8,7 @@ module Users self.table_name = 'user_credit_card_validations' - ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.8', remove_after: '2023-12-22' + ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.9', remove_after: '2024-01-22' attr_accessor :last_digits, :network, :holder_name, :expiration_date diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb deleted file mode 100644 index 5362a726ff5..00000000000 --- a/app/models/users/in_product_marketing_email.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -module Users - class InProductMarketingEmail < ApplicationRecord - include BulkInsertSafe - - belongs_to :user - - validates :user, presence: true - validates :track, presence: true - validates :series, presence: true - - validates :user_id, uniqueness: { - scope: [:track, :series], - message: 'track series email has already been sent' - }, if: -> { track.present? } - - enum track: { - create: 0, - verify: 1, - trial: 2, - team: 3, - experience: 4, - team_short: 5, - trial_short: 6, - admin_verify: 7, - invite_team: 8 - }, _suffix: true - - # Tracks we don't send emails for (e.g. unsuccessful experiment). These - # are kept since we already have DB records that use the enum value. - INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze - ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES) - - scope :for_user_with_track_and_series, ->(user, track, series) do - where(user: user, track: track, series: series) - end - - scope :without_track_and_series, ->(track, series) do - join_condition = for_user.and(for_track_and_series(track, series)) - users_without_records(join_condition) - end - - def self.users_table - User.arel_table - end - - def self.distinct_users_sql - name = users_table.name - Arel.sql("DISTINCT ON(#{name}.id) #{name}.*") - end - - def self.users_without_records(condition) - arel_join = users_table.join(arel_table, Arel::Nodes::OuterJoin).on(condition) - joins(arel_join.join_sources) - .where(in_product_marketing_emails: { id: nil }) - .select(distinct_users_sql) - end - - def self.for_user - arel_table[:user_id].eq(users_table[:id]) - end - - def self.for_track_and_series(track, series) - arel_table[:track].eq(ACTIVE_TRACKS[track]) - .and(arel_table[:series]).eq(series) - end - - def self.save_cta_click(user, track, series) - email = for_user_with_track_and_series(user, track, series).take - - email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank? - end - end -end diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb index 072b75a1c90..ffb8d3a95a2 100644 --- a/app/models/users/phone_number_validation.rb +++ b/app/models/users/phone_number_validation.rb @@ -4,12 +4,17 @@ module Users class PhoneNumberValidation < ApplicationRecord include IgnorableColumns + # SMS send attempts subsequent to the first one will have wait times of 1 + # min, 3 min, 5 min after each one respectively. Wait time between the fifth + # attempt and so on will be 10 minutes. + SMS_SEND_WAIT_TIMES = [1.minute, 3.minutes, 5.minutes, 10.minutes].freeze + self.primary_key = :user_id self.table_name = 'user_phone_number_validations' ignore_column :verification_attempts, remove_with: '16.7', remove_after: '2023-11-17' - belongs_to :user, foreign_key: :user_id + belongs_to :user belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id validates :country, presence: true, length: { maximum: 3 } @@ -26,13 +31,24 @@ module Users presence: true, format: { with: /\A\d+\Z/, - message: -> (object, data) { _('can contain only digits') } + message: ->(_object, _data) { _('can contain only digits') } }, length: { maximum: 12 } validates :telesign_reference_xid, length: { maximum: 255 } - scope :for_user, -> (user_id) { where(user_id: user_id) } + scope :for_user, ->(user_id) { where(user_id: user_id) } + + scope :similar_to, ->(phone_number_validation) do + where( + international_dial_code: phone_number_validation.international_dial_code, + phone_number: phone_number_validation.phone_number + ) + end + + def similar_records + self.class.similar_to(self).includes(:user) + end def self.related_to_banned_user?(international_dial_code, phone_number) joins(:banned_user) @@ -51,5 +67,18 @@ module Users def validated? validated_at.present? end + + def sms_send_allowed_after + return unless Feature.enabled?(:sms_send_wait_time, user) + + # first send is allowed anytime + return if sms_send_count < 1 + return unless sms_sent_at + + max_wait_time = SMS_SEND_WAIT_TIMES.last + wait_time = SMS_SEND_WAIT_TIMES.fetch(sms_send_count - 1, max_wait_time) + + sms_sent_at + wait_time + end end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 77f684e3578..f1d007e8167 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -5,7 +5,7 @@ class WorkItem < Issue COMMON_QUICK_ACTIONS_COMMANDS = [ :title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to, :checkin_reminder, - :subscribe, :unsubscribe, :confidential, :award + :subscribe, :unsubscribe, :confidential, :award, :react ].freeze self.table_name = 'issues' diff --git a/app/models/work_items/hierarchy_restriction.rb b/app/models/work_items/hierarchy_restriction.rb index a253447a8db..f74f2f037b1 100644 --- a/app/models/work_items/hierarchy_restriction.rb +++ b/app/models/work_items/hierarchy_restriction.rb @@ -7,8 +7,17 @@ module WorkItems belongs_to :parent_type, class_name: 'WorkItems::Type' belongs_to :child_type, class_name: 'WorkItems::Type' + after_destroy :clear_parent_type_cache! + after_save :clear_parent_type_cache! + validates :parent_type, presence: true validates :child_type, presence: true validates :child_type, uniqueness: { scope: :parent_type_id } + + private + + def clear_parent_type_cache! + parent_type.clear_reactive_cache! + end end end diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb index f25c951406f..2637a7c8185 100644 --- a/app/models/work_items/widget_definition.rb +++ b/app/models/work_items/widget_definition.rb @@ -32,7 +32,9 @@ module WorkItems notifications: 14, current_user_todos: 15, award_emoji: 16, - linked_items: 17 + linked_items: 17, + color: 18, # EE-only + rolledup_dates: 19 # EE-only } def self.available_widgets diff --git a/app/models/work_items/widgets/notes.rb b/app/models/work_items/widgets/notes.rb index bde94ea8f43..67ee19f4947 100644 --- a/app/models/work_items/widgets/notes.rb +++ b/app/models/work_items/widgets/notes.rb @@ -4,8 +4,18 @@ module WorkItems module Widgets class Notes < Base delegate :notes, to: :work_item + delegate :discussion_locked, to: :work_item + delegate_missing_to :work_item + def self.quick_action_commands + [:lock, :unlock] + end + + def self.quick_action_params + [:discussion_locked] + end + def declarative_policy_delegate work_item end diff --git a/app/policies/container_registry/referrer_policy.rb b/app/policies/container_registry/referrer_policy.rb new file mode 100644 index 00000000000..96eb4c60c84 --- /dev/null +++ b/app/policies/container_registry/referrer_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ContainerRegistry + class ReferrerPolicy < BasePolicy + delegate { @subject.tag } + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 175f86c9673..85ddf61fbd4 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -15,6 +15,8 @@ class GlobalPolicy < BasePolicy @user&.required_terms_not_accepted? end + condition(:can_create_group_and_projects, scope: :user) { @user&.allow_user_to_create_group_and_project? } + condition(:password_expired, scope: :user) do @user&.password_expired_if_applicable? end @@ -90,6 +92,8 @@ class GlobalPolicy < BasePolicy enable :create_group end + rule { ~can_create_group_and_projects }.prevent :create_group + rule { can_create_organization }.policy do enable :create_organization end diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb index d538b786f78..a203a58b164 100644 --- a/app/policies/organizations/organization_policy.rb +++ b/app/policies/organizations/organization_policy.rb @@ -3,6 +3,7 @@ module Organizations class OrganizationPolicy < BasePolicy condition(:organization_user) { @subject.user?(@user) } + condition(:organization_owner) { @subject.owner?(@user) } desc 'Organization is public' condition(:public_organization, scope: :subject, score: 0) { true } @@ -13,14 +14,19 @@ module Organizations rule { admin }.policy do enable :admin_organization + enable :create_group enable :read_organization enable :read_organization_user end - rule { organization_user }.policy do + rule { organization_owner }.policy do enable :admin_organization + end + + rule { organization_user }.policy do enable :read_organization enable :read_organization_user + enable :create_group end end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 255538c538a..a26758974d6 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -914,6 +914,7 @@ class ProjectPolicy < BasePolicy rule { can?(:admin_project) }.policy do enable :read_usage_quotas + enable :view_edit_page end rule { can?(:project_bot_access) }.policy do diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index c52fc168c55..087fb8bf201 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -22,14 +22,15 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ) end - def highlight(to: nil, plain: nil) + def highlight(to: nil, plain: nil, used_on: :blob) load_all_blob_data Gitlab::Highlight.highlight( blob.path, blob_data(to), language: blob_language, - plain: plain + plain: plain, + used_on: used_on ) end diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb index a0d731f0ccf..244f36f627d 100644 --- a/app/presenters/projects/security/configuration_presenter.rb +++ b/app/presenters/projects/security/configuration_presenter.rb @@ -85,7 +85,8 @@ module Projects available: scan.available?, can_enable_by_merge_request: scan.can_enable_by_merge_request?, meta_info_path: scan.meta_info_path, - on_demand_available: scan.on_demand_available? + on_demand_available: scan.on_demand_available?, + security_features: scan.security_features } end diff --git a/app/serializers/activity_pub/activity_serializer.rb b/app/serializers/activity_pub/activity_serializer.rb new file mode 100644 index 00000000000..71a1bfece6b --- /dev/null +++ b/app/serializers/activity_pub/activity_serializer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ActivityPub + # Serializer for the `Activity` ActivityStreams model. + # Reference: https://www.w3.org/TR/activitystreams-core/#activities + class ActivitySerializer < ObjectSerializer + MissingActorError = Class.new(StandardError) + MissingObjectError = Class.new(StandardError) + IntransitiveWithObjectError = Class.new(StandardError) + + private + + def validate_response(serialized, opts) + response = super(serialized, opts) + + unless response[:actor].present? + raise MissingActorError, "The serializer does not provide the mandatory 'actor' field." + end + + if opts[:intransitive] && response[:object].present? + raise IntransitiveWithObjectError, <<~ERROR + The serializer does provide both the 'object' field and the :intransitive option. + Intransitive activities are meant precisely for when no object is available. + Please remove either of those. + See https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + ERROR + end + + unless opts[:intransitive] || response[:object].present? + raise MissingObjectError, <<~ERROR + The serializer does not provide the mandatory 'object' field. + Pass the :intransitive option to #represent if this is an intransitive activity. + See https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + ERROR + end + + response + end + end +end diff --git a/app/serializers/activity_pub/activity_streams_serializer.rb b/app/serializers/activity_pub/activity_streams_serializer.rb deleted file mode 100644 index 39caa4a6d10..00000000000 --- a/app/serializers/activity_pub/activity_streams_serializer.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -module ActivityPub - class ActivityStreamsSerializer < ::BaseSerializer - MissingIdentifierError = Class.new(StandardError) - MissingTypeError = Class.new(StandardError) - MissingOutboxError = Class.new(StandardError) - - alias_method :base_represent, :represent - - def represent(resource, opts = {}, entity_class = nil) - response = if respond_to?(:paginated?) && paginated? - represent_paginated(resource, opts, entity_class) - else - represent_whole(resource, opts, entity_class) - end - - validate_response(HashWithIndifferentAccess.new(response)) - end - - private - - def validate_response(response) - unless response[:id].present? - raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field." - end - - unless response[:type].present? - raise MissingTypeError, "The serializer does not provide the mandatory 'type' field." - end - - response - end - - def represent_whole(resource, opts, entity_class) - raise MissingOutboxError, 'Please provide an :outbox option for this actor' unless opts[:outbox].present? - - serialized = base_represent(resource, opts, entity_class) - - { - :@context => "https://www.w3.org/ns/activitystreams", - inbox: opts[:inbox], - outbox: opts[:outbox] - }.merge(serialized) - end - - def represent_paginated(resources, opts, entity_class) - if paginator.params['page'].present? - represent_page(resources, resources.current_page, opts, entity_class) - else - represent_pagination_index(resources) - end - end - - def represent_page(resources, page, opts, entity_class) - opts[:page] = page - serialized = base_represent(resources, opts, entity_class) - - { - :@context => 'https://www.w3.org/ns/activitystreams', - type: 'OrderedCollectionPage', - id: collection_url(page), - prev: page > 1 ? collection_url(page - 1) : nil, - next: page < resources.total_pages ? collection_url(page + 1) : nil, - partOf: collection_url, - orderedItems: serialized - } - end - - def represent_pagination_index(resources) - { - :@context => 'https://www.w3.org/ns/activitystreams', - type: 'OrderedCollection', - id: collection_url, - totalItems: resources.total_count, - first: collection_url(1), - last: collection_url(resources.total_pages) - } - end - - def collection_url(page = nil) - uri = URI.parse(paginator.request.url) - uri.query ||= "" - parts = uri.query.split('&').reject { |part| part =~ /^page=/ } - parts << "page=#{page}" if page - uri.query = parts.join('&') - uri.to_s.sub(/\?$/, '') - end - end -end diff --git a/app/serializers/activity_pub/actor_serializer.rb b/app/serializers/activity_pub/actor_serializer.rb new file mode 100644 index 00000000000..14ab43666ec --- /dev/null +++ b/app/serializers/activity_pub/actor_serializer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ActivityPub + # Serializer for the `Actor` ActivityStreams model. + # Reference: https://www.w3.org/TR/activitystreams-core/#actors + class ActorSerializer < ObjectSerializer + MissingOutboxError = Class.new(StandardError) + + def represent(resource, opts = {}, entity_class = nil) + raise MissingInboxError, 'Please provide an :inbox option for this actor' unless opts[:inbox].present? + raise MissingOutboxError, 'Please provide an :outbox option for this actor' unless opts[:outbox].present? + + super + end + + private + + def validate_response(response, _opts) + unless response[:id].present? + raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field." + end + + unless response[:type].present? + raise MissingTypeError, "The serializer does not provide the mandatory 'type' field." + end + + response + end + + def wrap(serialized, opts) + parent_value = super(serialized, opts) + + { + inbox: opts[:inbox], + outbox: opts[:outbox] + }.merge(parent_value) + end + end +end diff --git a/app/serializers/activity_pub/collection_serializer.rb b/app/serializers/activity_pub/collection_serializer.rb new file mode 100644 index 00000000000..16c78eb1b7d --- /dev/null +++ b/app/serializers/activity_pub/collection_serializer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ActivityPub + # Serializer for the `Collection` ActivityStreams model. + # Reference: https://www.w3.org/TR/activitystreams-core/#collections + class CollectionSerializer < ::BaseSerializer + include WithPagination + + NotPaginatedError = Class.new(StandardError) + + alias_method :base_represent, :represent + + def represent(resources, opts = {}) + unless respond_to?(:paginated?) && paginated? + raise NotPaginatedError, 'Pass #with_pagination to the serializer or use ActivityPub::ObjectSerializer instead' + end + + response = if paginator.params['page'].present? + represent_page(resources, paginator.params['page'].to_i, opts) + else + represent_pagination_index(resources) + end + + HashWithIndifferentAccess.new(response) + end + + private + + def represent_page(resources, page, opts) + resources = paginator.paginate(resources) + opts[:page] = page + serialized = base_represent(resources, opts) + + { + :@context => 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollectionPage', + id: collection_url(page), + prev: page > 1 ? collection_url(page - 1) : nil, + next: page < resources.total_pages ? collection_url(page + 1) : nil, + partOf: collection_url, + orderedItems: serialized + } + end + + def represent_pagination_index(resources) + paginator.params['page'] = 1 + resources = paginator.paginate(resources) + + { + :@context => 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: collection_url, + totalItems: resources.total_count, + first: collection_url(1), + last: collection_url(resources.total_pages) + } + end + + def collection_url(page = nil) + uri = URI.parse(paginator.request.url) + uri.query ||= "" + parts = uri.query.split('&').reject { |part| part =~ /^page=/ } + parts << "page=#{page}" if page + uri.query = parts.join('&') + uri.to_s.sub(/\?$/, '') + end + end +end diff --git a/app/serializers/activity_pub/object_serializer.rb b/app/serializers/activity_pub/object_serializer.rb new file mode 100644 index 00000000000..cdcef59cc41 --- /dev/null +++ b/app/serializers/activity_pub/object_serializer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ActivityPub + # Serializer for the `Object` ActivityStreams model. + # Reference: https://www.w3.org/TR/activitystreams-core/#object + class ObjectSerializer < ::BaseSerializer + MissingIdentifierError = Class.new(StandardError) + MissingTypeError = Class.new(StandardError) + + def represent(resource, opts = {}, entity_class = nil) + serialized = super(resource, opts, entity_class) + response = wrap(serialized, opts) + + validate_response(HashWithIndifferentAccess.new(response), opts) + end + + private + + def wrap(serialized, _opts) + { :@context => "https://www.w3.org/ns/activitystreams" }.merge(serialized) + end + + def validate_response(response, _opts) + unless response[:id].present? + raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field." + end + + unless response[:type].present? + raise MissingTypeError, "The serializer does not provide the mandatory 'type' field." + end + + response + end + end +end diff --git a/app/serializers/activity_pub/publish_release_activity_serializer.rb b/app/serializers/activity_pub/publish_release_activity_serializer.rb new file mode 100644 index 00000000000..b70ff470af5 --- /dev/null +++ b/app/serializers/activity_pub/publish_release_activity_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivityPub + class PublishReleaseActivitySerializer < ActivitySerializer + entity ReleaseEntity + end +end diff --git a/app/serializers/activity_pub/releases_actor_serializer.rb b/app/serializers/activity_pub/releases_actor_serializer.rb index 5bae83f2dc7..f4b33e25393 100644 --- a/app/serializers/activity_pub/releases_actor_serializer.rb +++ b/app/serializers/activity_pub/releases_actor_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActivityPub - class ReleasesActorSerializer < ActivityStreamsSerializer + class ReleasesActorSerializer < ActorSerializer entity ReleasesActorEntity end end diff --git a/app/serializers/activity_pub/releases_outbox_serializer.rb b/app/serializers/activity_pub/releases_outbox_serializer.rb index b6d4e633fb0..6087e713e64 100644 --- a/app/serializers/activity_pub/releases_outbox_serializer.rb +++ b/app/serializers/activity_pub/releases_outbox_serializer.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module ActivityPub - class ReleasesOutboxSerializer < ActivityStreamsSerializer - include WithPagination - + class ReleasesOutboxSerializer < CollectionSerializer entity ReleaseEntity end end diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index a654482b989..414517dc77e 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -17,12 +17,6 @@ module Admin admin_user_path(report.user) end - expose :plan do |report| - if Gitlab::CurrentSettings.current_application_settings.try(:should_check_namespace_plan?) - report.user.namespace&.actual_plan&.title - end - end - expose :verification_state do expose :email do |report| report.user.confirmed? @@ -44,6 +38,15 @@ module Admin end end + expose :phone_number, if: ->(report) { report.user.phone_number_validation.present? } do + expose :similar_records_count do |report| + report.user.phone_number_validation.similar_records.count + end + expose :phone_matches_link do |report| + phone_match_admin_user_path(report.user) if Gitlab.ee? + end + end + expose :past_closed_reports do |report| AbuseReportEntity.represent(report.past_closed_reports_for_user, only: [:created_at, :category, :report_path]) end @@ -82,3 +85,5 @@ module Admin end end end + +Admin::AbuseReportDetailsEntity.prepend_mod_with('Admin::AbuseReportDetailsEntity') diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 35063ceeb06..e166119f59d 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -155,11 +155,11 @@ class BuildDetailsEntity < Ci::JobEntity # We do not return the invalid_dependencies for all scenarios see https://gitlab.com/gitlab-org/gitlab/-/issues/287772#note_914406387 punctuation = invalid_dependencies.empty? ? '.' : ': ' _("This job could not start because it could not retrieve the needed artifacts%{punctuation}%{invalid_dependencies}") % - { invalid_dependencies: html_escape(invalid_dependencies), punctuation: punctuation } + { invalid_dependencies: ERB::Util.html_escape(invalid_dependencies), punctuation: punctuation } end def help_message(docs_url, troubleshooting_url) - html_escape(_("Learn more about <a href=\"#{docs_url}\">dependencies</a> and <a href=\"#{troubleshooting_url}\">common causes</a> of this error.</a>".html_safe)) + ERB::Util.html_escape(_("Learn more about <a href=\"#{docs_url}\">dependencies</a> and <a href=\"#{troubleshooting_url}\">common causes</a> of this error.</a>".html_safe)) end end diff --git a/app/serializers/ci/basic_variable_entity.rb b/app/serializers/ci/basic_variable_entity.rb index 210c01408a6..5907bf0f29c 100644 --- a/app/serializers/ci/basic_variable_entity.rb +++ b/app/serializers/ci/basic_variable_entity.rb @@ -5,6 +5,7 @@ module Ci expose :id expose :key expose :value + expose :description expose :variable_type expose :protected?, as: :protected diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb index e55f31a8376..b840f3acb88 100644 --- a/app/serializers/diffs_metadata_entity.rb +++ b/app/serializers/diffs_metadata_entity.rb @@ -15,6 +15,10 @@ class DiffsMetadataEntity < DiffsEntity presenter(options[:merge_request]).conflict_resolution_path end + # #cannot_be_merged? is generally indicative of conflicts, and is set via + # MergeRequests::MergeabilityCheckService. However, it can also indicate + # that either #has_no_commits? or #branch_missing? are true. + # expose :has_conflicts do |_, options| options[:merge_request].cannot_be_merged? end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 7d473f9ed89..f515fdede29 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -231,7 +231,7 @@ module Auth return if path.has_repository? return unless actions.include?('push') - ContainerRepository.find_or_create_from_path(path) + ContainerRepository.find_or_create_from_path!(path) end # Overridden in EE diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb index c9da889c536..7d202da96ce 100644 --- a/app/services/boards/base_item_move_service.rb +++ b/app/services/boards/base_item_move_service.rb @@ -84,20 +84,11 @@ module Boards end def remove_label_ids - label_ids = - if moving_to_list.movable? - moving_from_list.label_id - else - board_label_ids - end + label_ids = moving_to_list.movable? ? moving_from_list.label_id : [] Array(label_ids).compact end - def board_label_ids - ::Label.ids_on_board(board.id) - end - def move_params_from_list_position(position) if position == LIST_END_POSITION { move_before_id: moving_to_list_items_relation.reverse_order.pick(:id), move_after_id: nil } diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index 8fa438a76ce..39c27c04b8c 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -71,7 +71,6 @@ module BulkImports unless @remote_content_validated validate_content_type - validate_content_length @remote_content_validated = true end @@ -130,11 +129,12 @@ module BulkImports end def validate_url - ::Gitlab::UrlBlocker.validate!( + ::Gitlab::HTTP_V2::UrlBlocker.validate!( http_client.resource_url(relative_url), allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w[http https] + schemes: %w[http https], + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) end diff --git a/app/services/ci/cancel_pipeline_service.rb b/app/services/ci/cancel_pipeline_service.rb index 38053b13921..92eead3fdd1 100644 --- a/app/services/ci/cancel_pipeline_service.rb +++ b/app/services/ci/cancel_pipeline_service.rb @@ -10,17 +10,20 @@ module Ci # @cascade_to_children - if true cancels all related child pipelines for parent child pipelines # @auto_canceled_by_pipeline - store the pipeline_id of the pipeline that triggered cancellation # @execute_async - if true cancel the children asyncronously + # @safe_cancellation - if true only cancel interruptible:true jobs def initialize( pipeline:, current_user:, cascade_to_children: true, auto_canceled_by_pipeline: nil, - execute_async: true) + execute_async: true, + safe_cancellation: false) @pipeline = pipeline @current_user = current_user @cascade_to_children = cascade_to_children @auto_canceled_by_pipeline = auto_canceled_by_pipeline @execute_async = execute_async + @safe_cancellation = safe_cancellation end def execute @@ -42,13 +45,16 @@ module Ci log_pipeline_being_canceled pipeline.update_column(:auto_canceled_by_id, @auto_canceled_by_pipeline.id) if @auto_canceled_by_pipeline - cancel_jobs(pipeline.cancelable_statuses) - return ServiceResponse.success unless cascade_to_children? + if @safe_cancellation + # Only build and bridge (trigger) jobs can be interruptible. + # We do not cancel GenericCommitStatuses because they can't have the `interruptible` attribute. + cancel_jobs(pipeline.processables.cancelable.interruptible) + else + cancel_jobs(pipeline.cancelable_statuses) + end - # cancel any bridges that could spin up new child pipelines - cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable) - cancel_children + cancel_children if cascade_to_children? ServiceResponse.success end @@ -106,8 +112,15 @@ module Ci ) end - # For parent child-pipelines only (not multi-project) + # We don't handle the case when `cascade_to_children` is `true` and `safe_cancellation` is `true` + # because `safe_cancellation` is passed as `true` only when `cascade_to_children` is `false` + # from `CancelRedundantPipelinesService`. + # In the future, when "safe cancellation" is implemented as a regular cancellation feature, + # we need to handle this case. def cancel_children + cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable) + + # For parent child-pipelines only (not multi-project) pipeline.all_child_pipelines.each do |child_pipeline| if execute_async? ::Ci::CancelPipelineWorker.perform_async( diff --git a/app/services/ci/catalog/resources/versions/create_service.rb b/app/services/ci/catalog/resources/versions/create_service.rb index 863bad43271..9547db7bcf1 100644 --- a/app/services/ci/catalog/resources/versions/create_service.rb +++ b/app/services/ci/catalog/resources/versions/create_service.rb @@ -65,10 +65,12 @@ module Ci end def extract_metadata(blob) + component_name = components_project.extract_component_name(blob.path) + { - name: components_project.extract_component_name(blob.path), + name: component_name, inputs: components_project.extract_inputs(blob.data), - path: blob.path + path: "#{Settings.gitlab.host}/#{project.full_path}/#{component_name}@#{release.tag}" } end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 2231b1dd6bd..7d3e71b003e 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -116,14 +116,6 @@ module Ci private - def commit - @commit ||= project.commit(origin_sha || origin_ref) - end - - def sha - commit.try(:id) - end - def create_namespace_onboarding_action Onboarding::PipelineCreatedWorker.perform_async(project.namespace_id) end diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb index db8f61c81fa..ce4400e9f4f 100644 --- a/app/services/ci/create_web_ide_terminal_service.rb +++ b/app/services/ci/create_web_ide_terminal_service.rb @@ -52,7 +52,7 @@ module Ci ref: ref, sha: sha, tag: false, - before_sha: Gitlab::Git::BLANK_SHA + before_sha: Gitlab::Git::SHA1_BLANK_SHA ) end diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb index 224b2d96205..98469e82af3 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -23,7 +23,7 @@ module Ci pipelines = parent_and_child_pipelines(ids) Gitlab::OptimisticLocking.retry_lock(pipelines, name: 'cancel_pending_pipelines') do |cancelables| - auto_cancel_interruptible_pipelines(cancelables.ids) + auto_cancel_pipelines(cancelables.ids) end end end @@ -69,31 +69,66 @@ module Ci .base_and_descendants .alive_or_scheduled end - # rubocop: enable CodeReuse/ActiveRecord - def auto_cancel_interruptible_pipelines(pipeline_ids) + def legacy_auto_cancel_pipelines(pipeline_ids) ::Ci::Pipeline .id_in(pipeline_ids) - .with_only_interruptible_builds + .conservative_interruptible .each do |cancelable_pipeline| - Gitlab::AppLogger.info( - class: self.class.name, - message: "Pipeline #{pipeline.id} auto-canceling pipeline #{cancelable_pipeline.id}", - canceled_pipeline_id: cancelable_pipeline.id, - canceled_by_pipeline_id: pipeline.id, - canceled_by_pipeline_source: pipeline.source - ) - - # cascade_to_children not needed because we iterate through descendants here - ::Ci::CancelPipelineService.new( - pipeline: cancelable_pipeline, - current_user: nil, - auto_canceled_by_pipeline: pipeline, - cascade_to_children: false - ).force_execute + cancel_pipeline(cancelable_pipeline, safe_cancellation: false) end end + def auto_cancel_pipelines(pipeline_ids) + if Feature.disabled?(:ci_workflow_auto_cancel_on_new_commit, project) + return legacy_auto_cancel_pipelines(pipeline_ids) + end + + ::Ci::Pipeline + .id_in(pipeline_ids) + .each do |cancelable_pipeline| + case cancelable_pipeline.auto_cancel_on_new_commit + when 'none' + # no-op + when 'conservative' + next unless conservative_cancellable_pipeline_ids(pipeline_ids).include?(cancelable_pipeline.id) + + cancel_pipeline(cancelable_pipeline, safe_cancellation: false) + when 'interruptible' + cancel_pipeline(cancelable_pipeline, safe_cancellation: true) + else + raise ArgumentError, + "Unknown auto_cancel_on_new_commit value: #{cancelable_pipeline.auto_cancel_on_new_commit}" + end + end + end + + def conservative_cancellable_pipeline_ids(pipeline_ids) + strong_memoize_with(:conservative_cancellable_pipeline_ids, pipeline_ids) do + ::Ci::Pipeline.id_in(pipeline_ids).conservative_interruptible.ids + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def cancel_pipeline(cancelable_pipeline, safe_cancellation:) + Gitlab::AppLogger.info( + class: self.class.name, + message: "Pipeline #{pipeline.id} auto-canceling pipeline #{cancelable_pipeline.id}", + canceled_pipeline_id: cancelable_pipeline.id, + canceled_by_pipeline_id: pipeline.id, + canceled_by_pipeline_source: pipeline.source + ) + + # cascade_to_children not needed because we iterate through descendants here + ::Ci::CancelPipelineService.new( + pipeline: cancelable_pipeline, + current_user: nil, + auto_canceled_by_pipeline: pipeline, + cascade_to_children: false, + safe_cancellation: safe_cancellation + ).force_execute + end + def pipelines_created_after 3.days.ago end diff --git a/app/services/ci/runners/unregister_runner_manager_service.rb b/app/services/ci/runners/unregister_runner_manager_service.rb index ecf6aba09c7..9b3bd4a53e2 100644 --- a/app/services/ci/runners/unregister_runner_manager_service.rb +++ b/app/services/ci/runners/unregister_runner_manager_service.rb @@ -20,6 +20,8 @@ module Ci runner_manager = runner.runner_managers.find_by_system_xid!(system_id) runner_manager.destroy! + runner.clear_heartbeat if runner.runner_managers.empty? + ServiceResponse.success end diff --git a/app/services/ci/unlock_pipeline_service.rb b/app/services/ci/unlock_pipeline_service.rb index 88d4a8fd0be..bd42871ffbe 100644 --- a/app/services/ci/unlock_pipeline_service.rb +++ b/app/services/ci/unlock_pipeline_service.rb @@ -84,7 +84,7 @@ module Ci def unlock_job_artifacts start = Time.current - pipeline.builds.each_batch(of: BATCH_SIZE) do |builds| + builds_relation.each_batch(of: BATCH_SIZE) do |builds| # rubocop: disable CodeReuse/ActiveRecord Ci::JobArtifact.where(job_id: builds.pluck(:id)).each_batch(of: BATCH_SIZE) do |job_artifacts| unlocked_count = Ci::JobArtifact @@ -100,6 +100,16 @@ module Ci end end + # Removes the partition_id filter from the query until we get more data in the + # second partition. + def builds_relation + if Feature.enabled?(:disable_ci_partition_pruning, pipeline.project, type: :wip) + Ci::Build.in_pipelines(pipeline) + else + pipeline.builds + end + end + def unlock_pipeline_artifacts @unlocked_pipeline_artifacts_count = pipeline.pipeline_artifacts.update_all(locked: :unlocked) end diff --git a/app/services/click_house/sync_strategies/base_sync_strategy.rb b/app/services/click_house/sync_strategies/base_sync_strategy.rb new file mode 100644 index 00000000000..58c2161b83c --- /dev/null +++ b/app/services/click_house/sync_strategies/base_sync_strategy.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module ClickHouse + module SyncStrategies + class BaseSyncStrategy + include Gitlab::ExclusiveLeaseHelpers + include Gitlab::Utils::StrongMemoize + + # the job is scheduled every 3 minutes and we will allow maximum 2.5 minutes runtime + MAX_TTL = 2.5.minutes.to_i + MAX_RUNTIME = 120.seconds + BATCH_SIZE = 500 + INSERT_BATCH_SIZE = 5000 + + def execute + return { status: :disabled } unless enabled? + + metadata = { status: :processed } + + begin + # Prevent parallel jobs + in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do + loop { break unless next_batch } + + metadata.merge!(records_inserted: context.total_record_count, + reached_end_of_table: context.no_more_records?) + + if context.last_processed_id + ClickHouse::SyncCursor.update_cursor_for(model_class.table_name, + context.last_processed_id) + end + end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + # Skip retrying, just let the next worker to start after a few minutes + metadata = { status: :skipped } + end + + metadata + end + + private + + def enabled? + ClickHouse::Client.database_configured?(:main) + end + + def context + @context ||= ClickHouse::RecordSyncContext.new( + last_record_id: ClickHouse::SyncCursor.cursor_for(model_class.table_name), + max_records_per_batch: INSERT_BATCH_SIZE, + runtime_limiter: Analytics::CycleAnalytics::RuntimeLimiter.new(MAX_RUNTIME) + ) + end + + def last_id_in_postgresql + model_class.maximum(:id) + end + + strong_memoize_attr :last_id_in_postgresql + + def next_batch + context.new_batch! + + CsvBuilder::Gzip.new(process_batch(context), csv_mapping).render do |tempfile, rows_written| + unless rows_written == 0 + ClickHouse::Client.insert_csv(insert_query, File.open(tempfile.path), + :main) + end + end + + !(context.over_time? || context.no_more_records?) + end + + def process_batch(context) + Enumerator.new do |yielder| + has_more_data = false + batching_scope.each_batch(of: BATCH_SIZE) do |relation| + records = relation.select(projections).to_a + has_more_data = records.size == BATCH_SIZE + records.each do |row| + yielder << transform_row(row) + context.last_processed_id = row.id + + break if context.record_limit_reached? + end + + break if context.over_time? || context.record_limit_reached? || !has_more_data + end + + context.no_more_records! unless has_more_data + end + end + + def transform_row(row) + row + end + + # rubocop: disable CodeReuse/ActiveRecord -- because model here is dynamic and is passed by child class + def batching_scope + return model_class.none unless last_id_in_postgresql + + table = model_class.arel_table + + model_class + .where(table[:id].gt(context.last_record_id)) + .where(table[:id].lteq(last_id_in_postgresql)) + end + + # rubocop: enable CodeReuse/ActiveRecord + + def projections + raise NotImplementedError, "Subclasses must implement `projections`" + end + + def csv_mapping + raise NotImplementedError, "Subclasses must implement `csv_mapping`" + end + + def insert_query + raise NotImplementedError, "Subclasses must implement `insert_query`" + end + end + end +end diff --git a/app/services/click_house/sync_strategies/event_sync_strategy.rb b/app/services/click_house/sync_strategies/event_sync_strategy.rb new file mode 100644 index 00000000000..3e86e8c52bc --- /dev/null +++ b/app/services/click_house/sync_strategies/event_sync_strategy.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module ClickHouse + module SyncStrategies + class EventSyncStrategy < BaseSyncStrategy + # transforms the traversal_ids to a String: + # Example: group_id/subgroup_id/group_or_projectnamespace_id/ + PATH_COLUMN = <<~SQL + ( + CASE + WHEN project_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = (SELECT project_namespace_id FROM projects WHERE id = events.project_id LIMIT 1) LIMIT 1) + WHEN group_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = events.group_id LIMIT 1) + ELSE '' + END + ) AS path + SQL + + private + + def csv_mapping + { + id: :id, + path: :path, + author_id: :author_id, + target_id: :target_id, + target_type: :target_type, + action: :raw_action, + created_at: :casted_created_at, + updated_at: :casted_updated_at + } + end + + def projections + [ + :id, + PATH_COLUMN, + :author_id, + :target_id, + :target_type, + 'action AS raw_action', + 'EXTRACT(epoch FROM created_at) AS casted_created_at', + 'EXTRACT(epoch FROM updated_at) AS casted_updated_at' + ] + end + + def insert_query + <<~SQL.squish + INSERT INTO events (#{csv_mapping.keys.join(', ')}) + SETTINGS async_insert=1, wait_for_async_insert=1 FORMAT CSV + SQL + end + + def model_class + ::Event + end + + def enabled? + super && Feature.enabled?(:event_sync_worker_for_click_house) + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/base_service.rb b/app/services/cloud_seed/google_cloud/base_service.rb new file mode 100644 index 00000000000..e59031c5371 --- /dev/null +++ b/app/services/cloud_seed/google_cloud/base_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class BaseService < ::BaseService + protected + + def google_oauth2_token + @params[:google_oauth2_token] + end + + def gcp_project_id + @params[:gcp_project_id] + end + + def environment_name + @params[:environment_name] + end + + def google_api_client + @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil) + end + + def unique_gcp_project_ids + filter_params = { key: 'GCP_PROJECT_ID' } + @unique_gcp_project_ids ||= ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq + end + + def group_vars_by_environment(keys) + filtered_vars = project.variables.filter { |variable| keys.include? variable.key } + filtered_vars.each_with_object({}) do |variable, grouped| + grouped[variable.environment_scope] ||= {} + grouped[variable.environment_scope][variable.key] = variable.value + end + end + + def create_or_replace_project_vars(environment_scope, key, value, is_protected, is_masked = false) + change_params = { + variable_params: { + key: key, + value: value, + environment_scope: environment_scope, + protected: is_protected, + masked: is_masked + } + } + existing_variable = find_existing_variable(environment_scope, key) + + if existing_variable + change_params[:action] = :update + change_params[:variable] = existing_variable + else + change_params[:action] = :create + end + + ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute + end + + private + + def find_existing_variable(environment_scope, key) + filter_params = { key: key, filter: { environment_scope: environment_scope } } + ::Ci::VariablesFinder.new(project, filter_params).execute.first + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb b/app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb new file mode 100644 index 00000000000..8b967a2d551 --- /dev/null +++ b/app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + DEFAULT_REGION = 'us-east1' + + class CreateCloudsqlInstanceService < ::CloudSeed::GoogleCloud::BaseService + WORKER_INTERVAL = 30.seconds + + def execute + create_cloud_instance + trigger_instance_setup_worker + success + rescue Google::Apis::Error => err + error(err.message) + end + + private + + def create_cloud_instance + google_api_client.create_cloudsql_instance( + gcp_project_id, + instance_name, + root_password, + database_version, + region, + tier + ) + end + + def trigger_instance_setup_worker + ::GoogleCloud::CreateCloudsqlInstanceWorker.perform_in( + WORKER_INTERVAL, + current_user.id, + project.id, + { + 'google_oauth2_token': google_oauth2_token, + 'gcp_project_id': gcp_project_id, + 'instance_name': instance_name, + 'database_version': database_version, + 'environment_name': environment_name, + 'is_protected': protected? + } + ) + end + + def protected? + project.protected_for?(environment_name) + end + + def instance_name + # Generates an `instance_name` for the to-be-created Cloud SQL instance + # Example: `gitlab-34647-postgres-14-staging` + environment_alias = environment_name == '*' ? 'ALL' : environment_name + name = "gitlab-#{project.id}-#{database_version}-#{environment_alias}" + name.tr("_", "-").downcase + end + + def root_password + SecureRandom.hex(16) + end + + def database_version + params[:database_version] + end + + def region + region = ::Ci::VariablesFinder + .new(project, { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY, + environment_scope: environment_name }) + .execute.first + region&.value || DEFAULT_REGION + end + + def tier + params[:tier] + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/create_service_accounts_service.rb b/app/services/cloud_seed/google_cloud/create_service_accounts_service.rb new file mode 100644 index 00000000000..f15779cc14b --- /dev/null +++ b/app/services/cloud_seed/google_cloud/create_service_accounts_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class CreateServiceAccountsService < ::CloudSeed::GoogleCloud::BaseService + def execute + service_account = google_api_client.create_service_account(gcp_project_id, service_account_name, service_account_desc) + service_account_key = google_api_client.create_service_account_key(gcp_project_id, service_account.unique_id) + google_api_client.grant_service_account_roles(gcp_project_id, service_account.email) + + service_accounts_service.add_for_project( + environment_name, + service_account.project_id, + Gitlab::Json.dump(service_account), + Gitlab::Json.dump(service_account_key), + ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name) + ) + + ServiceResponse.success(message: _('Service account generated successfully'), payload: { + service_account: service_account, + service_account_key: service_account_key + }) + end + + private + + def service_accounts_service + GoogleCloud::ServiceAccountsService.new(project) + end + + def service_account_name + "GitLab :: #{project.name} :: #{environment_name}" + end + + def service_account_desc + "GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'" + end + end + end +end + +CloudSeed::GoogleCloud::CreateServiceAccountsService.prepend_mod diff --git a/app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb b/app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb new file mode 100644 index 00000000000..3ab5608c937 --- /dev/null +++ b/app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class EnableCloudRunService < ::CloudSeed::GoogleCloud::BaseService + def execute + gcp_project_ids = unique_gcp_project_ids + + if gcp_project_ids.empty? + error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.") + else + gcp_project_ids.each do |gcp_project_id| + google_api_client.enable_cloud_run(gcp_project_id) + google_api_client.enable_artifacts_registry(gcp_project_id) + google_api_client.enable_cloud_build(gcp_project_id) + end + + success({ gcp_project_ids: gcp_project_ids }) + end + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb b/app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb new file mode 100644 index 00000000000..d36f3ffd7c2 --- /dev/null +++ b/app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class EnableCloudsqlService < ::CloudSeed::GoogleCloud::BaseService + def execute + create_or_replace_project_vars(environment_name, 'GCP_PROJECT_ID', gcp_project_id, ci_var_protected?) + + unique_gcp_project_ids.each do |gcp_project_id| + google_api_client.enable_cloud_sql_admin(gcp_project_id) + google_api_client.enable_compute(gcp_project_id) + google_api_client.enable_service_networking(gcp_project_id) + end + + success({ gcp_project_ids: unique_gcp_project_ids }) + rescue Google::Apis::Error => err + error(err.message) + end + + private + + def ci_var_protected? + ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name) + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb b/app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb new file mode 100644 index 00000000000..865c11cba6a --- /dev/null +++ b/app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class EnableVisionAiService < ::CloudSeed::GoogleCloud::BaseService + def execute + gcp_project_ids = unique_gcp_project_ids + + if gcp_project_ids.empty? + error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.") + else + gcp_project_ids.each do |gcp_project_id| + google_api_client.enable_vision_api(gcp_project_id) + end + + success({ gcp_project_ids: gcp_project_ids }) + end + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb b/app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb new file mode 100644 index 00000000000..c02b3a87352 --- /dev/null +++ b/app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class FetchGoogleIpListService + include BaseServiceUtility + + GOOGLE_IP_RANGES_URL = 'https://www.gstatic.com/ipranges/cloud.json' + RESPONSE_BODY_LIMIT = 1.megabyte + EXPECTED_CONTENT_TYPE = 'application/json' + + IpListNotRetrievedError = Class.new(StandardError) + + def execute + # Prevent too many workers from hitting the same HTTP endpoint + if ::Gitlab::ApplicationRateLimiter.throttled?(:fetch_google_ip_list, scope: nil) + return error("#{self.class} was rate limited") + end + + subnets = fetch_and_update_cache! + + Gitlab::AppJsonLogger.info( + class: self.class.name, + message: 'Successfully retrieved Google IP list', + subnet_count: subnets.count + ) + + success({ subnets: subnets }) + rescue IpListNotRetrievedError => err + Gitlab::ErrorTracking.log_exception(err) + error('Google IP list not retrieved') + end + + private + + # Attempts to retrieve and parse the list of IPs from Google. Updates + # the internal cache so that the data is accessible. + # + # Returns an array of IPAddr objects consisting of subnets. + def fetch_and_update_cache! + parsed_response = fetch_google_ip_list + + parse_google_prefixes(parsed_response).tap do |subnets| + ::ObjectStorage::CDN::GoogleIpCache.update!(subnets) + end + end + + def fetch_google_ip_list + response = Gitlab::HTTP.get(GOOGLE_IP_RANGES_URL, follow_redirects: false, allow_local_requests: false) + + validate_response!(response) + + response.parsed_response + end + + def validate_response!(response) + raise IpListNotRetrievedError, "response was #{response.code}" unless response.code == 200 + raise IpListNotRetrievedError, "response was nil" unless response.body + + parsed_response = response.parsed_response + + unless response.content_type == EXPECTED_CONTENT_TYPE && parsed_response.is_a?(Hash) + raise IpListNotRetrievedError, "response was not JSON" + end + + if response.body&.bytesize.to_i > RESPONSE_BODY_LIMIT + raise IpListNotRetrievedError, "response was too large: #{response.body.bytesize}" + end + + prefixes = parsed_response['prefixes'] + + raise IpListNotRetrievedError, "JSON was type #{prefixes.class}, expected Array" unless prefixes.is_a?(Array) + raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if prefixes.empty? + + response.parsed_response + end + + def parse_google_prefixes(parsed_response) + ranges = parsed_response['prefixes'].map do |prefix| + ip_range = prefix['ipv4Prefix'] || prefix['ipv6Prefix'] + + next unless ip_range + + IPAddr.new(ip_range) + end.compact + + raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if ranges.empty? + + ranges + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb new file mode 100644 index 00000000000..11a644b3e9d --- /dev/null +++ b/app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class GcpRegionAddOrReplaceService < ::CloudSeed::GoogleCloud::BaseService + def execute(environment, region) + gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY + + change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } } + filter_params = { key: gcp_region_key, filter: { environment_scope: environment } } + + 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 +end diff --git a/app/services/cloud_seed/google_cloud/generate_pipeline_service.rb b/app/services/cloud_seed/google_cloud/generate_pipeline_service.rb new file mode 100644 index 00000000000..d8b45f301ec --- /dev/null +++ b/app/services/cloud_seed/google_cloud/generate_pipeline_service.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class GeneratePipelineService < ::CloudSeed::GoogleCloud::BaseService + ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN' + ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE' + ACTION_VISION_AI_PIPELINE = 'VISION_AI_PIPELINE' + + def execute + commit_attributes = generate_commit_attributes + create_branch_response = ::Branches::CreateService.new(project, current_user) + .execute(commit_attributes[:branch_name], project.default_branch) + + if create_branch_response[:status] == :error + return create_branch_response + end + + branch = create_branch_response[:branch] + + service = default_branch_gitlab_ci_yml.present? ? ::Files::UpdateService : ::Files::CreateService + + commit_response = service.new(project, current_user, commit_attributes).execute + + if commit_response[:status] == :error + return commit_response + end + + success({ branch_name: branch.name, commit: commit_response }) + end + + private + + def action + @params[:action] + end + + def generate_commit_attributes + case action + when ACTION_DEPLOY_TO_CLOUD_RUN + branch_name = "deploy-to-cloud-run-#{SecureRandom.hex(8)}" + { + commit_message: 'Enable Cloud Run deployments', + file_path: '.gitlab-ci.yml', + file_content: pipeline_content('gcp/cloud-run.gitlab-ci.yml'), + branch_name: branch_name, + start_branch: branch_name + } + when ACTION_DEPLOY_TO_CLOUD_STORAGE + branch_name = "deploy-to-cloud-storage-#{SecureRandom.hex(8)}" + { + commit_message: 'Enable Cloud Storage deployments', + file_path: '.gitlab-ci.yml', + file_content: pipeline_content('gcp/cloud-storage.gitlab-ci.yml'), + branch_name: branch_name, + start_branch: branch_name + } + when ACTION_VISION_AI_PIPELINE + branch_name = "vision-ai-pipeline-#{SecureRandom.hex(8)}" + { + commit_message: 'Enable Vision AI Pipeline', + file_path: '.gitlab-ci.yml', + file_content: pipeline_content('gcp/vision-ai.gitlab-ci.yml'), + branch_name: branch_name, + start_branch: branch_name + } + end + end + + def default_branch_gitlab_ci_yml + @default_branch_gitlab_ci_yml ||= project.ci_config_for(project.default_branch) + end + + def pipeline_content(include_path) + gitlab_ci_yml = ::Gitlab::Ci::Config::Yaml::Loader.new(default_branch_gitlab_ci_yml || '{}').load + + append_remote_include( + gitlab_ci_yml.content, + "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{include_path}" + ) + end + + def append_remote_include(gitlab_ci_yml, include_url) + stages = gitlab_ci_yml['stages'] || [] + gitlab_ci_yml['stages'] = if action == ACTION_VISION_AI_PIPELINE + (stages + %w[validate detect render]).uniq + else + (stages + %w[build test deploy]).uniq + end + + includes = gitlab_ci_yml['include'] || [] + includes = Array.wrap(includes) + includes << { 'remote' => include_url } + gitlab_ci_yml['include'] = includes.uniq + + gitlab_ci_yml.deep_stringify_keys.to_yaml + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb b/app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb new file mode 100644 index 00000000000..b037298c8cb --- /dev/null +++ b/app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class GetCloudsqlInstancesService < ::CloudSeed::GoogleCloud::BaseService + CLOUDSQL_KEYS = %w[GCP_PROJECT_ID GCP_CLOUDSQL_INSTANCE_NAME GCP_CLOUDSQL_VERSION].freeze + + def execute + group_vars_by_environment(CLOUDSQL_KEYS).map do |environment_scope, value| + { + ref: environment_scope, + gcp_project: value['GCP_PROJECT_ID'], + instance_name: value['GCP_CLOUDSQL_INSTANCE_NAME'], + version: value['GCP_CLOUDSQL_VERSION'] + } + end + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/service_accounts_service.rb b/app/services/cloud_seed/google_cloud/service_accounts_service.rb new file mode 100644 index 00000000000..4881c440c9c --- /dev/null +++ b/app/services/cloud_seed/google_cloud/service_accounts_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + ## + # GCP keys used to store Google Cloud Service Accounts + GCP_KEYS = %w[GCP_PROJECT_ID GCP_SERVICE_ACCOUNT GCP_SERVICE_ACCOUNT_KEY].freeze + + ## + # This service deals with GCP Service Accounts in GitLab + + class ServiceAccountsService < ::CloudSeed::GoogleCloud::BaseService + ## + # Find GCP Service Accounts in a GitLab project + # + # This method looks up GitLab project's CI vars + # and returns Google Cloud Service Accounts combinations + # aligning GitLab project and ref to GCP projects + + def find_for_project + group_vars_by_environment(GCP_KEYS).map do |environment_scope, value| + { + 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? + } + end + end + + def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected) + create_or_replace_project_vars( + ref, + 'GCP_PROJECT_ID', + gcp_project_id, + is_protected + ) + create_or_replace_project_vars( + ref, + 'GCP_SERVICE_ACCOUNT', + service_account, + is_protected + ) + create_or_replace_project_vars( + ref, + 'GCP_SERVICE_ACCOUNT_KEY', + service_account_key, + is_protected + ) + end + end + end +end diff --git a/app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb b/app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb new file mode 100644 index 00000000000..b8c160f0683 --- /dev/null +++ b/app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module CloudSeed + module GoogleCloud + class SetupCloudsqlInstanceService < ::CloudSeed::GoogleCloud::BaseService + INSTANCE_STATE_RUNNABLE = 'RUNNABLE' + OPERATION_STATE_DONE = 'DONE' + DEFAULT_DATABASE_NAME = 'main_db' + DEFAULT_DATABASE_USER = 'main_user' + + def execute + return error('Unauthorized user') unless Ability.allowed?(current_user, :admin_project_google_cloud, project) + + get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name) + + if get_instance_response.state != INSTANCE_STATE_RUNNABLE + return error("CloudSQL instance not RUNNABLE: #{Gitlab::Json.dump(get_instance_response)}") + end + + save_instance_ci_vars(get_instance_response) + + list_database_response = google_api_client.list_cloudsql_databases(gcp_project_id, instance_name) + list_user_response = google_api_client.list_cloudsql_users(gcp_project_id, instance_name) + + existing_database = list_database_response.items.find { |database| database.name == database_name } + existing_user = list_user_response.items.find { |user| user.name == username } + + if existing_database && existing_user + save_database_ci_vars + save_user_ci_vars(existing_user) + return success + end + + database_response = execute_database_setup(existing_database) + return database_response if database_response[:status] == :error + + save_database_ci_vars + + user_response = execute_user_setup(existing_user) + return user_response if user_response[:status] == :error + + save_user_ci_vars(existing_user) + + success + rescue Google::Apis::Error => err + error(message: Gitlab::Json.dump(err)) + end + + private + + def instance_name + @params[:instance_name] + end + + def database_version + @params[:database_version] + end + + def database_name + @params.fetch(:database_name, DEFAULT_DATABASE_NAME) + end + + def username + @params.fetch(:username, DEFAULT_DATABASE_USER) + end + + def password + @password ||= SecureRandom.hex(16) + end + + def save_ci_var(key, value, is_masked = false) + create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked) + end + + def save_instance_ci_vars(cloudsql_instance) + primary_ip_address = cloudsql_instance.ip_addresses.first.ip_address + connection_name = cloudsql_instance.connection_name + + save_ci_var('GCP_PROJECT_ID', gcp_project_id) + save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name) + save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name) + save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address) + save_ci_var('GCP_CLOUDSQL_VERSION', database_version) + end + + def save_database_ci_vars + save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name) + end + + def save_user_ci_vars(user_exists) + save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username) + save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', user_exists ? user_exists.password : password, true) + end + + def execute_database_setup(database_exists) + return success if database_exists + + database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name) + + if database_response.status != OPERATION_STATE_DONE + return error("Database creation failed: #{Gitlab::Json.dump(database_response)}") + end + + success + end + + def execute_user_setup(existing_user) + return success if existing_user + + user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password) + + if user_response.status != OPERATION_STATE_DONE + return error("User creation failed: #{Gitlab::Json.dump(user_response)}") + end + + success + end + end + end +end diff --git a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb index 7efa95739fb..4c3d059777a 100644 --- a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb +++ b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb @@ -59,7 +59,7 @@ module Clusters return unless project_entries - allowed_projects.where_full_path_in(project_entries.keys, use_includes: false).map do |project| + allowed_projects.where_full_path_in(project_entries.keys, preload_routes: false).map do |project| { project_id: project.id, config: user_access_as } end end @@ -70,7 +70,7 @@ module Clusters return unless group_entries - allowed_groups.where_full_path_in(group_entries.keys, use_includes: false).map do |group| + allowed_groups.where_full_path_in(group_entries.keys, preload_routes: false).map do |group| { group_id: group.id, config: user_access_as } end end diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index a54c4947b0b..f84793d869c 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -3,6 +3,9 @@ module Users module ParticipableService extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + SEARCH_LIMIT = 10 included do attr_reader :noteable @@ -25,6 +28,16 @@ module Users sorted(users) end + def filter_and_sort_users(users_relation) + if params[:search] + users_relation.gfm_autocomplete_search(params[:search]).limit(SEARCH_LIMIT).tap do |users| + preload_status(users) + end + else + sorted(users_relation) + end + end + def sorted(users) users.uniq.to_a.compact.sort_by(&:username).tap do |users| preload_status(users) @@ -34,8 +47,15 @@ module Users def groups return [] unless current_user - current_user.authorized_groups.with_route.sort_by(&:full_path) + relation = current_user.authorized_groups + + if params[:search] + relation.gfm_autocomplete_search(params[:search]).limit(SEARCH_LIMIT).to_a + else + relation.with_route.sort_by(&:full_path) + end end + strong_memoize_attr :groups def render_participants_as_hash(participants) participants.map { |participant| participant_as_hash(participant) } @@ -74,11 +94,14 @@ module Users end def group_counts - @group_counts ||= GroupMember - .of_groups(current_user.authorized_groups) + groups_for_count = params[:search] ? groups : current_user.authorized_groups + + GroupMember + .of_groups(groups_for_count) .non_request .count_users_by_group_id end + strong_memoize_attr :group_counts def preload_status(users) users.each { |u| lazy_user_availability(u) } diff --git a/app/services/draft_notes/destroy_service.rb b/app/services/draft_notes/destroy_service.rb index ddca0debb03..6c7b0dfdbd7 100644 --- a/app/services/draft_notes/destroy_service.rb +++ b/app/services/draft_notes/destroy_service.rb @@ -15,9 +15,11 @@ module DraftNotes private def clear_highlight_diffs_cache(drafts) - if drafts.any? { |draft| draft.diff_file&.unfolded? } - merge_request.diffs.clear_cache - end + merge_request.diffs.clear_cache if unfolded_drafts?(drafts) + end + + def unfolded_drafts?(drafts) + drafts.any? { |draft| draft.diff_file&.unfolded? } end end end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index b755f512772..1a4e691a059 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -100,8 +100,10 @@ class EventCreateService end end - def join_project(project, current_user) - create_event(project, current_user, :joined) + def join_source(source, current_user) + return unless source.is_a?(Project) + + create_event(source, current_user, :joined) end def leave_project(project, current_user) diff --git a/app/services/google_cloud/base_service.rb b/app/services/google_cloud/base_service.rb deleted file mode 100644 index 01aee2231c9..00000000000 --- a/app/services/google_cloud/base_service.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class BaseService < ::BaseService - protected - - def google_oauth2_token - @params[:google_oauth2_token] - end - - def gcp_project_id - @params[:gcp_project_id] - end - - def environment_name - @params[:environment_name] - end - - def google_api_client - @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil) - end - - def unique_gcp_project_ids - filter_params = { key: 'GCP_PROJECT_ID' } - @unique_gcp_project_ids ||= ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq - end - - def group_vars_by_environment(keys) - filtered_vars = project.variables.filter { |variable| keys.include? variable.key } - filtered_vars.each_with_object({}) do |variable, grouped| - grouped[variable.environment_scope] ||= {} - grouped[variable.environment_scope][variable.key] = variable.value - end - end - - def create_or_replace_project_vars(environment_scope, key, value, is_protected, is_masked = false) - change_params = { - variable_params: { - key: key, - value: value, - environment_scope: environment_scope, - protected: is_protected, - masked: is_masked - } - } - existing_variable = find_existing_variable(environment_scope, key) - - if existing_variable - change_params[:action] = :update - change_params[:variable] = existing_variable - else - change_params[:action] = :create - end - - ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute - end - - private - - def find_existing_variable(environment_scope, key) - filter_params = { key: key, filter: { environment_scope: environment_scope } } - ::Ci::VariablesFinder.new(project, filter_params).execute.first - end - end -end diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb deleted file mode 100644 index 9a1263f0796..00000000000 --- a/app/services/google_cloud/create_cloudsql_instance_service.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - DEFAULT_REGION = 'us-east1' - - class CreateCloudsqlInstanceService < ::GoogleCloud::BaseService - WORKER_INTERVAL = 30.seconds - - def execute - create_cloud_instance - trigger_instance_setup_worker - success - rescue Google::Apis::Error => err - error(err.message) - end - - private - - def create_cloud_instance - google_api_client.create_cloudsql_instance( - gcp_project_id, - instance_name, - root_password, - database_version, - region, - tier - ) - end - - def trigger_instance_setup_worker - GoogleCloud::CreateCloudsqlInstanceWorker.perform_in( - WORKER_INTERVAL, - current_user.id, - project.id, - { - 'google_oauth2_token': google_oauth2_token, - 'gcp_project_id': gcp_project_id, - 'instance_name': instance_name, - 'database_version': database_version, - 'environment_name': environment_name, - 'is_protected': protected? - } - ) - end - - def protected? - project.protected_for?(environment_name) - end - - def instance_name - # Generates an `instance_name` for the to-be-created Cloud SQL instance - # Example: `gitlab-34647-postgres-14-staging` - environment_alias = environment_name == '*' ? 'ALL' : environment_name - name = "gitlab-#{project.id}-#{database_version}-#{environment_alias}" - name.tr("_", "-").downcase - end - - def root_password - SecureRandom.hex(16) - end - - def database_version - params[:database_version] - end - - def region - region = ::Ci::VariablesFinder - .new(project, { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY, - environment_scope: environment_name }) - .execute.first - region&.value || DEFAULT_REGION - end - - def tier - params[:tier] - end - end -end diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb deleted file mode 100644 index ca0aa7c91df..00000000000 --- a/app/services/google_cloud/create_service_accounts_service.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class CreateServiceAccountsService < ::GoogleCloud::BaseService - def execute - service_account = google_api_client.create_service_account(gcp_project_id, service_account_name, service_account_desc) - service_account_key = google_api_client.create_service_account_key(gcp_project_id, service_account.unique_id) - google_api_client.grant_service_account_roles(gcp_project_id, service_account.email) - - service_accounts_service.add_for_project( - environment_name, - service_account.project_id, - Gitlab::Json.dump(service_account), - Gitlab::Json.dump(service_account_key), - ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name) - ) - - ServiceResponse.success(message: _('Service account generated successfully'), payload: { - service_account: service_account, - service_account_key: service_account_key - }) - end - - private - - def service_accounts_service - GoogleCloud::ServiceAccountsService.new(project) - end - - def service_account_name - "GitLab :: #{project.name} :: #{environment_name}" - end - - def service_account_desc - "GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'" - end - end -end - -GoogleCloud::CreateServiceAccountsService.prepend_mod diff --git a/app/services/google_cloud/enable_cloud_run_service.rb b/app/services/google_cloud/enable_cloud_run_service.rb deleted file mode 100644 index 4fd92f423c5..00000000000 --- a/app/services/google_cloud/enable_cloud_run_service.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class EnableCloudRunService < ::GoogleCloud::BaseService - def execute - gcp_project_ids = unique_gcp_project_ids - - if gcp_project_ids.empty? - error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.") - else - gcp_project_ids.each do |gcp_project_id| - google_api_client.enable_cloud_run(gcp_project_id) - google_api_client.enable_artifacts_registry(gcp_project_id) - google_api_client.enable_cloud_build(gcp_project_id) - end - - success({ gcp_project_ids: gcp_project_ids }) - end - end - end -end diff --git a/app/services/google_cloud/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb deleted file mode 100644 index 911cccca5ca..00000000000 --- a/app/services/google_cloud/enable_cloudsql_service.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class EnableCloudsqlService < ::GoogleCloud::BaseService - def execute - create_or_replace_project_vars(environment_name, 'GCP_PROJECT_ID', gcp_project_id, ci_var_protected?) - - unique_gcp_project_ids.each do |gcp_project_id| - google_api_client.enable_cloud_sql_admin(gcp_project_id) - google_api_client.enable_compute(gcp_project_id) - google_api_client.enable_service_networking(gcp_project_id) - end - - success({ gcp_project_ids: unique_gcp_project_ids }) - rescue Google::Apis::Error => err - error(err.message) - end - - private - - def ci_var_protected? - ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name) - end - end -end diff --git a/app/services/google_cloud/enable_vision_ai_service.rb b/app/services/google_cloud/enable_vision_ai_service.rb deleted file mode 100644 index f7adea706ed..00000000000 --- a/app/services/google_cloud/enable_vision_ai_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class EnableVisionAiService < ::GoogleCloud::BaseService - def execute - gcp_project_ids = unique_gcp_project_ids - - if gcp_project_ids.empty? - error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.") - else - gcp_project_ids.each do |gcp_project_id| - google_api_client.enable_vision_api(gcp_project_id) - end - - success({ gcp_project_ids: gcp_project_ids }) - end - end - end -end diff --git a/app/services/google_cloud/fetch_google_ip_list_service.rb b/app/services/google_cloud/fetch_google_ip_list_service.rb deleted file mode 100644 index 54af841d002..00000000000 --- a/app/services/google_cloud/fetch_google_ip_list_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class FetchGoogleIpListService - include BaseServiceUtility - - GOOGLE_IP_RANGES_URL = 'https://www.gstatic.com/ipranges/cloud.json' - RESPONSE_BODY_LIMIT = 1.megabyte - EXPECTED_CONTENT_TYPE = 'application/json' - - IpListNotRetrievedError = Class.new(StandardError) - - def execute - # Prevent too many workers from hitting the same HTTP endpoint - if ::Gitlab::ApplicationRateLimiter.throttled?(:fetch_google_ip_list, scope: nil) - return error("#{self.class} was rate limited") - end - - subnets = fetch_and_update_cache! - - Gitlab::AppJsonLogger.info( - class: self.class.name, - message: 'Successfully retrieved Google IP list', - subnet_count: subnets.count - ) - - success({ subnets: subnets }) - rescue IpListNotRetrievedError => err - Gitlab::ErrorTracking.log_exception(err) - error('Google IP list not retrieved') - end - - private - - # Attempts to retrieve and parse the list of IPs from Google. Updates - # the internal cache so that the data is accessible. - # - # Returns an array of IPAddr objects consisting of subnets. - def fetch_and_update_cache! - parsed_response = fetch_google_ip_list - - parse_google_prefixes(parsed_response).tap do |subnets| - ::ObjectStorage::CDN::GoogleIpCache.update!(subnets) - end - end - - def fetch_google_ip_list - response = Gitlab::HTTP.get(GOOGLE_IP_RANGES_URL, follow_redirects: false, allow_local_requests: false) - - validate_response!(response) - - response.parsed_response - end - - def validate_response!(response) - raise IpListNotRetrievedError, "response was #{response.code}" unless response.code == 200 - raise IpListNotRetrievedError, "response was nil" unless response.body - - parsed_response = response.parsed_response - - unless response.content_type == EXPECTED_CONTENT_TYPE && parsed_response.is_a?(Hash) - raise IpListNotRetrievedError, "response was not JSON" - end - - if response.body&.bytesize.to_i > RESPONSE_BODY_LIMIT - raise IpListNotRetrievedError, "response was too large: #{response.body.bytesize}" - end - - prefixes = parsed_response['prefixes'] - - raise IpListNotRetrievedError, "JSON was type #{prefixes.class}, expected Array" unless prefixes.is_a?(Array) - raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if prefixes.empty? - - response.parsed_response - end - - def parse_google_prefixes(parsed_response) - ranges = parsed_response['prefixes'].map do |prefix| - ip_range = prefix['ipv4Prefix'] || prefix['ipv6Prefix'] - - next unless ip_range - - IPAddr.new(ip_range) - end.compact - - raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if ranges.empty? - - ranges - 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 deleted file mode 100644 index f79df707a08..00000000000 --- a/app/services/google_cloud/gcp_region_add_or_replace_service.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class GcpRegionAddOrReplaceService < ::GoogleCloud::BaseService - def execute(environment, region) - gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY - - change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } } - filter_params = { key: gcp_region_key, filter: { environment_scope: environment } } - - 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/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb deleted file mode 100644 index 97d008db76b..00000000000 --- a/app/services/google_cloud/generate_pipeline_service.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class GeneratePipelineService < ::GoogleCloud::BaseService - ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN' - ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE' - ACTION_VISION_AI_PIPELINE = 'VISION_AI_PIPELINE' - - def execute - commit_attributes = generate_commit_attributes - create_branch_response = ::Branches::CreateService.new(project, current_user) - .execute(commit_attributes[:branch_name], project.default_branch) - - if create_branch_response[:status] == :error - return create_branch_response - end - - branch = create_branch_response[:branch] - - service = default_branch_gitlab_ci_yml.present? ? ::Files::UpdateService : ::Files::CreateService - - commit_response = service.new(project, current_user, commit_attributes).execute - - if commit_response[:status] == :error - return commit_response - end - - success({ branch_name: branch.name, commit: commit_response }) - end - - private - - def action - @params[:action] - end - - def generate_commit_attributes - case action - when ACTION_DEPLOY_TO_CLOUD_RUN - branch_name = "deploy-to-cloud-run-#{SecureRandom.hex(8)}" - { - commit_message: 'Enable Cloud Run deployments', - file_path: '.gitlab-ci.yml', - file_content: pipeline_content('gcp/cloud-run.gitlab-ci.yml'), - branch_name: branch_name, - start_branch: branch_name - } - when ACTION_DEPLOY_TO_CLOUD_STORAGE - branch_name = "deploy-to-cloud-storage-#{SecureRandom.hex(8)}" - { - commit_message: 'Enable Cloud Storage deployments', - file_path: '.gitlab-ci.yml', - file_content: pipeline_content('gcp/cloud-storage.gitlab-ci.yml'), - branch_name: branch_name, - start_branch: branch_name - } - when ACTION_VISION_AI_PIPELINE - branch_name = "vision-ai-pipeline-#{SecureRandom.hex(8)}" - { - commit_message: 'Enable Vision AI Pipeline', - file_path: '.gitlab-ci.yml', - file_content: pipeline_content('gcp/vision-ai.gitlab-ci.yml'), - branch_name: branch_name, - start_branch: branch_name - } - end - end - - def default_branch_gitlab_ci_yml - @default_branch_gitlab_ci_yml ||= project.ci_config_for(project.default_branch) - end - - def pipeline_content(include_path) - gitlab_ci_yml = ::Gitlab::Ci::Config::Yaml::Loader.new(default_branch_gitlab_ci_yml || '{}').load - - append_remote_include( - gitlab_ci_yml.content, - "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{include_path}" - ) - end - - def append_remote_include(gitlab_ci_yml, include_url) - stages = gitlab_ci_yml['stages'] || [] - gitlab_ci_yml['stages'] = if action == ACTION_VISION_AI_PIPELINE - (stages + %w[validate detect render]).uniq - else - (stages + %w[build test deploy]).uniq - end - - includes = gitlab_ci_yml['include'] || [] - includes = Array.wrap(includes) - includes << { 'remote' => include_url } - gitlab_ci_yml['include'] = includes.uniq - - gitlab_ci_yml.deep_stringify_keys.to_yaml - end - end -end diff --git a/app/services/google_cloud/get_cloudsql_instances_service.rb b/app/services/google_cloud/get_cloudsql_instances_service.rb deleted file mode 100644 index 701e83d556d..00000000000 --- a/app/services/google_cloud/get_cloudsql_instances_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class GetCloudsqlInstancesService < ::GoogleCloud::BaseService - CLOUDSQL_KEYS = %w[GCP_PROJECT_ID GCP_CLOUDSQL_INSTANCE_NAME GCP_CLOUDSQL_VERSION].freeze - - def execute - group_vars_by_environment(CLOUDSQL_KEYS).map do |environment_scope, value| - { - ref: environment_scope, - gcp_project: value['GCP_PROJECT_ID'], - instance_name: value['GCP_CLOUDSQL_INSTANCE_NAME'], - version: value['GCP_CLOUDSQL_VERSION'] - } - end - end - end -end diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb deleted file mode 100644 index e90fd112e2e..00000000000 --- a/app/services/google_cloud/service_accounts_service.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - ## - # GCP keys used to store Google Cloud Service Accounts - GCP_KEYS = %w[GCP_PROJECT_ID GCP_SERVICE_ACCOUNT GCP_SERVICE_ACCOUNT_KEY].freeze - - ## - # This service deals with GCP Service Accounts in GitLab - - class ServiceAccountsService < ::GoogleCloud::BaseService - ## - # Find GCP Service Accounts in a GitLab project - # - # This method looks up GitLab project's CI vars - # and returns Google Cloud Service Accounts combinations - # aligning GitLab project and ref to GCP projects - - def find_for_project - group_vars_by_environment(GCP_KEYS).map do |environment_scope, value| - { - 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? - } - end - end - - def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected) - create_or_replace_project_vars( - ref, - 'GCP_PROJECT_ID', - gcp_project_id, - is_protected - ) - create_or_replace_project_vars( - ref, - 'GCP_SERVICE_ACCOUNT', - service_account, - is_protected - ) - create_or_replace_project_vars( - ref, - 'GCP_SERVICE_ACCOUNT_KEY', - service_account_key, - is_protected - ) - end - end -end diff --git a/app/services/google_cloud/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb deleted file mode 100644 index 40184b927ad..00000000000 --- a/app/services/google_cloud/setup_cloudsql_instance_service.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -module GoogleCloud - class SetupCloudsqlInstanceService < ::GoogleCloud::BaseService - INSTANCE_STATE_RUNNABLE = 'RUNNABLE' - OPERATION_STATE_DONE = 'DONE' - DEFAULT_DATABASE_NAME = 'main_db' - DEFAULT_DATABASE_USER = 'main_user' - - def execute - return error('Unauthorized user') unless Ability.allowed?(current_user, :admin_project_google_cloud, project) - - get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name) - - if get_instance_response.state != INSTANCE_STATE_RUNNABLE - return error("CloudSQL instance not RUNNABLE: #{Gitlab::Json.dump(get_instance_response)}") - end - - save_instance_ci_vars(get_instance_response) - - list_database_response = google_api_client.list_cloudsql_databases(gcp_project_id, instance_name) - list_user_response = google_api_client.list_cloudsql_users(gcp_project_id, instance_name) - - existing_database = list_database_response.items.find { |database| database.name == database_name } - existing_user = list_user_response.items.find { |user| user.name == username } - - if existing_database && existing_user - save_database_ci_vars - save_user_ci_vars(existing_user) - return success - end - - database_response = execute_database_setup(existing_database) - return database_response if database_response[:status] == :error - - save_database_ci_vars - - user_response = execute_user_setup(existing_user) - return user_response if user_response[:status] == :error - - save_user_ci_vars(existing_user) - - success - rescue Google::Apis::Error => err - error(message: Gitlab::Json.dump(err)) - end - - private - - def instance_name - @params[:instance_name] - end - - def database_version - @params[:database_version] - end - - def database_name - @params.fetch(:database_name, DEFAULT_DATABASE_NAME) - end - - def username - @params.fetch(:username, DEFAULT_DATABASE_USER) - end - - def password - @password ||= SecureRandom.hex(16) - end - - def save_ci_var(key, value, is_masked = false) - create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked) - end - - def save_instance_ci_vars(cloudsql_instance) - primary_ip_address = cloudsql_instance.ip_addresses.first.ip_address - connection_name = cloudsql_instance.connection_name - - save_ci_var('GCP_PROJECT_ID', gcp_project_id) - save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name) - save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name) - save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address) - save_ci_var('GCP_CLOUDSQL_VERSION', database_version) - end - - def save_database_ci_vars - save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name) - end - - def save_user_ci_vars(user_exists) - save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username) - save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', user_exists ? user_exists.password : password, true) - end - - def execute_database_setup(database_exists) - return success if database_exists - - database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name) - - if database_response.status != OPERATION_STATE_DONE - return error("Database creation failed: #{Gitlab::Json.dump(database_response)}") - end - - success - end - - def execute_user_setup(existing_user) - return success if existing_user - - user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password) - - if user_response.status != OPERATION_STATE_DONE - return error("User creation failed: #{Gitlab::Json.dump(user_response)}") - end - - success - end - end -end diff --git a/app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb b/app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb new file mode 100644 index 00000000000..c9afa8609f9 --- /dev/null +++ b/app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module GoogleCloudPlatform + module ArtifactRegistry + class ListDockerImagesService < BaseProjectService + def execute(page_token: nil) + return ServiceResponse.error(message: "Access denied") unless allowed? + + ServiceResponse.success(payload: client.list_docker_images(page_token: page_token)) + end + + private + + def allowed? + can?(current_user, :read_container_image, project) + end + + def client + ::Integrations::GoogleCloudPlatform::ArtifactRegistry::Client.new( + project: project, + user: current_user, + gcp_project_id: gcp_project_id, + gcp_location: gcp_location, + gcp_repository: gcp_repository, + gcp_wlif: gcp_wlif + ) + end + + def gcp_project_id + params[:gcp_project_id] + end + + def gcp_location + params[:gcp_location] + end + + def gcp_repository + params[:gcp_repository] + end + + def gcp_wlif + params[:gcp_wlif] + end + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 21d3c6499a0..06c6560f0fe 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -92,9 +92,32 @@ module Groups end end + unless organization_setting_valid? + # We are unsetting this here to match behavior of invalid parent_id above and protect against possible + # committing to the database of a value that isn't allowed. + @group.organization = nil + message = s_("CreateGroup|You don't have permission to create a group in the provided organization.") + @group.errors.add(:organization_id, message) + + return false + end + true end + def organization_setting_valid? + # we check for the params presence explicitly since: + # 1. We have a default organization_id at db level set and organization exists and may not have the entry + # in organization_users table to allow authorization. This shouldn't be the case longterm as we + # plan on populating organization_users correctly. + # 2. We shouldn't need to check if this is allowed if the user didn't try to set it themselves. i.e. + # provided in the params + return true if params[:organization_id].blank? + return true if @group.organization.blank? + + can?(current_user, :create_group, @group.organization) + end + def can_use_visibility_level? unless Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level) deny_visibility_level(@group) diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb index 7b68b435f14..ae1a917f022 100644 --- a/app/services/groups/participants_service.rb +++ b/app/services/groups/participants_service.rb @@ -29,7 +29,9 @@ module Groups def group_hierarchy_users return [] unless group - sorted(Autocomplete::GroupUsersFinder.new(group: group).execute) + relation = Autocomplete::GroupUsersFinder.new(group: group).execute + + filter_and_sort_users(relation) end end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 79557dae14a..9fc1a05476e 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -236,7 +236,7 @@ module Groups def ensure_ownership return if @new_parent_group - return unless @group.owners.empty? + return unless @group.all_owner_members.empty? add_owner_on_transferred_group end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index d91e09d212a..a6ef8c8743b 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -29,6 +29,8 @@ module Groups handle_namespace_settings + handle_hierarchy_cache_update + group.assign_attributes(params) begin @@ -46,6 +48,28 @@ module Groups private + def handle_hierarchy_cache_update + return unless params.key?(:enable_namespace_descendants_cache) + + enabled = Gitlab::Utils.to_boolean(params.delete(:enable_namespace_descendants_cache)) + + return unless Feature.enabled?(:group_hierarchy_optimization, group, type: :beta) + + if enabled + return if group.namespace_descendants + + params[:namespace_descendants_attributes] = { + traversal_ids: group.traversal_ids, + all_project_ids: [], + self_and_descendant_group_ids: [] + } + else + return unless group.namespace_descendants + + params[:namespace_descendants_attributes] = { id: group.id, _destroy: true } + end + end + def valid_path_change? return true unless group.packages_feature_enabled? return true if params[:path].blank? diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb index e628e88eaa9..d8f39d7b963 100644 --- a/app/services/import/bitbucket_server_service.rb +++ b/app/services/import/bitbucket_server_service.rb @@ -84,11 +84,12 @@ module Import end def blocked_url? - Gitlab::UrlBlocker.blocked_url?( + Gitlab::HTTP_V2::UrlBlocker.blocked_url?( url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w[http https] + schemes: %w[http https], + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) end diff --git a/app/services/import/fogbugz_service.rb b/app/services/import/fogbugz_service.rb index 2f63e4e6fb7..52d9cb77c0a 100644 --- a/app/services/import/fogbugz_service.rb +++ b/app/services/import/fogbugz_service.rb @@ -84,11 +84,12 @@ module Import end def blocked_url?(url) - Gitlab::UrlBlocker.blocked_url?( + Gitlab::HTTP_V2::UrlBlocker.blocked_url?( url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w[http https] + schemes: %w[http https], + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index a96bfd74cd0..ffd26e2aaca 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -5,6 +5,8 @@ module Import include ActiveSupport::NumberHelper include Gitlab::Utils::StrongMemoize + COLLAB_IMPORT_SCOPES = %w[admin:org read:org].freeze + attr_accessor :client attr_reader :params, :current_user @@ -12,6 +14,9 @@ module Import context_error = validate_context return context_error if context_error + scope_error = validate_collaborators_import_scope + return scope_error if scope_error + project = create_project(access_params, provider) track_access_level('github') @@ -87,16 +92,33 @@ module Import end def blocked_url? - Gitlab::UrlBlocker.blocked_url?( + Gitlab::HTTP_V2::UrlBlocker.blocked_url?( url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w[http https] + schemes: %w[http https], + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) end private + def validate_collaborators_import_scope + collaborators_import = params.dig(:optional_stages, :collaborators_import) + # A value for `collaborators_import` may not be included in POST params + # and the default value is `true` + return unless collaborators_import == true || collaborators_import.nil? + + # We need to call `#repo` to ensure the `#last_response` from the client has the headers we need. + repo + scopes = client.octokit.last_response.headers["x-oauth-scopes"] + scopes = scopes.split(',').map(&:strip) + + return if (scopes & COLLAB_IMPORT_SCOPES).any? + + log_and_return_error('Invalid scope', _('Your GitHub access token does not have the correct scope to import collaborators.'), :unprocessable_entity) + end + def validate_context if blocked_url? log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request) @@ -139,7 +161,8 @@ module Import .new(project) .write( timeout_strategy: params[:timeout_strategy] || ProjectImportData::PESSIMISTIC_TIMEOUT, - optional_stages: params[:optional_stages] + optional_stages: params[:optional_stages], + extended_events: Feature.enabled?(:github_import_extended_events, current_user) ) end end diff --git a/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb b/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb deleted file mode 100644 index 82bf9a41ae7..00000000000 --- a/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module Integrations - module GoogleCloudPlatform - module ArtifactRegistry - class ListDockerImagesService < BaseProjectService - def execute(page_token: nil) - return ServiceResponse.error(message: "Access denied") unless allowed? - - ServiceResponse.success(payload: client.list_docker_images(page_token: page_token)) - end - - private - - def allowed? - can?(current_user, :read_container_image, project) - end - - def client - ::Integrations::GoogleCloudPlatform::ArtifactRegistry::Client.new( - project: project, - user: current_user, - gcp_project_id: gcp_project_id, - gcp_location: gcp_location, - gcp_repository: gcp_repository, - gcp_wlif: gcp_wlif - ) - end - - def gcp_project_id - params[:gcp_project_id] - end - - def gcp_location - params[:gcp_location] - end - - def gcp_repository - params[:gcp_repository] - end - - def gcp_wlif - params[:gcp_wlif] - end - end - end - end -end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index db28be864a7..a0fa1616f7b 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -2,10 +2,11 @@ module Issuable class CommonSystemNotesService < ::BaseProjectService - attr_reader :issuable + attr_reader :issuable, :is_update def execute(issuable, old_labels: [], old_milestone: nil, is_update: true) @issuable = issuable + @is_update = is_update # We disable touch so that created system notes do not update # the noteable's updated_at field @@ -17,10 +18,10 @@ module Issuable handle_description_change_note - handle_time_tracking_note if issuable.is_a?(TimeTrackable) create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') end + handle_time_tracking_note if issuable.is_a?(TimeTrackable) handle_start_date_or_due_date_change_note create_milestone_change_event(old_milestone) if issuable.previous_changes.include?('milestone_id') create_labels_note(old_labels) if old_labels && issuable.labels != old_labels @@ -37,13 +38,11 @@ module Issuable end def handle_time_tracking_note - if issuable.previous_changes.include?('time_estimate') - create_time_estimate_note - end + estimate_updated = is_update && issuable.previous_changes.include?('time_estimate') + estimate_set = !is_update && issuable.time_estimate != 0 - if issuable.time_spent? - create_time_spent_note - end + create_time_estimate_note if estimate_updated || estimate_set + create_time_spent_note if issuable.time_spent? end def handle_description_change_note diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 27cfaef2db2..0240d0184ac 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -79,9 +79,7 @@ class IssuableBaseService < ::BaseContainerService # confidential attribute is a special type of metadata and needs to be allowed to be set # by non-members on issues in public projects so that security issues can be reported as confidential. params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable) - params.delete(:add_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable) - params.delete(:remove_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable) - + filter_contact_params(issuable) filter_assignees(issuable) filter_labels filter_severity(issuable) @@ -118,9 +116,8 @@ class IssuableBaseService < ::BaseContainerService return false unless user ability_name = :"read_#{issuable.to_ability_name}" - resource = issuable.persisted? ? issuable : project - can?(user, ability_name, resource) + can?(user, ability_name, issuable.resource_parent) end def filter_labels @@ -644,6 +641,13 @@ class IssuableBaseService < ::BaseContainerService def filter_widget_params params.delete(:widget_params) end + + def filter_contact_params(issuable) + return if params.slice(:add_contacts, :remove_contacts).empty? + return if can?(current_user, :set_issue_crm_contacts, issuable) + + params.extract!(:add_contacts, :remove_contacts) + end end IssuableBaseService.prepend_mod_with('IssuableBaseService') diff --git a/app/services/issue_email_participants/base_service.rb b/app/services/issue_email_participants/base_service.rb new file mode 100644 index 00000000000..c9847bae537 --- /dev/null +++ b/app/services/issue_email_participants/base_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module IssueEmailParticipants + class BaseService < ::BaseProjectService + MAX_NUMBER_OF_EMAILS = 6 + + attr_reader :target, :emails + + def initialize(target:, current_user:, emails:) + super(project: target.project, current_user: current_user) + + @target = target + @emails = emails + end + + private + + def response_from_guard_checks + return error_feature_flag unless Feature.enabled?(:issue_email_participants, target.project) + return error_underprivileged unless current_user.can?(:"admin_#{target.to_ability_name}", target) + + nil + end + + def add_system_note(emails) + message = format(system_note_text, emails: emails.to_sentence) + ::SystemNoteService.email_participants(target, project, current_user, message) + + message + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_feature_flag + # Don't translate feature flag error because it's temporary. + error("Feature flag issue_email_participants is not enabled for this project.") + end + + def error_underprivileged + error(_("You don't have permission to manage email participants.")) + end + end +end diff --git a/app/services/issue_email_participants/create_service.rb b/app/services/issue_email_participants/create_service.rb index 52c59b2b8fe..aac396ba226 100644 --- a/app/services/issue_email_participants/create_service.rb +++ b/app/services/issue_email_participants/create_service.rb @@ -1,25 +1,15 @@ # frozen_string_literal: true module IssueEmailParticipants - class CreateService < ::BaseProjectService + class CreateService < BaseService include Gitlab::Utils::StrongMemoize - MAX_NUMBER_OF_EMAILS = 6 MAX_NUMBER_OF_RECORDS = 10 - attr_reader :target, :emails - - def initialize(target:, current_user:, emails:) - super(project: target.project, current_user: current_user) - - @target = target - @emails = emails - end - def execute - return error_feature_flag unless Feature.enabled?(:issue_email_participants, target.project) - return error_underprivileged unless current_user.can?(:"admin_#{target.to_ability_name}", target) - return error_no_participants unless emails.present? + response = response_from_guard_checks + return response unless response.nil? + return error_no_participants_added unless emails.present? added_emails = add_participants(deduplicate_and_limit_emails) @@ -27,7 +17,7 @@ module IssueEmailParticipants message = add_system_note(added_emails) ServiceResponse.success(message: message.upcase_first << ".") else - error_no_participants + error_no_participants_added end end @@ -60,13 +50,6 @@ module IssueEmailParticipants added_emails end - def add_system_note(added_emails) - message = format(_("added %{emails}"), emails: added_emails.to_sentence) - ::SystemNoteService.add_email_participants(target, project, current_user, message) - - message - end - def existing_emails target.email_participants_emails_downcase end @@ -78,20 +61,11 @@ module IssueEmailParticipants end end - def error(message) - ServiceResponse.error(message: message) - end - - def error_feature_flag - # Don't translate feature flag error because it's temporary. - error("Feature flag issue_email_participants is not enabled for this project.") - end - - def error_underprivileged - error(_("You don't have permission to add email participants.")) + def system_note_text + _("added %{emails}") end - def error_no_participants + def error_no_participants_added error(_("No email participants were added. Either none were provided, or they already exist.")) end end diff --git a/app/services/issue_email_participants/destroy_service.rb b/app/services/issue_email_participants/destroy_service.rb new file mode 100644 index 00000000000..8cd0178da00 --- /dev/null +++ b/app/services/issue_email_participants/destroy_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module IssueEmailParticipants + class DestroyService < BaseService + def execute + response = response_from_guard_checks + return response unless response.nil? + return error_no_participants_removed unless emails.present? + + removed_emails = remove_participants(emails.first(MAX_NUMBER_OF_EMAILS)) + + if removed_emails.any? + message = add_system_note(removed_emails) + ServiceResponse.success(message: message.upcase_first << ".") + else + error_no_participants_removed + end + end + + private + + def remove_participants(emails_to_remove) + participants = target + .issue_email_participants + .with_emails(emails_to_remove) + .load # to avoid additional query + + emails = participants.map(&:email) + return [] if emails.empty? + + participants.delete_all + + emails + end + + def system_note_text + _("removed %{emails}") + end + + def error_no_participants_removed + error(_("No email participants were removed. Either none were provided, or they don't exist.")) + end + end +end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index a5ae5854e33..f564914352b 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -61,12 +61,13 @@ module Issues # Setting created_at, updated_at and iid is allowed only for admins and owners or # when moving an issue as we preserve the original issue attributes except id and iid. - params.delete(:iid) unless current_user.can?(:set_issue_iid, project) - params.delete(:created_at) unless moved_issue || current_user.can?(:set_issue_created_at, project) - params.delete(:updated_at) unless moved_issue || current_user.can?(:set_issue_updated_at, project) + params.delete(:iid) if params[:iid].present? && !iid_param_allowed? + filter_timestamp_params unless moved_issue # Only users with permission to handle error data can add it to issues - params.delete(:sentry_issue_attributes) unless current_user.can?(:update_sentry_issue, project) + if params[:sentry_issue_attributes].present? && !current_user.can?(:update_sentry_issue, project) + params.delete(:sentry_issue_attributes) + end issue.system_note_timestamp = params[:created_at] || params[:updated_at] end @@ -144,6 +145,19 @@ module Issues def log_audit_event(issue, user, event_type, message) # defined in EE end + + def iid_param_allowed? + current_user.can?(:set_issue_iid, project) + end + + def filter_timestamp_params + timestamp_params = params.slice(:created_at, :updated_at).keys + return unless timestamp_params.any? + + timestamp_params.each do |param| + params.delete(param) unless current_user.can?(:"set_issue_#{param}", project) + end + end end end diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb index d0ca8863c29..8d7b460bf69 100644 --- a/app/services/jira/requests/base.rb +++ b/app/services/jira/requests/base.rb @@ -12,7 +12,8 @@ module Jira jira_ruby: JIRA::HTTPError, ssl: OpenSSL::SSL::SSLError, timeout: [Timeout::Error, Errno::ETIMEDOUT], - uri: [URI::InvalidURIError, SocketError] + uri: [URI::InvalidURIError, SocketError], + url_blocked: Gitlab::HTTP::BlockedUrlError }.freeze ALL_ERRORS = ERRORS.values.flatten.freeze @@ -63,12 +64,21 @@ module Jira def auth_docs_link_start auth_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/index', anchor: 'authentication-in-jira') - '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auth_docs_link_url } + link_start(auth_docs_link_url) end def config_docs_link_start config_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/configure') - '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: config_docs_link_url } + link_start(config_docs_link_url) + end + + def config_integration_link_start + config_jira_integration_url = Rails.application.routes.url_helpers.edit_project_settings_integration_path(project, jira_integration) + link_start(config_jira_integration_url) + end + + def link_start(url) + '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: url } end def error_message(error) @@ -89,6 +99,8 @@ module Jira s_('JiraRequest|A timeout error occurred while connecting to Jira. Try your request again.') when *ERRORS[:connection] s_('JiraRequest|A connection error occurred while connecting to Jira. Try your request again.') + when ERRORS[:url_blocked] + s_('JiraRequest|Unable to connect to the Jira URL. Please verify your %{config_link_start}Jira integration URL%{config_link_end} and attempt the connection again.').html_safe % { config_link_start: config_integration_link_start, config_link_end: '</a>'.html_safe } end end diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index f9857cdad39..8458eaeaf57 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -4,6 +4,7 @@ module MergeRequests class ApprovalService < MergeRequests::BaseService def execute(merge_request) return unless eligible_for_approval?(merge_request) + return if merge_request.merged? approval = merge_request.approvals.new( user: current_user, diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb index 575a6bfe95a..4e6f117e9fb 100644 --- a/app/services/merge_requests/conflicts/list_service.rb +++ b/app/services/merge_requests/conflicts/list_service.rb @@ -15,6 +15,10 @@ module MergeRequests def can_be_resolved_in_ui? return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) + # #cannot_be_merged? is generally indicative of conflicts, and is set via + # MergeRequests::MergeabilityCheckService. However, it can also indicate + # that either #has_no_commits? or #branch_missing? are true. + # return @conflicts_can_be_resolved_in_ui = false unless merge_request.cannot_be_merged? return @conflicts_can_be_resolved_in_ui = false unless merge_request.has_complete_diff_refs? return @conflicts_can_be_resolved_in_ui = false if merge_request.branch_missing? diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb index c0bb257eda6..b8f512bdb2c 100644 --- a/app/services/merge_requests/remove_approval_service.rb +++ b/app/services/merge_requests/remove_approval_service.rb @@ -5,6 +5,7 @@ module MergeRequests # rubocop: disable CodeReuse/ActiveRecord def execute(merge_request) return unless merge_request.approved_by?(current_user) + return if merge_request.merged? # paranoid protection against running wrong deletes return unless merge_request.id && current_user.id diff --git a/app/services/merge_requests/request_review_service.rb b/app/services/merge_requests/request_review_service.rb index ebbae98352b..87b00aa088c 100644 --- a/app/services/merge_requests/request_review_service.rb +++ b/app/services/merge_requests/request_review_service.rb @@ -12,6 +12,7 @@ module MergeRequests notify_reviewer(merge_request, user) trigger_merge_request_reviewers_updated(merge_request) + create_system_note(merge_request, user) success else @@ -25,5 +26,9 @@ module MergeRequests notification_service.async.review_requested_of_merge_request(merge_request, current_user, reviewer) todo_service.create_request_review_todo(merge_request, current_user, reviewer) end + + def create_system_note(merge_request, user) + ::SystemNoteService.request_review(merge_request, merge_request.project, current_user, user) + end end end diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index 191a8711cbd..aa122b1282a 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -11,7 +11,7 @@ module Milestones end milestone.merge_requests.each do |merge_request| - MergeRequests::UpdateService.new(project: parent, current_user: current_user, params: update_params).execute(merge_request) + MergeRequests::UpdateService.new(project: merge_request.project, current_user: current_user, params: update_params).execute(merge_request) end log_destroy_event_for(milestone) diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb index 4417f17f33e..d657b8b3255 100644 --- a/app/services/milestones/promote_service.rb +++ b/app/services/milestones/promote_service.rb @@ -63,9 +63,12 @@ module Milestones def update_children(group_milestone, milestone_ids) issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids) merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids) + milestone_events = ResourceMilestoneEvent.where(milestone_id: milestone_ids) - [issues, merge_requests].each do |issuable_collection| - issuable_collection.update_all(milestone_id: group_milestone.id) + [issues, merge_requests, milestone_events].each do |collection| + collection.each_batch do |batch| + batch.update_all(milestone_id: group_milestone.id) + end end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ml/create_model_service.rb b/app/services/ml/create_model_service.rb index b87b13dd379..7ac9c2a2737 100644 --- a/app/services/ml/create_model_service.rb +++ b/app/services/ml/create_model_service.rb @@ -12,21 +12,25 @@ module Ml def execute ApplicationRecord.transaction do - model = Ml::Model.create!( + model = Ml::Model.new( project: @project, name: @name, - user: (@user.is_a?(User) ? @user : nil), + user: @user, description: @description, default_experiment: default_experiment ) - add_metadata(model, @metadata) + model.save - Gitlab::InternalEvents.track_event( - 'model_registry_ml_model_created', - project: @project, - user: @user - ) + if model.persisted? + add_metadata(model, @metadata) + + Gitlab::InternalEvents.track_event( + 'model_registry_ml_model_created', + project: @project, + user: @user + ) + end model end diff --git a/app/services/ml/create_model_version_service.rb b/app/services/ml/create_model_version_service.rb index 3b8c096b5b4..4af9dd40d12 100644 --- a/app/services/ml/create_model_version_service.rb +++ b/app/services/ml/create_model_version_service.rb @@ -8,6 +8,7 @@ module Ml @package = params[:package] @description = params[:description] @user = params[:user] + @metadata = params[:metadata] end def execute @@ -24,6 +25,8 @@ module Ml { model_version: model_version } ).execute + model_version.add_metadata(@metadata) + Gitlab::InternalEvents.track_event( 'model_registry_ml_model_version_created', project: @model.project, diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb index d7ab6828346..06a15671f25 100644 --- a/app/services/namespaces/package_settings/update_service.rb +++ b/app/services/namespaces/package_settings/update_service.rb @@ -12,6 +12,8 @@ module Namespaces maven_package_requests_forwarding nuget_duplicates_allowed nuget_duplicate_exception_regex + terraform_module_duplicates_allowed + terraform_module_duplicate_exception_regex npm_package_requests_forwarding pypi_package_requests_forwarding lock_maven_package_requests_forwarding diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5099272a212..36431c1cbde 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -522,29 +522,40 @@ class NotificationService ).deliver_later end - # Project invite - def invite_project_member(project_member, token) - return true unless project_member.notifiable?(:subscription) + def invite_member(member, token) + mailer.member_invited_email(member.real_source_type, member.id, token).deliver_later + end + + def new_member(member) + notifiable_options = case member.source + when Group + {} + when Project + { skip_read_ability: true } + end + + return true unless member.notifiable?(:mention, notifiable_options) - mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later + mailer.member_access_granted_email(member.real_source_type, member.id).deliver_later end - def accept_project_invite(project_member) - return true unless project_member.notifiable?(:subscription) + def accept_invite(member) + return true if member.source.is_a?(Project) && !member.notifiable?(:subscription) - mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later + mailer.member_invite_accepted_email(member.real_source_type, member.id).deliver_later end - def new_project_member(project_member) - return true unless project_member.notifiable?(:mention, skip_read_ability: true) + def updated_member_access_level(member) + return true unless member.notifiable?(:mention) - mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later + mailer.member_access_granted_email(member.real_source_type, member.id).deliver_later end - def update_project_member(project_member) - return true unless project_member.notifiable?(:mention) + def updated_member_expiration(member) + return true unless member.source.is_a?(Group) + return true unless member.notifiable?(:mention) - mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later + mailer.member_expiration_date_updated_email(member.real_source_type, member.id).deliver_later end def member_about_to_expire(member) @@ -553,37 +564,10 @@ class NotificationService mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later end - # Group invite - def invite_group_member(group_member, token) - mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later - end - def invite_member_reminder(group_member, token, reminder_index) mailer.member_invited_reminder_email(group_member.real_source_type, group_member.id, token, reminder_index).deliver_later end - def accept_group_invite(group_member) - mailer.member_invite_accepted_email(group_member.real_source_type, group_member.id).deliver_later - end - - def new_group_member(group_member) - return true unless group_member.notifiable?(:mention) - - mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later - end - - def update_group_member(group_member) - return true unless group_member.notifiable?(:mention) - - mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later - end - - def updated_group_member_expiration(group_member) - return true unless group_member.notifiable?(:mention) - - mailer.member_expiration_date_updated_email(group_member.real_source_type, group_member.id).deliver_later - end - def project_was_moved(project, old_path_with_namespace) recipients = project_moved_recipients(project) recipients = notifiable_users(recipients, :custom, custom_action: :moved_project, project: project) diff --git a/app/services/organizations/create_service.rb b/app/services/organizations/create_service.rb index ab70799a095..f29065b8ffd 100644 --- a/app/services/organizations/create_service.rb +++ b/app/services/organizations/create_service.rb @@ -7,13 +7,21 @@ module Organizations organization = Organization.create(params) - return error_creating(organization) unless organization.persisted? + if organization.persisted? + add_organization_owner(organization) - ServiceResponse.success(payload: { organization: organization }) + ServiceResponse.success(payload: { organization: organization }) + else + error_creating(organization) + end end private + def add_organization_owner(organization) + organization.organization_users.create(user: current_user, access_level: :owner) + end + def error_no_permissions ServiceResponse.error(message: [_('You have insufficient permissions to create organizations')]) end diff --git a/app/services/organizations/update_service.rb b/app/services/organizations/update_service.rb index bc3a2d29abf..6e3a2cddddb 100644 --- a/app/services/organizations/update_service.rb +++ b/app/services/organizations/update_service.rb @@ -17,6 +17,10 @@ module Organizations def execute return error_no_permissions unless allowed? + if params[:organization_detail_attributes].key?(:avatar) && params[:organization_detail_attributes][:avatar].nil? + organization.remove_avatar! + end + if organization.update(params) ServiceResponse.success(payload: { organization: organization }) else diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index 0f0dc297e9a..a27f059036c 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -8,24 +8,35 @@ module Packages PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename licenseText contributors exports].freeze DEFAULT_LEASE_TIMEOUT = 1.hour.to_i + ERROR_REASON_INVALID_PARAMETER = :invalid_parameter + ERROR_REASON_PACKAGE_EXISTS = :package_already_exists + ERROR_REASON_PACKAGE_LEASE_TAKEN = :package_lease_taken + ERROR_REASON_PACKAGE_PROTECTED = :package_attachment_data_empty + def execute - return error('Version is empty.', 400) if version.blank? - return error('Attachment data is empty.', 400) if attachment['data'].blank? - return error('Package already exists.', 403) if current_package_exists? - return error('Package protected.', 403) if current_package_protected? - return error('File is too large.', 400) if file_size_exceeded? + return error('Version is empty.', ERROR_REASON_INVALID_PARAMETER) if version.blank? + return error('Attachment data is empty.', ERROR_REASON_INVALID_PARAMETER) if attachment['data'].blank? + return error('Package already exists.', ERROR_REASON_PACKAGE_EXISTS) if current_package_exists? + return error('Package protected.', ERROR_REASON_PACKAGE_PROTECTED) if current_package_protected? + return error('File is too large.', ERROR_REASON_INVALID_PARAMETER) if file_size_exceeded? package = try_obtain_lease do ApplicationRecord.transaction { create_npm_package! } end - return error('Could not obtain package lease. Please try again.', 400) unless package + unless package + return error('Could not obtain package lease. Please try again.', ERROR_REASON_PACKAGE_LEASE_TAKEN) + end - package + ServiceResponse.success(payload: { package: package }) end private + def error(message, reason) + ServiceResponse.error(message: message, reason: reason) + end + def create_npm_package! package = create_package!(:npm, name: name, version: version) diff --git a/app/services/packages/terraform_module/create_package_service.rb b/app/services/packages/terraform_module/create_package_service.rb index 9df722db529..eb48b481dd8 100644 --- a/app/services/packages/terraform_module/create_package_service.rb +++ b/app/services/packages/terraform_module/create_package_service.rb @@ -6,10 +6,20 @@ module Packages include Gitlab::Utils::StrongMemoize def execute - return error('Version is empty.', 400) if params[:module_version].blank? - return error('Access Denied', 403) if current_package_exists_elsewhere? - return error('Package version already exists.', 403) if current_package_version_exists? - return error('File is too large.', 400) if file_size_exceeded? + if params[:module_version].blank? + return ServiceResponse.error(message: 'Version is empty.', reason: :bad_request) + end + + if duplicates_not_allowed? && current_package_exists_elsewhere? + return ServiceResponse.error( + message: 'A package with the same name already exists in the namespace', + reason: :forbidden + ) + end + + if current_package_version_exists? + return ServiceResponse.error(message: 'Package version already exists.', reason: :forbidden) + end ApplicationRecord.transaction { create_terraform_module_package! } end @@ -24,6 +34,15 @@ module Packages package end + def duplicates_not_allowed? + return true if package_settings_with_duplicates_allowed.blank? + + package_settings_with_duplicates_allowed.none? do |setting| + setting.terraform_module_duplicates_allowed || + ::Gitlab::UntrustedRegexp.new("\\A#{setting.terraform_module_duplicate_exception_regex}\\z").match?(name) + end + end + def current_package_exists_elsewhere? ::Packages::Package .for_projects(project.root_namespace.all_projects.id_not_in(project.id)) @@ -62,9 +81,13 @@ module Packages } end - def file_size_exceeded? - project.actual_limits.exceeded?(:generic_packages_max_file_size, params[:file].size) + def package_settings_with_duplicates_allowed + ::Namespace::PackageSetting + .select(:terraform_module_duplicates_allowed, :terraform_module_duplicate_exception_regex) + .namespace_id_in(project.namespace.self_and_ancestor_ids) + .with_terraform_module_duplicates_allowed_or_exception_regex end + strong_memoize_attr :package_settings_with_duplicates_allowed end end end diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index c11b019cee5..1733021cbb5 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -10,9 +10,6 @@ module PagesDomains # no particular SLA, usually takes 10-15 seconds CERTIFICATE_PROCESSING_DELAY = 1.minute.freeze - # Maximum domain length for Let's Encrypt - MAX_DOMAIN_LENGTH = 64 - attr_reader :pages_domain def initialize(pages_domain) @@ -20,11 +17,6 @@ module PagesDomains end def execute - if pages_domain.domain.bytesize > MAX_DOMAIN_LENGTH - log_domain_length_error - return - end - pages_domain.acme_orders.expired.delete_all acme_order = pages_domain.acme_orders.first @@ -67,16 +59,6 @@ module PagesDomains NotificationService.new.pages_domain_auto_ssl_failed(pages_domain) end - def log_domain_length_error - Gitlab::AppLogger.error( - message: "Domain name too long for Let's Encrypt certificate", - pages_domain: pages_domain.domain, - pages_domain_bytesize: pages_domain.domain.bytesize, - max_allowed_bytesize: MAX_DOMAIN_LENGTH, - project_id: pages_domain.project_id - ) - end - def log_error(api_order) Gitlab::AppLogger.error( message: "Failed to obtain Let's Encrypt certificate", diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 7ba5b6119b9..033d90abc7a 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -159,6 +159,7 @@ module Projects destroy_web_hooks! destroy_project_bots! destroy_ci_records! + destroy_deployments! destroy_mr_diff_relations! destroy_merge_request_diffs! @@ -253,6 +254,12 @@ module Projects ) end + def destroy_deployments! + project.deployments.each_batch(of: BATCH_SIZE) do |deployments| + deployments.fast_destroy_all + end + end + # The project can have multiple webhooks with hundreds of thousands of web_hook_logs. # By default, they are removed with "DELETE CASCADE" option defined via foreign_key. # But such queries can exceed the statement_timeout limit and fail to delete the project. diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index fe19d1f051d..188f12a287b 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -18,13 +18,17 @@ module Projects end def project_members - @project_members ||= sorted(project.authorized_users) + filter_and_sort_users(project_members_relation) end def all_members return [] if Feature.enabled?(:disable_all_mention) - [{ username: "all", name: "All Project and Group Members", count: project_members.count }] + [{ username: "all", name: "All Project and Group Members", count: project_members_relation.count }] + end + + def project_members_relation + project.authorized_users end end end diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index cdd1870858e..dbac59dd32b 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -46,9 +46,7 @@ module Projects end # rubocop: enable Cop/InBatches - if Feature.enabled?(:refresh_statistics_on_unlink_fork, @project.namespace) && refresh_statistics - ProjectCacheWorker.perform_async(project.id, [], [:repository_size]) - end + ProjectCacheWorker.perform_async(project.id, [], [:repository_size]) if refresh_statistics # When the project getting out of the network is a node with parent # and children, both the parent and the node needs a cache refresh. diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb index 9b979f6ed68..0d51de4d26e 100644 --- a/app/services/projects/update_statistics_service.rb +++ b/app/services/projects/update_statistics_service.rb @@ -17,6 +17,8 @@ module Projects expire_repository_caches expire_wiki_caches project.statistics.refresh!(only: statistics) + + record_onboarding_progress end private @@ -46,5 +48,11 @@ module Projects params[:statistics]&.map(&:to_sym) end end + + def record_onboarding_progress + return unless repository.commit_count > 1 || repository.branch_count > 1 + + Onboarding::ProgressService.new(project.namespace).execute(action: :code_added) + end end end diff --git a/app/services/routes/rename_descendants_service.rb b/app/services/routes/rename_descendants_service.rb new file mode 100644 index 00000000000..18a28b87dcb --- /dev/null +++ b/app/services/routes/rename_descendants_service.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Routes + class RenameDescendantsService + BATCH_SIZE = 100 + class RouteChanges + attr_reader :saved_change_to_parent_path, :saved_change_to_parent_name, :old_path_of_parent, :old_name_of_parent + + def initialize(changes) + path_details = changes.fetch(:path) + name_details = changes.fetch(:name) + + @saved_change_to_parent_path = path_details.fetch(:saved) + @old_path_of_parent = path_details.fetch(:old_value) + @saved_change_to_parent_name = name_details.fetch(:saved) + @old_name_of_parent = name_details.fetch(:old_value) + end + end + + def initialize(parent_route) + @parent_route = parent_route + @routes_to_update = [] + @redirect_routes_to_insert = [] + end + + def execute(changes) + process_changes(changes) + update_routes_for_descendants + create_redirect_routes_for_descendants + end + + private + + def process_changes(changes) + changes = RouteChanges.new(changes) + + saved_change_to_parent_path = changes.saved_change_to_parent_path + saved_change_to_parent_name = changes.saved_change_to_parent_name + + return unless saved_change_to_parent_path || saved_change_to_parent_name + + old_path_of_parent = changes.old_path_of_parent + old_name_of_parent = changes.old_name_of_parent + + descendant_routes_inside(old_path_of_parent).each_batch(of: BATCH_SIZE) do |relation| + relation.each do |descendant_route| + attributes_to_update = {} + + if saved_change_to_parent_path && descendant_route.path.present? + attributes_to_update[:path] = descendant_route.path.sub( + old_path_of_parent, current_path_of_parent + ) + end + + if saved_change_to_parent_name && old_name_of_parent.present? && descendant_route.name.present? + attributes_to_update[:name] = descendant_route.name.sub( + old_name_of_parent, current_name_of_parent + ) + end + + push_to_routes_data(descendant_route, attributes_to_update) + push_to_redirect_routes_data(descendant_route) if attributes_to_update[:path] + end + end + end + + def push_to_routes_data(descendant_route, attributes_to_update) + return if attributes_to_update.empty? + + # We merge updated attributes with all existing attributes of the `Route` record. + # This comprehensive attribute set is required for the initial attempt of `upsert_all` to function effectively. + # During the first phase (insertion attempt), `upsert_all` tries to insert new records into the database, + # necessitating the presence of all attributes, including NOT NULL attributes, to create new entries. + # Attributes like `source_id` and `source_type` are crucial, as they are NOT NULL attributes essential + # for record creation. + # In the event of conflicts (e.g., existing Route records with conflicting `id`s), + # `upsert_all` switches to an update operation for those specific conflicted records. + # And this is the way we get to update `path` and/or `name` of multiple, existing route records in one go. + @routes_to_update << descendant_route + .attributes.symbolize_keys + .merge(attributes_to_update) + end + + def push_to_redirect_routes_data(descendant_route) + @redirect_routes_to_insert << { + source_id: descendant_route.source_id, + source_type: descendant_route.source_type, + path: descendant_route.path + } + end + + def update_routes_for_descendants + return if @routes_to_update.blank? + + @routes_to_update.each_slice(BATCH_SIZE) do |data| + # Utilizing `upsert_all` with `unique_by: :id` ensures that only updates occur, + # as the provided data contains attributes exclusively for existing `Route` records, + # identified by their unique `id`. + # This upsert operation is hence guaranteed to solely execute updates, never inserts. + Route.upsert_all( + data, + unique_by: :id, + update_only: [:path, :name], # on conflicts, we need to update only path/name. + record_timestamps: true # this makes sure that `updated_at` is updated. + ) + end + end + + def create_redirect_routes_for_descendants + return if @redirect_routes_to_insert.blank? + + @redirect_routes_to_insert.each_slice(BATCH_SIZE) do |data| + RedirectRoute.insert_all( + data, + # We need to make sure no duplicates are inserted. + # We use the value of `lower(path)` to make this check, + # which is already a UNIQUE index on this table. + unique_by: :index_redirect_routes_on_path_unique_text_pattern_ops + ) + end + end + + def current_name_of_parent + @parent_route.name + end + + def current_path_of_parent + @parent_route.path + end + + def descendant_routes_inside(path) + Route.inside_path(path) + end + end +end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 2d4bebc8b2b..f69ee255e01 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -70,7 +70,8 @@ module Spam result = spamcheck_client.spam?(spammable: target, user: user, context: context, extra_features: extra_features) if result.evaluated? - Abuse::TrustScore.create!(user: user, score: result.score, source: :spamcheck) + correlation_id = Labkit::Correlation::CorrelationId.current_id || '' + Abuse::TrustScoreWorker.perform_async(user.id, :spamcheck, result.score, correlation_id) end result.verdict diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 5f71b7ac9e9..fc27303792b 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -45,6 +45,10 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_reviewers(old_reviewers) end + def request_review(issuable, project, author, user) + ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).request_review(user) + end + def change_issuable_contacts(issuable, project, author, added_count, removed_count) ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_contacts(added_count, removed_count) end @@ -282,8 +286,8 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_canonical_issue_of_duplicate(duplicate_issue) end - def add_email_participants(noteable, project, author, body) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).add_email_participants(body) + def email_participants(noteable, project, author, body) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).email_participants(body) end def discussion_lock(issuable, author) diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index c584d5ccca3..3f96ca9cefb 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -133,6 +133,12 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer')) end + def request_review(user) + body = "#{self.class.issuable_events[:review_requested]} #{user.to_reference}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer')) + end + # Called when the contacts of an issuable are changed or removed # We intend to reference the contacts but for security we are just # going to state how many were added/removed for now. See discussion: @@ -431,7 +437,7 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) end - def add_email_participants(body) + def email_participants(body) create_note(NoteSummary.new(noteable, project, author, body)) end diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb index f9084ed67d3..6ebf1215a25 100644 --- a/app/services/system_notes/time_tracking_service.rb +++ b/app/services/system_notes/time_tracking_service.rb @@ -43,18 +43,11 @@ module SystemNotes # # Returns the created Note object def change_time_estimate - parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) - body = if noteable.time_estimate == 0 - "removed time estimate" - else - "changed time estimate to #{parsed_time}" - end - if noteable.is_a?(Issue) issue_activity_counter.track_issue_time_estimate_changed_action(author: author, project: project) end - create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + create_note(NoteSummary.new(noteable, project, author, time_estimate_system_note, action: 'time_tracking')) end # Called when the spent time of a Noteable is changed @@ -160,5 +153,19 @@ module SystemNotes def work_item_activity_counter Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter end + + def time_estimate_system_note + parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) + previous_estimate = noteable.previous_changes['time_estimate']&.at(0) || 0 + parsed_previous_restimate = Gitlab::TimeTrackingFormatter.output(previous_estimate) + + if previous_estimate == 0 + "added time estimate of #{parsed_time}" + elsif noteable.time_estimate == 0 + "removed time estimate of #{parsed_previous_restimate}" + else + "changed time estimate to #{parsed_time} from #{parsed_previous_restimate}" + end + end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index be7405cc896..168b36ea4d1 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -168,7 +168,11 @@ class TodoService def mark_todo(target, current_user) project = target.project attributes = attributes_for_todo(project, target, current_user, Todo::MARKED) - create_todos(current_user, attributes, target_namespace(target), project) + + todos = create_todos(current_user, attributes, target_namespace(target), project) + work_item_activity_counter.track_work_item_mark_todo_action(author: current_user) if target.is_a?(WorkItem) + + todos end def todo_exist?(issuable, current_user) @@ -475,6 +479,10 @@ class TodoService project = target.project project&.namespace || target.try(:namespace) end + + def work_item_activity_counter + Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter + end end TodoService.prepend_mod_with('TodoService') diff --git a/app/services/work_items/callbacks/assignees.rb b/app/services/work_items/callbacks/assignees.rb new file mode 100644 index 00000000000..14755ff0b46 --- /dev/null +++ b/app/services/work_items/callbacks/assignees.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class Assignees < Base + def before_update + params[:assignee_ids] = [] if excluded_in_new_type? + + return unless params.present? && params.has_key?(:assignee_ids) + return unless has_permission?(:set_work_item_metadata) + + assignee_ids = filter_assignees_count(params[:assignee_ids]) + assignee_ids = filter_assignee_permissions(assignee_ids) + + return if assignee_ids.sort == work_item.assignee_ids.sort + + work_item.assignee_ids = assignee_ids + work_item.touch + end + + private + + def filter_assignees_count(assignee_ids) + return assignee_ids if work_item.allows_multiple_assignees? + + assignee_ids.first(1) + end + + def filter_assignee_permissions(assignee_ids) + assignees = User.id_in(assignee_ids) + + assignees.select { |assignee| assignee.can?(:read_work_item, work_item) }.map(&:id) + end + end + end +end diff --git a/app/services/work_items/callbacks/current_user_todos.rb b/app/services/work_items/callbacks/current_user_todos.rb new file mode 100644 index 00000000000..c6c74a5ce3d --- /dev/null +++ b/app/services/work_items/callbacks/current_user_todos.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class CurrentUserTodos < Base + def before_update + return unless params.present? && params.key?(:action) + + case params[:action] + when "add" + add_todo + when "mark_as_done" + mark_as_done(params[:todo_id]) + end + end + + private + + def add_todo + return unless has_permission?(:create_todo) + + TodoService.new.mark_todo(work_item, current_user)&.first + end + + def mark_as_done(todo_id) + todos = TodosFinder.new(current_user, state: :pending, target_id: work_item.id).execute + todos = todo_id ? todos.id_in(todo_id) : todos + + return if todos.empty? + + TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_done) + end + end + end +end diff --git a/app/services/work_items/callbacks/description.rb b/app/services/work_items/callbacks/description.rb new file mode 100644 index 00000000000..b9620c65214 --- /dev/null +++ b/app/services/work_items/callbacks/description.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class Description < Base + def before_update + params[:description] = nil if excluded_in_new_type? + + return unless params.present? && params.key?(:description) + return unless has_permission?(:update_work_item) + + work_item.description = params[:description] + work_item.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user) + end + end + end +end diff --git a/app/services/work_items/callbacks/notifications.rb b/app/services/work_items/callbacks/notifications.rb new file mode 100644 index 00000000000..233088ea188 --- /dev/null +++ b/app/services/work_items/callbacks/notifications.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class Notifications < Base + def before_update + return unless params.present? && params.key?(:subscribed) + return unless has_permission?(:update_subscription) + + update_subscription(work_item, params) + end + + private + + def update_subscription(work_item, subscription_params) + work_item.set_subscription( + current_user, + subscription_params[:subscribed], + work_item.project + ) + end + end + end +end diff --git a/app/services/work_items/callbacks/start_and_due_date.rb b/app/services/work_items/callbacks/start_and_due_date.rb new file mode 100644 index 00000000000..b7318dcfcf4 --- /dev/null +++ b/app/services/work_items/callbacks/start_and_due_date.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module WorkItems + module Callbacks + class StartAndDueDate < Base + def before_update + return work_item.assign_attributes({ start_date: nil, due_date: nil }) if excluded_in_new_type? + + return if params.blank? + return unless has_permission?(:set_work_item_metadata) + + work_item.assign_attributes(params.slice(:start_date, :due_date)) + end + end + end +end diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index 354a33a0384..f9eadc3fb60 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -73,3 +73,5 @@ module WorkItems end end end + +WorkItems::CreateService.prepend_mod diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb deleted file mode 100644 index 7a084917ea7..00000000000 --- a/app/services/work_items/widgets/assignees_service/update_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module AssigneesService - class UpdateService < WorkItems::Widgets::BaseService - def before_update_in_transaction(params:) - params[:assignee_ids] = [] if new_type_excludes_widget? - - return unless params.present? && params.has_key?(:assignee_ids) - return unless has_permission?(:set_work_item_metadata) - - assignee_ids = filter_assignees_count(params[:assignee_ids]) - assignee_ids = filter_assignee_permissions(assignee_ids) - - return if assignee_ids.sort == work_item.assignee_ids.sort - - work_item.assignee_ids = assignee_ids - work_item.touch - end - - private - - def filter_assignees_count(assignee_ids) - return assignee_ids if work_item.allows_multiple_assignees? - - assignee_ids.first(1) - end - - def filter_assignee_permissions(assignee_ids) - assignees = User.id_in(assignee_ids) - - assignees.select { |assignee| assignee.can?(:read_work_item, work_item) }.map(&:id) - end - end - end - end -end diff --git a/app/services/work_items/widgets/current_user_todos_service/update_service.rb b/app/services/work_items/widgets/current_user_todos_service/update_service.rb deleted file mode 100644 index 38e2ae4de32..00000000000 --- a/app/services/work_items/widgets/current_user_todos_service/update_service.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module CurrentUserTodosService - class UpdateService < WorkItems::Widgets::BaseService - def before_update_in_transaction(params:) - return unless params.present? && params.key?(:action) - - case params[:action] - when "add" - add_todo - when "mark_as_done" - mark_as_done(params[:todo_id]) - end - end - - private - - def add_todo - return unless has_permission?(:create_todo) - - TodoService.new.mark_todo(work_item, current_user)&.first - end - - def mark_as_done(todo_id) - todos = TodosFinder.new(current_user, state: :pending, target_id: work_item.id).execute - todos = todo_id ? todos.id_in(todo_id) : todos - - return if todos.empty? - - TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_done) - end - end - end - end -end diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb deleted file mode 100644 index 2640c6132cd..00000000000 --- a/app/services/work_items/widgets/description_service/update_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module DescriptionService - class UpdateService < WorkItems::Widgets::BaseService - def before_update_callback(params: {}) - params[:description] = nil if new_type_excludes_widget? - - return unless params.present? && params.key?(:description) - return unless has_permission?(:update_work_item) - - work_item.description = params[:description] - work_item.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user) - end - end - end - end -end diff --git a/app/services/work_items/widgets/notifications_service/update_service.rb b/app/services/work_items/widgets/notifications_service/update_service.rb deleted file mode 100644 index b301e2ca7db..00000000000 --- a/app/services/work_items/widgets/notifications_service/update_service.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module NotificationsService - class UpdateService < WorkItems::Widgets::BaseService - def before_update_in_transaction(params:) - return unless params.present? && params.key?(:subscribed) - return unless has_permission?(:update_subscription) - - update_subscription(work_item, params) - end - - private - - def update_subscription(work_item, subscription_params) - work_item.set_subscription( - current_user, - subscription_params[:subscribed], - work_item.project - ) - end - end - end - end -end diff --git a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb deleted file mode 100644 index 5d47b3a1516..00000000000 --- a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module StartAndDueDateService - class UpdateService < WorkItems::Widgets::BaseService - def before_update_callback(params: {}) - return widget.work_item.assign_attributes({ start_date: nil, due_date: nil }) if new_type_excludes_widget? - - return if params.blank? - return unless has_permission?(:set_work_item_metadata) - - widget.work_item.assign_attributes(params.slice(:start_date, :due_date)) - end - end - end - end -end diff --git a/app/validators/json_schemas/application_setting_rate_limits.json b/app/validators/json_schemas/application_setting_rate_limits.json new file mode 100644 index 00000000000..e74295291df --- /dev/null +++ b/app/validators/json_schemas/application_setting_rate_limits.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Application rate limits", + "type": "object", + "additionalProperties": false, + "properties": { + "members_delete_limit": { + "type": "integer", + "minimum": 0, + "description": "Number of project or group members a user can delete per minute." + } + } +} diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json index ac34af3f107..e8d095e2921 100644 --- a/app/validators/json_schemas/build_metadata_secrets.json +++ b/app/validators/json_schemas/build_metadata_secrets.json @@ -8,33 +8,79 @@ "patternProperties": { "^vault$": { "type": "object", - "required": ["path", "field", "engine"], + "required": [ + "path", + "field", + "engine" + ], "properties": { - "path": { "type": "string" }, - "field": { "type": "string" }, + "path": { + "type": "string" + }, + "field": { + "type": "string" + }, "engine": { "type": "object", - "required": ["name", "path"], + "required": [ + "name", + "path" + ], "properties": { - "path": { "type": "string" }, - "name": { "type": "string" } + "path": { + "type": "string" + }, + "name": { + "type": "string" + } }, "additionalProperties": false } }, "additionalProperties": false }, + "^gcp_secret_manager$": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "^azure_key_vault$": { "type": "object", - "required": ["name"], + "required": [ + "name" + ], "properties": { - "name": { "type": "string" }, - "version": { "type": ["string", "null"] } + "name": { + "type": "string" + }, + "version": { + "type": [ + "string", + "null" + ] + } }, "additionalProperties": false }, - "^file$": { "type": "boolean" }, - "^token$": { "type": "string" } + "^file$": { + "type": "boolean" + }, + "^token$": { + "type": "string" + } }, "anyOf": [ { @@ -44,6 +90,11 @@ }, { "required": [ + "gcp_secret_manager" + ] + }, + { + "required": [ "azure_key_vault" ] } diff --git a/app/validators/json_schemas/cloud_connector_access.json b/app/validators/json_schemas/cloud_connector_access.json new file mode 100644 index 00000000000..8ebb32245d5 --- /dev/null +++ b/app/validators/json_schemas/cloud_connector_access.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Cloud Connector Access", + "type": "object", + "available_services": { + "type": "array", + "items": { + "type": "object" + } + }, + "additionalProperties": true +} diff --git a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json index 4885e5266c1..b4bebed3d1c 100644 --- a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json +++ b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json @@ -17,6 +17,30 @@ }, "block_branch_modification": { "type": "boolean" + }, + "block_group_branch_modification": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "exceptions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "enabled" + ] + } + ] } } } diff --git a/app/views/admin/application_settings/_members_api_limits.html.haml b/app/views/admin/application_settings/_members_api_limits.html.haml new file mode 100644 index 00000000000..3065c62b7e2 --- /dev/null +++ b/app/views/admin/application_settings/_members_api_limits.html.haml @@ -0,0 +1,21 @@ +%section.settings.as-members-api-limits.no-animate#js-members-api-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _('Members API rate limit') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded_by_default? ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = _('Limit the number of project or group members a user can delete per minute through API requests.') + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_members_api'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-members-api-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :members_delete_limit, _('Maximum requests per minute per group / project'), class: 'label-bold' + = f.number_field :members_delete_limit, min: 0, class: 'form-control gl-form-input' + .form-text.gl-text-gray-600 + = _("Set to 0 to disable the limit.") + + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 412098cfae4..5fc9db06bb2 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -5,7 +5,7 @@ .sub-section %h4= _('Hashed repository storage paths') .form-group - - repository_storage_help_link_url = help_page_path('administration/repository_storage_types') + - repository_storage_help_link_url = help_page_path('administration/repository_storage_paths') - repository_storage_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_storage_help_link_url } = f.gitlab_ui_checkbox_component :hashed_storage_enabled, _('Use hashed storage'), diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 2b972a2d7f1..3d3d4ab29d1 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -24,6 +24,9 @@ _('Enforce two-factor authentication'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } .form-group + = f.label :require_admin_two_factor_authentication, _('Enforce Two-Factor authentication for administrator users'), class: 'label-bold' + = f.gitlab_ui_checkbox_component :require_admin_two_factor_authentication, _('Require administrators to enable 2FA') + .form-group = f.label :two_factor_authentication, _('Two-factor grace period'), class: 'label-bold' = f.number_field :two_factor_grace_period, min: 0, class: 'form-control gl-form-input', placeholder: '0' .form-text.text-muted diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml index 4fb65c20daf..4bdc21a3695 100644 --- a/app/views/admin/application_settings/_user_restrictions.html.haml +++ b/app/views/admin/application_settings/_user_restrictions.html.haml @@ -8,3 +8,4 @@ = form.gitlab_ui_checkbox_component :can_create_group, _("Allow new users to create top-level groups") = form.gitlab_ui_checkbox_component :user_defaults_to_private_profile, _("Make new users' profiles private by default") = render_if_exists 'admin/application_settings/allow_account_deletion', form: form + = form.gitlab_ui_checkbox_component :allow_project_creation_for_guest_and_below, _("Allow users with up to Guest role to create groups and personal projects") diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 672af002e5e..e8bf25b8da6 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -6,6 +6,28 @@ .settings-section .settings-sticky-header .settings-sticky-header-inner + %h4.gl-my-0 Favicon + + .form-group + = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0' + %p + - if @appearance.favicon? + = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do + = _('Remove favicon') + %hr + = f.hidden_field :favicon_cache + = f.file_field :favicon, class: '', accept: 'image/*' + .form-text.text-muted + = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist } + %br + = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner %h4.gl-my-0= _('Navigation bar') .form-group @@ -26,54 +48,29 @@ .settings-section .settings-sticky-header .settings-sticky-header-inner - %h4.gl-my-0 Favicon + %h4.gl-my-0= _('New project pages') .form-group - = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0' + = f.label :new_project_guidelines, class: 'col-form-label' %p - - if @appearance.favicon? - = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do - = _('Remove favicon') - %hr - = f.hidden_field :favicon_cache - = f.file_field :favicon, class: '', accept: 'image/*' + = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10 .form-text.text-muted - = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist } - %br - = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") - - = render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f } + = parsed_with_gfm .settings-section .settings-sticky-header .settings-sticky-header-inner - %h4.gl-my-0= _('Sign in/Sign up pages') + %h4.gl-my-0= _('Profile image guidelines') + + %p.gl-text-secondary + = _('These guidelines for public avatars are displayed on the user settings page.') .form-group - = f.label :title, class: 'col-form-label' - = f.text_field :title, class: "form-control gl-form-input" - .form-group - = f.label :description, class: 'col-form-label' - = f.text_area :description, class: "form-control gl-form-input", rows: 10 - .form-text.text-muted - = parsed_with_gfm - .form-group - = f.label :logo, class: 'col-form-label gl-pt-0' + = f.label :profile_image_guidelines, class: 'col-form-label' %p - - if @appearance.logo? - = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - - if @appearance.persisted? - %br - = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do - = _('Remove logo') - %hr - = f.hidden_field :logo_cache - = f.file_field :logo, class: "", accept: 'image/*' + = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10 .form-text.text-muted - = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.') + = parsed_with_gfm .settings-section .settings-sticky-header @@ -109,26 +106,32 @@ .settings-section .settings-sticky-header .settings-sticky-header-inner - %h4.gl-my-0= _('New project pages') + %h4.gl-my-0= _('Sign in/Sign up pages') .form-group - = f.label :new_project_guidelines, class: 'col-form-label' - %p - = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10 - .form-text.text-muted - = parsed_with_gfm - - .settings-section - .settings-sticky-header - .settings-sticky-header-inner - %h4.gl-my-0= _('Profile image guideline') - + = f.label :title, class: 'col-form-label' + = f.text_field :title, class: "form-control gl-form-input" .form-group - = f.label :profile_image_guidelines, class: 'col-form-label' + = f.label :description, class: 'col-form-label' + = f.text_area :description, class: "form-control gl-form-input", rows: 10 + .form-text.text-muted + = parsed_with_gfm + .form-group + = f.label :logo, class: 'col-form-label gl-pt-0' %p - = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10 + - if @appearance.logo? + = image_tag @appearance.logo_path, class: 'appearance-logo-preview' + - if @appearance.persisted? + %br + = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do + = _('Remove logo') + %hr + = f.hidden_field :logo_cache + = f.file_field :logo, class: "", accept: 'image/*' .form-text.text-muted - = parsed_with_gfm + = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.') + + = render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f } - if @appearance.persisted? || @appearance.updated_at .settings-section diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index addd23688b4..b7a43916a30 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -7,16 +7,7 @@ .settings-header = render 'admin/application_settings/ci/header', expanded: expanded_by_default? .settings-content - %p - = _('Variables can be:') - %ul - %li - = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' - %li - = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' - + = render 'ci/variables/attributes' - if ci_variable_protected_by_default? %p.settings-message.text-center.gl-mb-0 - help_link = link_to('', help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable', target: '_blank', rel: 'noopener noreferrer')) diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 39f1ec7056c..2e16161cde4 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -120,8 +120,5 @@ = render_if_exists 'admin/application_settings/add_license' = render 'admin/application_settings/jira_connect' = render 'admin/application_settings/slack' -- if Feature.enabled?(:updated_ai_powered_features_menu_for_sm) - = render_if_exists 'admin/application_settings/ai_powered' -- else - = render_if_exists 'admin/application_settings/ai_access' += render_if_exists 'admin/application_settings/ai_powered' = render 'admin/application_settings/security_txt', expanded: expanded_by_default? diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index ae5f7a5cec3..c2f19c11b4e 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -151,6 +151,8 @@ = render 'projects_api_limits' += render 'members_api_limits' + %section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only diff --git a/app/views/admin/dashboard/_stats_users_table.html.haml b/app/views/admin/dashboard/_stats_users_table.html.haml index 473384b8961..a23674c79db 100644 --- a/app/views/admin/dashboard/_stats_users_table.html.haml +++ b/app/views/admin/dashboard/_stats_users_table.html.haml @@ -1,4 +1,4 @@ -%table.table.gl-text-gray-500 +%table.table.gl-text-gray-500.gl-w-full %tr %td.gl-p-5! = s_('AdminArea|Users without a Group and Project') diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml index 059460ae5b2..8c096e2936f 100644 --- a/app/views/admin/dashboard/stats.html.haml +++ b/app/views/admin/dashboard/stats.html.haml @@ -8,7 +8,7 @@ %p.gl-font-weight-bold.gl-mt-8 = s_('AdminArea|Totals') -%table.gl-table.gl-text-gray-500 +%table.gl-table.gl-text-gray-500.gl-w-full = render_if_exists 'admin/dashboard/stats_active_users_row', users_statistics: @users_statistics %tr.bg-gray-light.gl-text-gray-900 diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index b3e24d5b3ac..e2f5ef5d786 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -4,11 +4,9 @@ .row.gl-mt-5.justify-content-center .col-md-5 .login-page - #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) } + .borderless - if any_form_based_providers_enabled? = render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false - - else - = render 'devise/shared/tab_single', tab_title: page_title if Feature.disabled?(:restyle_login_page, @project) .tab-content - if allow_admin_mode_password_authentication_for_web? || ldap_sign_in_enabled? || crowd_enabled? = render 'admin/sessions/signin_box' diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml index ef004004227..898d47f446a 100644 --- a/app/views/admin/sessions/two_factor.html.haml +++ b/app/views/admin/sessions/two_factor.html.haml @@ -4,8 +4,7 @@ .row.justify-content-center .col-md-5 .login-page - #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) } - = render 'devise/shared/tab_single', tab_title: _('Enter admin mode') if Feature.disabled?(:restyle_login_page, @project) + .borderless .login-box.gl-p-5 .login-body - if current_user.two_factor_enabled? diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 46fe6bed05e..649ed00ea22 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -151,6 +151,8 @@ = render_if_exists 'admin/users/credit_card_info', user: @user, link_to_match_page: true + = render_if_exists 'admin/users/phone_info', user: @user, link_to_match_page: true + = render 'shared/custom_attributes', custom_attributes: @user.custom_attributes -# Rendered on desktop only so order of cards can be different on desktop vs mobile diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml deleted file mode 100644 index 29c2e364c37..00000000000 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ /dev/null @@ -1,23 +0,0 @@ -- link = link_to _("Install GitLab Runner and ensure it's running."), 'https://docs.gitlab.com/runner/install/', target: '_blank', rel: 'noopener noreferrer' -.gl-mb-3 - %h5= _("Set up a %{type} runner for a project") % { type: type } -%ol - %li - = link.html_safe - %li - = _("Register the runner with this URL:") - %br - %code#coordinator_address= root_url(only_path: false) - = deprecated_clipboard_button(target: '#coordinator_address', title: _("Copy URL")) - %br - %br - = _("And this registration token:") - %br - %code#registration_token= registration_token - = deprecated_clipboard_button(target: '#registration_token', title: _("Copy token")) - -.gl-mt-3.gl-mb-3 -= render Pajamas::ButtonComponent.new(variant: :default, method: :put, href: reset_token_url, button_options: { id: 'Reset registration token', data: { confirm: _("Are you sure you want to reset the registration token?") } }) do - = _('Reset registration token') - -#js-install-runner diff --git a/app/views/ci/variables/_attributes.html.haml b/app/views/ci/variables/_attributes.html.haml new file mode 100644 index 00000000000..a924d92a4bb --- /dev/null +++ b/app/views/ci/variables/_attributes.html.haml @@ -0,0 +1,13 @@ +%p + = s_('CiVariables|Variables can be accidentally exposed in a job log, or maliciously sent to a third party server. The masked variable feature can help reduce the risk of accidentally exposing variable values, but is not a guaranteed method to prevent malicious users from accessing variables.') + = link_to _('How can I make my variables more secure?'), help_page_path('ci/variables/index', anchor: 'cicd-variable-security'), target: '_blank', rel: 'noopener noreferrer' +%p + = s_('CiVariables|Variables can have several attributes.') + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer' +%ul + %li + = html_escape(s_('CiVariables|%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li + = html_escape(s_('CiVariables|%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li + = html_escape(s_('CiVariables|%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 65f9e6c2342..a1567ad34e8 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -2,16 +2,7 @@ - is_group = !@group.nil? - is_project = !@project.nil? -%p - = _('Variables can have several attributes.') - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer' -%ul - %li - = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } += render 'ci/variables/attributes' #js-ci-variables{ data: { endpoint: save_endpoint, is_project: is_project.to_s, diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 4f3ca9fd71b..1b0bd10db77 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -104,7 +104,10 @@ - if todos_filter_empty? %p - = (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '<strong>', strongEnd: '</strong>', openIssuesLinkStart: "<a href=\"#{issues_dashboard_path}\">", openIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path}\">", mergeRequestLinkEnd: '</a>' }).html_safe + = (s_("Todos|Not sure where to go next? Take a look at your %{strongStart}%{assignedIssuesLinkStart}assigned issues%{assignedIssuesLinkEnd}%{strongEnd} or %{strongStart}%{mergeRequestLinkStart}merge requests%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}.") % { strongStart: '<strong>', strongEnd: '</strong>', assignedIssuesLinkStart: "<a href=\"#{issues_dashboard_path(assignee_username: current_user.username)}\">", assignedIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path(assignee_username: current_user.username)}\">", mergeRequestLinkEnd: '</a>' }).html_safe + %p + = link_to s_("Todos| What actions create to-do items?"), help_page_path('user/todos', anchor: 'actions-that-create-to-do-items'), target: '_blank', rel: 'noopener noreferrer' + - elsif todos_has_filtered_results? %p = link_to s_("Todos|Do you want to remove the filters?"), todos_filter_path(without: [:project_id, :author_id, :type, :action_id]) diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 00652e8574a..0ce6d9b1095 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -8,7 +8,8 @@ = f.label :email = f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', title: _('Please provide a valid email address.'), value: nil .form-text.gl-text-secondary - = _('Requires your primary GitLab email address.') + - emails_link = link_to('', profile_emails_url, target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('Requires your primary GitLab email address. If you want to confirm a secondary email address, go to %{emails_link_start}Emails%{emails_link_end}'), tag_pair(emails_link, :emails_link_start, :emails_link_end)) %div - if recaptcha_enabled? diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index 227418e366d..536d4c9fd4b 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -16,8 +16,4 @@ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do = _('Reset password') -- if Feature.enabled?(:restyle_login_page, @project) - = render 'devise/shared/sign_in_link' -- else - .gl-mt-3 - = render 'devise/shared/sign_in_link' += render 'devise/shared/sign_in_link' diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 29f1a1f398b..ec85c680f7f 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -11,9 +11,8 @@ = render 'devise/shared/signup_omniauth_providers' .signup-page - = render signup_box_template, + = render 'devise/shared/signup_box', url: registration_path(resource_name, registration_path_params), button_text: _('Register'), - borderless: Feature.enabled?(:restyle_login_page, @project), show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index e7ebe6d808c..728728ea653 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -9,26 +9,23 @@ = render_if_exists "layouts/google_tag_manager_body" -#signin-container +.js-non-oauth-login - if any_form_based_providers_enabled? = render 'devise/shared/tabs_ldap', render_signup_link: false .tab-content - if password_authentication_enabled_for_web? || ldap_sign_in_enabled? || crowd_enabled? = render 'devise/shared/signin_box' - - -# Show a message if none of the mechanisms above are enabled - - if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) - %div - = _('No authentication methods configured.') - - - if Feature.enabled?(:restyle_login_page, @project) && Gitlab::CurrentSettings.current_application_settings.terms - %p.gl-px-5 - = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, - link_end: '</a>'.html_safe } - - - if allow_signup? - %p{ class: "gl-mt-3 #{'gl-text-center' if Feature.enabled?(:restyle_login_page, @project)}" } - = _("Don't have an account yet?") - = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { testid: 'register-link' } - - if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled? - = render 'devise/shared/omniauth_box' +-# Show a message if none of the mechanisms above are enabled +- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) + %div + = _('No authentication methods configured.') +- if Gitlab::CurrentSettings.current_application_settings.terms + %p.gl-px-5 + = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, + link_end: '</a>'.html_safe } +- if allow_signup? + %p.gl-mt-3.gl-text-center + = _("Don't have an account yet?") + = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { testid: 'register-link' } +- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled? + = render 'devise/shared/omniauth_box' diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 96f6f5cb095..454b89e40f8 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -1,8 +1,7 @@ -= render 'devise/shared/tab_single', tab_title: _('Two-factor authentication') if Feature.disabled?(:restyle_login_page, @project) .login-box.gl-p-5 .login-body - if @user.two_factor_enabled? - = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f| + = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}", aria: { live: 'assertive' }}) do |f| .form-group = f.label :otp_attempt, _('Enter verification code') = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { testid: 'two-fa-code-field' } diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml index c35e43b909e..44f34e3f342 100644 --- a/app/views/devise/shared/_footer.html.haml +++ b/app/views/devise/shared/_footer.html.haml @@ -7,5 +7,8 @@ = link_to _("Help"), help_path = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}" = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer' + - if one_trust_enabled? + = render Pajamas::ButtonComponent.new(category: :tertiary, size: :small, button_options: { class: 'ot-sdk-show-settings' }) do + = _("Cookie Preferences") = render 'devise/shared/language_switcher' = footer_message diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 45062745b77..8197abcc787 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -1,21 +1,16 @@ - render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true) -- restyle_login_page_enabled = Feature.enabled?(:restyle_login_page, @project) -- if restyle_login_page_enabled && (any_form_based_providers_enabled? || password_authentication_enabled_for_web?) +- if any_form_based_providers_enabled? || password_authentication_enabled_for_web? .omniauth-divider.gl-display-flex.gl-align-items-center = _("or sign in with") -.gl-mt-5.gl-px-5{ class: restyle_login_page_enabled ? 'omniauth-container gl-text-center gl-ml-auto gl-mr-auto' : 'omniauth-container gl-py-5' } - - if !restyle_login_page_enabled - %label.gl-font-weight-bold - = _('Sign in with') +.gl-mt-5.gl-px-5.gl-text-center.gl-display-flex.gl-flex-direction-column.gl-gap-3.js-oauth-login - enabled_button_based_providers.each do |provider| - - has_icon = provider_has_icon?(provider) - = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", data: { testid: "#{test_id_for_provider(provider)}" }, class: "btn gl-button btn-default gl-mb-2 js-oauth-login gl-w-full", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do - - if has_icon - = provider_image_tag(provider) - %span.gl-button-text - = label_for_provider(provider) + = render 'devise/shared/omniauth_provider_button', + href: omniauth_authorize_path(:user, provider), + provider: provider, + data: { testid: test_id_for_provider(provider) }, + id: "oauth-login-#{provider}" - if render_remember_me = render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c| - c.with_label do diff --git a/app/views/devise/shared/_omniauth_provider_button.haml b/app/views/devise/shared/_omniauth_provider_button.haml new file mode 100644 index 00000000000..c33e2253bb1 --- /dev/null +++ b/app/views/devise/shared/_omniauth_provider_button.haml @@ -0,0 +1,7 @@ +- button_options = { class: local_assigns.fetch(:classes, nil) || nil, data: data, id: id } + += render Pajamas::ButtonComponent.new(href: href, method: :post, form: true, block: true, button_options: button_options) do + - if provider_has_icon?(provider) + = provider_image_tag(provider) + %span.gl-button-text + = label_for_provider(provider) diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index fb60b8c08eb..9eb0b773ebb 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,10 +1,7 @@ -- borderless ||= false - -.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') } +.gl-mb-3.gl-p-4 = yield :omniauth_providers_top if show_omniauth_providers = render 'devise/shared/signup_box_form', button_text: button_text, url: url, show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? - diff --git a/app/views/devise/shared/_signup_omniauth_provider_button.haml b/app/views/devise/shared/_signup_omniauth_provider_button.haml index 74f009a97d3..9870e90cfff 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_button.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_button.haml @@ -1,8 +1,6 @@ -- data = { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label } -- button_options = { class: 'js-oauth-login', data: data, id: "oauth-login-#{provider}" } - -= render Pajamas::ButtonComponent.new(href: href, method: :post, form: true, block: true, button_options: button_options) do - - if provider_has_icon?(provider) - = provider_image_tag(provider) - %span.gl-button-text - = label_for_provider(provider) += render 'devise/shared/omniauth_provider_button', + href: href, + provider: provider, + classes: 'js-track-omni-auth', + data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, + id: "oauth-login-#{provider}" diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index 9916d3fa026..c1026c0f431 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -1,21 +1,9 @@ -- if Feature.enabled?(:restyle_login_page, @project) - .gl-text-center.gl-pt-5 - %label.gl-font-weight-normal - = _("Register with:") - .gl-display-flex.gl-flex-direction-column.gl-gap-3 - - providers.each do |provider| - = render 'devise/shared/signup_omniauth_provider_button', - href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), - provider: provider, - tracking_label: tracking_label - - -- else - %label.gl-font-weight-bold - = _("Create an account using:") +.gl-text-center.gl-pt-5 + %label.gl-font-weight-normal + = _("Register with:") .gl-display-flex.gl-flex-direction-column.gl-gap-3 - providers.each do |provider| = render 'devise/shared/signup_omniauth_provider_button', - href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), - provider: provider, - tracking_label: tracking_label + href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), + provider: provider, + tracking_label: tracking_label diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml index 4e62c10b258..263e11ab341 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -1,6 +1,3 @@ -- if Feature.disabled?(:restyle_login_page, @project) - .omniauth-divider.gl-display-flex.gl-align-items-center - = _("or") = render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers, tracking_label: oauth_tracking_label diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index e6bc38ba6dd..3e9d60da228 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,7 +1,7 @@ - show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?) - render_signup_link = local_assigns.fetch(:render_signup_link, true) -%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: "#{'custom-provider-tabs' if any_form_based_providers_enabled?} #{'nav-links-unboxed' if Feature.enabled?(:restyle_login_page, @project)}" } +%ul.nav-links.new-session-tabs.nav-tabs.nav.nav-links-unboxed - if crowd_enabled? %li.nav-item = link_to _("Crowd"), "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab', role: 'tab' 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 3749dc66a04..5d5a5a64c29 100644 --- a/app/views/devise/shared/_terms_of_service_notice.html.haml +++ b/app/views/devise/shared/_terms_of_service_notice.html.haml @@ -1,17 +1,9 @@ - return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms? %p.gl-text-gray-500.gl-mt-5.gl-mb-0 - - if Feature.enabled?(:restyle_login_page, @project) - - if Gitlab.com? - = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie 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 - = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie 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 } + - if Gitlab.com? + = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie 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 - - 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 Statement%{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 - = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Statement%{link_end}")) % { button_text: button_text, - link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } + = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie 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 } diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml index 8bae27020c2..393f42cd197 100644 --- a/app/views/devise/unlocks/new.html.haml +++ b/app/views/devise/unlocks/new.html.haml @@ -11,8 +11,5 @@ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do = _('Resend unlock instructions') -- if Feature.enabled?(:restyle_login_page, @project) - = render 'devise/shared/sign_in_link' -- else - .gl-mt-3 - = render 'devise/shared/sign_in_link' += render 'devise/shared/sign_in_link' + diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml index c39f5cf87c7..43a8ccdaae4 100644 --- a/app/views/groups/_import_group_from_file_panel.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -10,14 +10,14 @@ alert_options: { class: 'gl-mb-5' }, dismissible: false) do |c| - c.with_body do - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') } + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index') } - link_end = '</a>'.html_safe = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end } = render 'shared/groups/group_name_and_path_fields', f: f .form-group = f.label :file, s_('GroupsNew|Upload file') .gl-font-weight-normal - - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/import/index') } + - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/project/settings/import_export', anchor: 'migrate-groups-by-uploading-an-export-file-deprecated') } = s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe } .gl-mt-3 = render 'shared/file_picker_button', f: f, field: :file, help_text: nil diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index ff1d76f470c..c7909a7c249 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -10,7 +10,7 @@ - c.with_body do = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| - c.with_body do - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') } + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index') } - docs_link_end = '</a>'.html_safe = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } %p diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 4334c4996f2..fae0c41b683 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -49,6 +49,7 @@ = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render 'groups/settings/membership', f: f, group: @group = render_if_exists 'groups/settings/security_policies_custom_ci', f: f, group: @group + = render_if_exists 'groups/settings/security_policies_policy_scope', f: f, group: @group %h5= _('Customer relations') .form-group.gl-mb-3 @@ -57,4 +58,12 @@ checkbox_options: { checked: @group.crm_enabled? }, help_text: s_('GroupSettings|Organizations and contacts can be created and associated with issues.') + - if Feature.enabled?(:group_hierarchy_optimization, @group, type: :beta) + %h5= _('Performance') + .form-group.gl-mb-3 + = f.gitlab_ui_checkbox_component :enable_namespace_descendants_cache, + s_('GroupSettings|Enable caching of hierarchical objects (subgroups and projects) to improve the performance of group-level features within a large group.'), + checkbox_options: { checked: @group.namespace_descendants.present? }, + help_text: s_('GroupSettings|Building the cache is asynchronous, happens in a background job. The cache invalidation is synchronous with strong consistency guarantees.') + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mt-3 js-dirty-submit', data: { testid: 'save-permissions-changes-button' } diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index 97acafe24d0..0f56ae92557 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -12,4 +12,4 @@ cancel_path: cancel_import_github_path, details_path: details_import_github_path, status_import_github_group_path: status_import_github_group_path(format: :json), - optional_stages: Gitlab::GithubImport::Settings.stages_array + optional_stages: Gitlab::GithubImport::Settings.stages_array(current_user) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 5f038ac467d..79fa5bfeac0 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -19,7 +19,6 @@ = yield :prefetch_asset_tags - diffs_colors = user_diffs_colors - = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename = render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path - if user_application_theme == 'gl-dark' diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index a7caa797a46..3af04db4cfd 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,16 +1,13 @@ .layout-page{ class: page_with_sidebar_class } - - if show_super_sidebar? - -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new. - - group = @parent_group || @group + -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new. + - group = @parent_group || @group - - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization) - - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json - %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } + - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization) + - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json + %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } - = render_if_exists "layouts/tanuki_bot_chat" + = render_if_exists "layouts/tanuki_bot_chat" - - elsif defined?(nav) && nav - = render "layouts/nav/sidebar/#{nav}" .content-wrapper{ class: "#{@content_wrapper_class}" } .mobile-overlay = dispensable_render_if_exists 'layouts/header/verification_reminder' diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index 3582deea902..503b38496f7 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -2,7 +2,7 @@ - namespace = @group || @project&.namespace || @namespace = webpack_bundle_tag 'tracker' -- if Gitlab.com? && Feature.enabled?(:browsersdk_tracking) && Feature.enabled?(:gl_analytics_tracking, Feature.current_request) +- if Gitlab.com? && Feature.enabled?(:gl_analytics_tracking, Feature.current_request) = webpack_bundle_tag 'analytics' = javascript_tag do :plain @@ -13,7 +13,6 @@ namespace_id: namespace&.id, plan_name: namespace&.actual_plan_name, project_id: @project&.id, - user_id: current_user&.id, - new_nav: show_super_sidebar? + user_id: current_user&.id ).to_context.to_json.to_json} gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json}; diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 78fa40167f8..b9257bcedc9 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,11 +1,13 @@ - page_classes = page_class << @html_class - page_classes = [user_application_theme, page_classes.flatten.compact] - body_classes = [user_tab_width, @body_class, client_class_list, *custom_diff_color_classes] +- ff_simplified_labels_enabled = Feature.enabled?(:simplified_labels) ? 'ff-simplified-labels-enabled' : '' +- ff_simplified_badges_class = Feature.enabled?(:simplified_badges) ? 'ff-simplified-badges-enabled' : '' !!! 5 %html{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ class: body_classes, data: body_data } + %body{ class: [body_classes, ff_simplified_labels_enabled, ff_simplified_badges_class], data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render "layouts/visual_review" if review_apps_enabled? diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 920771bf4c2..2905ba924ca 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -6,63 +6,31 @@ %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, testid: 'login-page' } } = header_message = render "layouts/init_client_detection_flags" - - if Feature.enabled?(:restyle_login_page, @project) - = yield :sessions_broadcast - .gl-h-full.borderless.gl-display-flex.gl-flex-wrap - .container - .content - = render "layouts/flash" - - if custom_text.present? - .row - .col-md.order-12.sm-bg-gray - .col-sm-12 - %h1.mb-3.gl-font-size-h2 - = brand_title - = custom_text - .col-md.order-md-12 - .col-sm-12.bar - .gl-text-center - = brand_image - = yield - - else - .mt-3 - .col-sm-12.gl-text-center - = brand_image + = yield :sessions_broadcast + .gl-h-full.borderless.gl-display-flex.gl-flex-wrap + .container.gl-align-self-center + .content + = render "layouts/flash" + - if custom_text.present? + .row + .col-md.order-12.sm-bg-gray + .col-sm-12 %h1.mb-3.gl-font-size-h2 = brand_title - .mb-3 - .gl-w-full.gl-sm-w-half.gl-ml-auto.gl-mr-auto.bar + = custom_text + .col-md.order-md-12 + .col-sm-12.bar + .gl-text-center + = brand_image = yield - - = render 'devise/shared/footer' - - else - = render "layouts/header/empty" - = yield :sessions_broadcast - .gl-h-full.gl-display-flex.gl-flex-wrap - .container - .content - = render "layouts/flash" - .row.mt-3 - .col-sm-12 - %h1.mb-3.font-weight-normal - = current_appearance&.title.presence || _('GitLab') - .row.mb-3 - .col-md-6.order-12.order-sm-1.brand-holder - - unless recently_confirmed_com? - = brand_image - - if custom_text.present? - = custom_text - - else - %h3.gl-sm-mt-0 - = _('A complete DevOps platform') - - %p - = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.') - - %p - = _('This is a self-managed instance of GitLab.') - - .col-md-6.order-1{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' } + - else + .mt-3 + .col-sm-12.gl-text-center + = brand_image + %h1.mb-3.gl-font-size-h2 + = brand_title + .mb-3 + .gl-w-full.gl-sm-w-half.gl-ml-auto.gl-mr-auto.bar = yield - = render 'devise/shared/footer' + = render 'devise/shared/footer' diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 6816a64ac8f..faf45ae78ef 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -7,7 +7,7 @@ = render "layouts/init_client_detection_flags" = render "layouts/header/empty" .gl-h-full.gl-display-flex.gl-flex-wrap - .container + .container.gl-align-self-center .content = render "layouts/flash" = yield diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 498e9216894..28305960de9 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -23,5 +23,6 @@ = dispensable_render_if_exists "shared/free_user_cap_alert", source: @group = dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group = dispensable_render_if_exists "shared/code_suggestions_ga_non_owner_alert", resource: @group += dispensable_render_if_exists "shared/code_suggestions_ga_owner_alert", resource: @group = render template: base_layout || "layouts/application" diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml index fc63400e011..9b0424ea478 100644 --- a/app/views/layouts/header/_super_sidebar_logged_out.haml +++ b/app/views/layouts/header/_super_sidebar_logged_out.haml @@ -2,7 +2,7 @@ %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid %nav.header-logged-out-nav.gl-display-flex.gl-gap-3.gl-justify-content-space-between{ 'aria-label': s_('LoggedOutMarketingHeader|Explore GitLab') } - .header-logged-out-logo.gl-display-flex.gl-align-items-center + .header-logged-out-logo.gl-display-flex.gl-align-items-center.gl-gap-3 %span.gl-sr-only GitLab = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', aria: { label: _('Homepage') }, **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do = brand_header_logo diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml index 3894501bbbb..37bf8515a8c 100644 --- a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml @@ -1,8 +1,8 @@ - dropdown_location = local_assigns.fetch(:location, nil) - button_tooltip = local_assigns.fetch(:title, _("Show all breadcrumbs")) - if defined?(@breadcrumb_collapsed_links) && @breadcrumb_collapsed_links.key?(dropdown_location) - %li.expander.gl-breadcrumb-item - %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip } + %li.expander.gl-breadcrumb-item.gl-display-inline-flex + %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander.gl-ml-0{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip } = sprite_icon("ellipsis_h", size: 12) - @breadcrumb_collapsed_links[dropdown_location].each_with_index do |link, index| %li.gl-breadcrumb-item{ :class => "gl-display-none!" } diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index e95d645769e..5a6f45d4dd7 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -26,5 +26,6 @@ = dispensable_render_if_exists "projects/free_user_cap_alert", project: @project = dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project = dispensable_render_if_exists 'projects/code_suggestions_ga_non_owner_alert', project: @project += dispensable_render_if_exists 'projects/code_suggestions_ga_owner_alert', project: @project = render template: "layouts/application" diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml index c8e15896b97..e3e071c2226 100644 --- a/app/views/layouts/signup_onboarding.html.haml +++ b/app/views/layouts/signup_onboarding.html.haml @@ -7,7 +7,7 @@ = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/logo_with_title" - .container + .container.gl-align-self-center .content = yield diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml index ead8e5d0a7e..b89d897a81b 100644 --- a/app/views/notify/_reassigned_issuable_email.html.haml +++ b/app/views/notify/_reassigned_issuable_email.html.haml @@ -1,7 +1,12 @@ -- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : _('Unassigned')) +- added_names = content_tag(:strong, sanitize_name(added_assignees.to_sentence(locale: I18n.locale))) +- removed_names = content_tag(:strong, sanitize_name(removed_assignees.to_sentence(locale: I18n.locale))) -%p - - if previous_assignees.any? - = html_escape(s_('Notify|Assignee changed from %{fromNames} to %{toNames}').html_safe % { fromNames: content_tag(:strong, sanitize_name(previous_assignees.map(&:name).to_sentence)), toNames: to_names }) - - else - = html_escape(s_('Notify|Assignee changed to %{toNames}').html_safe % { toNames: to_names}) +- if added_assignees.any? + %p + = html_escape(n_(s_('Notify|%{added} was added as an assignee.'), s_('Notify|%{added} were added as assignees.'), added_assignees.length).html_safe % { added: added_names }) +- if removed_assignees.any? && issuable.assignees.any? + %p + = html_escape(n_(s_('Notify|%{removed} was removed as an assignee.'), s_('Notify|%{removed} were removed as assignees.'), removed_assignees.length).html_safe % { removed: removed_names }) +- if removed_assignees.any? && issuable.assignees.empty? + %p + = html_escape(s_('Notify|All assignees were removed.')) diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml index 8a184aa9696..5b870fe2214 100644 --- a/app/views/notify/new_review_email.html.haml +++ b/app/views/notify/new_review_email.html.haml @@ -2,24 +2,41 @@ = content_for :head do = stylesheet_link_tag 'mailers/highlighted_diff_email' -%table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" } - %tbody - %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" } - %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } - %tbody - %tr - %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" } - - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request)) - - mr_author_link = link_to(@author_name, user_url(@author)) - = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link } - %tr - %td{ style: "overflow:hidden;line-height:1.4;display:grid;" } - - @notes.each do |note| - -# Get preloaded note discussion - - discussion = @discussions[note.discussion_id] if note.part_of_discussion? - -# Preload project for discussions first note - - discussion.first_note.project = @project if discussion&.first_note - - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") - = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author - = render_if_exists 'notify/review_summary' +- if Feature.enabled?(:enhanced_review_email, @project, type: :gitlab_com_derisk) + %div{ style: "color:#333333;border-bottom:8px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" } + - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request)) + - mr_author_link = link_to(@author_name, user_url(@author)) + = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link } + + - @notes.each do |note| + -# Get preloaded note discussion + - discussion = @discussions[note.discussion_id] if note.part_of_discussion? + -# Preload project for discussions first note + - discussion.first_note.project = @project if discussion&.first_note + - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") + = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:4px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author + = render_if_exists 'notify/review_summary' + +- else + + %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" } + - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request)) + - mr_author_link = link_to(@author_name, user_url(@author)) + = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link } + %tr + %td{ style: "overflow:hidden;line-height:1.4;display:grid;" } + - @notes.each do |note| + -# Get preloaded note discussion + - discussion = @discussions[note.discussion_id] if note.part_of_discussion? + -# Preload project for discussions first note + - discussion.first_note.project = @project if discussion&.first_note + - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") + = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author + = render_if_exists 'notify/review_summary' diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 6b088927623..e5860c1672a 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1 +1 @@ -= render 'reassigned_issuable_email', issuable: @issue, previous_assignees: @previous_assignees += render 'reassigned_issuable_email', issuable: @issue, added_assignees: @added_assignees, removed_assignees: @removed_assignees diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index f37c8ffa515..b5e5abbbccf 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1,6 +1,15 @@ +<% added_names = sanitize_name(@added_assignees.to_sentence(locale: I18n.locale)) -%> +<% removed_names = sanitize_name(@removed_assignees.to_sentence(locale: I18n.locale)) -%> Reassigned Issue <%= @issue.iid %> <%= url_for([@issue.project, @issue, { only_path: false }]) %> -Assignee changed<%= " from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> - to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> +<%- if @added_assignees.any? %> +<%= html_escape(n_(s_('Notify|%{added} was added as an assignee.'), s_('Notify|%{added} were added as assignees.'), @added_assignees.length).html_safe % { added: added_names }) %> +<% end -%> +<%- if @removed_assignees.any? && @issue.assignees.any? %> +<%= html_escape(n_(s_('Notify|%{removed} was removed as an assignee.'), s_('Notify|%{removed} were removed as assignees.'), @removed_assignees.length).html_safe % { removed: removed_names }) %> +<% end -%> +<%- if @removed_assignees.any? && @issue.assignees.empty? %> +<%= html_escape(s_('Notify|All assignees were removed.')) %> +<% end -%> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 0aefca6b14a..74de6767fe2 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1 +1 @@ -= render 'reassigned_issuable_email', issuable: @merge_request, previous_assignees: @previous_assignees += render 'reassigned_issuable_email', issuable: @merge_request, added_assignees: @added_assignees, removed_assignees: @removed_assignees diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index 888b995b67c..7929349c439 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1,6 +1,15 @@ +<% added_names = sanitize_name(@added_assignees.to_sentence(locale: I18n.locale)) -%> +<% removed_names = sanitize_name(@removed_assignees.to_sentence(locale: I18n.locale)) -%> Reassigned merge request <%= @merge_request.iid %> <%= url_for([@merge_request.project, @merge_request, { only_path: false }]) %> -Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> - to <%= "#{@merge_request.assignees.any? ? @merge_request.assignee_list : 'Unassigned'}" %> +<%- if @added_assignees.any? %> +<%= html_escape(n_(s_('Notify|%{added} was added as an assignee.'), s_('Notify|%{added} were added as assignees.'), @added_assignees.length).html_safe % { added: added_names }) %> +<% end -%> +<%- if @removed_assignees.any? && @merge_request.assignees.any? %> +<%= html_escape(n_(s_('Notify|%{removed} was removed as an assignee.'), s_('Notify|%{removed} were removed as assignees.'), @removed_assignees.length).html_safe % { removed: removed_names }) %> +<% end -%> +<%- if @removed_assignees.any? && @merge_request.assignees.empty? %> +<%= html_escape(s_('Notify|All assignees were removed.')) %> +<% end -%> diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index 6f0c091dfdb..3ecdc3f63e5 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -3,26 +3,27 @@ %label.label-bold.gl-mb-0 = s_('Profiles|Connected Accounts') %p= s_('Profiles|Select a service to sign in with.') - - providers.each do |provider| - - unlink_allowed = unlink_provider_allowed?(provider) - - link_allowed = link_provider_allowed?(provider) - - has_icon = provider_has_icon?(provider) - - if unlink_allowed || link_allowed - - if auth_active?(provider) - - if unlink_allowed - = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do + .gl-display-flex.gl-flex-wrap.gl-gap-3 + - providers.each do |provider| + - unlink_allowed = unlink_provider_allowed?(provider) + - link_allowed = link_provider_allowed?(provider) + - has_icon = provider_has_icon?(provider) + - if unlink_allowed || link_allowed + - if auth_active?(provider) + - if unlink_allowed + = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do + - if has_icon + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + .gl-button-text + = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) } + - else + %a{ class: button_class } + .gl-button-text + = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) } + - elsif link_allowed + = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do - if has_icon .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) .gl-button-text - = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) } - - else - %a{ class: button_class } - .gl-button-text - = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) } - - elsif link_allowed - = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do - - if has_icon - .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) - .gl-button-text - = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) } - = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities + = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) } + = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 6dcd661ecdb..3f18a7bbda6 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -24,7 +24,7 @@ = sprite_icon('mail', css_class: 'gl-mr-2') = @emails.load.size .gl-new-card-actions - = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'toggle_email_address_field' } }) do + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'toggle-email-address-field' } }) do = s_('Profiles|Add new email') - c.with_body do .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content @@ -33,9 +33,9 @@ = gitlab_ui_form_for 'email', url: profile_emails_path do |f| .form-group = f.label :email, s_('Profiles|Email address'), class: 'label-bold' - = f.text_field :email, class: 'form-control gl-form-input gl-form-input-xl', data: { qa_selector: 'email_address_field' } + = f.text_field :email, class: 'form-control gl-form-input gl-form-input-xl', data: { testid: 'email-address-field' } .gl-mt-3 - = f.submit s_('Profiles|Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true + = f.submit s_('Profiles|Add email address'), data: { testid: 'add-email-address-button' }, pajamas_button: true = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do = _('Cancel') - if @emails.any? @@ -59,7 +59,7 @@ = s_('Profiles|Default notification email') .gl-text-secondary.gl-font-sm= notification_message.html_safe - @emails.reject(&:user_primary_email?).each do |email| - %li{ class: 'gl-px-5!', data: { qa_selector: 'email_row_content' } } + %li{ class: 'gl-px-5!', data: { testid: 'email-row-content' } } .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-gap-3 %div = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } @@ -81,4 +81,4 @@ - confirm_title = "#{email.confirmation_sent_at ? s_('Profiles|Resend confirmation email') : s_('Profiles|Send confirmation email')}" = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small - = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), confirm_btn_variant: 'danger', qa_selector: 'delete_email_link'}, method: :delete, size: :small, icon: 'remove', 'aria-label': _('Remove') + = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), confirm_btn_variant: 'danger', testid: 'delete-email-link'}, method: :delete, size: :small, icon: 'remove', 'aria-label': _('Remove') diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml index 0a50ce55b50..ea7068e0484 100644 --- a/app/views/profiles/gpg_keys/_key_table.html.haml +++ b/app/views/profiles/gpg_keys/_key_table.html.haml @@ -3,7 +3,7 @@ - if @gpg_keys.any? .table-holder - %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } } + %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list %thead.d-none.d-md-table-header-group %tr %th= s_('Profiles|Key') diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 96375412f94..0d1e911f29d 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -9,9 +9,6 @@ - data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path } - @force_desktop_expanded_sidebar = true -- Gitlab::Themes.each do |theme| - = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename - = gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f| = render_if_exists 'profiles/preferences/code_suggestions_settings_self_assignment' .settings-section.js-preferences-form.js-search-settings-section.application-theme#navigation-theme @@ -173,7 +170,6 @@ .form-group = f.gitlab_ui_checkbox_component :enabled_following, s_('Preferences|Enable follow users') - = render_if_exists 'profiles/preferences/code_suggestions_settings', form: f = render_if_exists 'profiles/preferences/zoekt_settings', form: f #js-profile-preferences-app{ data: data_attributes } diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 79b2726ed2d..9f33ad0c2d4 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -66,7 +66,7 @@ %h4.gl-my-0= s_("Profiles|Time settings") %p.gl-text-secondary= s_("Profiles|Set your local time zone.") = f.label :user_timezone, _("Time zone") - .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } + .js-timezone-dropdown{ data: { timezone_data: timezone_data_with_unique_identifiers.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } .settings-section.js-search-settings-section.gl-border-t.gl-pt-6 .settings-sticky-header diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 63226838166..0bf05c85f5f 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -14,8 +14,6 @@ = render partial: 'shared/ci_catalog_badge', locals: { href: explore_catalog_path(@project.catalog_resource), css_class: 'gl-mx-0' } - if @project.group = render_if_exists 'shared/tier_badge', source: @project, namespace_to_track: @project.namespace - .gl-text-secondary - = render_if_exists "projects/home_mirror" .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3 - if current_user @@ -48,7 +46,7 @@ = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link, button_options: { class: 'js-read-more-trigger gl-lg-display-none' }) do = _("Read more") - = render_if_exists "projects/home_mirror" + = render_if_exists "projects/home_mirror" - if ff_reorg_disabled && @project.badges.present? .project-badges.mb-2{ data: { testid: 'project-badges-content' } } diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 3e92ef25552..b9abaa07c2c 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -18,42 +18,42 @@ .import-buttons - if gitlab_project_import_enabled? .import_gitlab_project.has-tooltip{ data: { container: 'body', testid: 'gitlab-import-button' } } - = render Pajamas::ButtonComponent.new(href: '#', icon: 'tanuki', button_options: { class: 'btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } }) do + = render Pajamas::ButtonComponent.new(href: '#', icon: 'tanuki', button_options: { class: 'btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } }) do = _('GitLab export') - if github_import_enabled? %div - = render Pajamas::ButtonComponent.new(href: new_import_github_path(namespace_id: namespace_id), icon: 'github', button_options: { class: 'js-import-github js-import-project-btn', data: { platform: 'github', **tracking_attrs_data(track_label, 'click_button', 'github') } }) do + = render Pajamas::ButtonComponent.new(href: new_import_github_path(namespace_id: namespace_id), icon: 'github', button_options: { class: 'js-import-github js-import-project-btn', data: { platform: 'github', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'github') } }) do GitHub - if bitbucket_import_enabled? %div - = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: "import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } }) do + = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: "import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } }) do Bitbucket Cloud - if bitbucket_server_import_enabled? %div - = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_server_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: 'import_bitbucket js-import-project-btn', data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } }) do + = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_server_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: 'import_bitbucket js-import-project-btn', data: { platform: 'bitbucket_server', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } }) do Bitbucket Server - if fogbugz_import_enabled? %div - = render Pajamas::ButtonComponent.new(href: new_import_fogbugz_path(namespace_id: namespace_id), icon: 'bug', button_options: { class: 'import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } }) do + = render Pajamas::ButtonComponent.new(href: new_import_fogbugz_path(namespace_id: namespace_id), icon: 'bug', button_options: { class: 'import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } }) do FogBugz - if gitea_import_enabled? %div - = render Pajamas::ButtonComponent.new(href: new_import_gitea_path(namespace_id: namespace_id), icon: 'gitea', button_options: { class: 'import_gitea js-import-project-btn', data: { platform: 'gitea', **tracking_attrs_data(track_label, 'click_button', 'gitea') } }) do + = render Pajamas::ButtonComponent.new(href: new_import_gitea_path(namespace_id: namespace_id), icon: 'gitea', button_options: { class: 'import_gitea js-import-project-btn', data: { platform: 'gitea', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'gitea') } }) do Gitea - if git_import_enabled? %div - = render Pajamas::ButtonComponent.new(icon: 'link', button_options: { class: 'js-toggle-button js-import-git-toggle-button js-import-project-btn', data: { platform: 'repo_url', toggle_open_class: 'active', **tracking_attrs_data(track_label, 'click_button', 'repo_url') } }) do + = render Pajamas::ButtonComponent.new(icon: 'link', button_options: { class: 'js-toggle-button js-import-git-toggle-button js-import-project-btn', data: { platform: 'repo_url', toggle_open_class: 'active', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'repo_url') } }) do = _('Repository by URL') - if manifest_import_enabled? %div - = render Pajamas::ButtonComponent.new(href: new_import_manifest_path(namespace_id: namespace_id), icon: 'doc-text', button_options: { class: 'import_manifest js-import-project-btn', data: { platform: 'manifest_file', **tracking_attrs_data(track_label, 'click_button', 'manifest_file') } }) do + = render Pajamas::ButtonComponent.new(href: new_import_manifest_path(namespace_id: namespace_id), icon: 'doc-text', button_options: { class: 'import_manifest js-import-project-btn', data: { platform: 'manifest_file', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'manifest_file') } }) do = _('Manifest file') = render_if_exists "projects/gitee_import_button", namespace_id: namespace_id, track_label: track_label @@ -63,4 +63,4 @@ = gitlab_ui_form_for @project, html: { class: 'new_project gl-show-field-errors js-project-import' } do |f| %hr = render "shared/import_form", f: f - = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label + = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: 'import_project' diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml index c3d66396256..fc9ddb650e9 100644 --- a/app/views/projects/_readme.html.haml +++ b/app/views/projects/_readme.html.haml @@ -1,5 +1,5 @@ - if (readme = @repository.readme) && readme.rich_viewer - .tree-holder + .tree-holder.gl-mt-5 .nav-block.mt-0 = render 'projects/tree/tree_header', tree: @tree %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) } diff --git a/app/views/projects/_sidebar.html.haml b/app/views/projects/_sidebar.html.haml index 565f14d01d9..7cb2f622788 100644 --- a/app/views/projects/_sidebar.html.haml +++ b/app/views/projects/_sidebar.html.haml @@ -2,43 +2,44 @@ - show_auto_devops_callout = show_auto_devops_callout?(@project) %aside.project-page-sidebar - - if @project.description.present? || @project.badges.present? - .project-page-sidebar-block.home-panel-home-desc.gl-py-4.gl-border-b.gl-border-gray-50 - -# Project description - - if @project.description.present? - .gl-display-flex.gl-justify-content-space-between.gl-mt-1.gl-pr-2 - %p.gl-font-weight-bold.gl-text-gray-900.gl-m-0= s_('ProjectPage|Project information') - = render Pajamas::ButtonComponent.new(href: edit_project_path(@project), - category: :tertiary, - icon: 'settings', - size: :small, - button_options: { class: 'has-tooltip', title: s_('ProjectPage|Project settings'), 'aria-label' => s_('ProjectPage|Project settings') }) - .home-panel-description.text-break - .home-panel-description-markdown{ itemprop: 'description' } - = markdown_field(@project, :description) + .project-page-sidebar-block.home-panel-home-desc.gl-py-4.gl-border-b.gl-border-gray-50{ class: 'gl-pt-2!' } + .gl-display-flex.gl-justify-content-space-between + %p.gl-font-weight-bold.gl-text-gray-900.gl-m-0.gl-mb-1= s_('ProjectPage|Project information') + -# Project settings + - if can?(current_user, :admin_project, @project) + = render Pajamas::ButtonComponent.new(href: edit_project_path(@project), + category: :tertiary, + icon: 'settings', + size: :small, + button_options: { class: 'has-tooltip gl-ml-2 gl-sm-mr-3', title: s_('ProjectPage|Project settings'), 'aria-label' => s_('ProjectPage|Project settings'), 'data-testid': 'project-settings-button' }) + -# Project description + - if @project.description.present? + .home-panel-description.text-break + .home-panel-description-markdown{ itemprop: 'description' } + = markdown_field(@project, :description) - -# Topics - - if @project.topics.present? - .gl-mb-5 - = render "shared/projects/topics", project: @project + -# Topics + - if @project.topics.present? + .gl-mb-5 + = render "shared/projects/topics", project: @project - -# Programming languages - - if can?(current_user, :read_code, @project) && @project.repository_languages.present? - .gl-mb-2{ class: ('gl-mb-4!' if @project.badges.present?) } - = repository_languages_bar(@project.repository_languages) + -# Programming languages + - if can?(current_user, :read_code, @project) && @project.repository_languages.present? + .gl-mb-2{ class: [('gl-mb-4!' if @project.badges.present?), ('gl-mt-3' if !@project.description.present?)] } + = repository_languages_bar(@project.repository_languages) - -# Badges - - if @project.badges.present? - .project-badges.gl-mb-2{ data: { testid: 'project-badges-content' } } - - @project.badges.each do |badge| - - badge_link_url = badge.rendered_link_url(@project) - %a.gl-mr-3{ href: badge_link_url, - target: '_blank', - rel: 'noopener noreferrer', - data: { testid: 'badge-image-link', qa_link_url: badge_link_url } }> - %img.project-badge{ src: badge.rendered_image_url(@project), - 'aria-hidden': true, - alt: 'Project badge' }> + -# Badges + - if @project.badges.present? + .project-badges.gl-mb-2{ data: { testid: 'project-badges-content' } } + - @project.badges.each do |badge| + - badge_link_url = badge.rendered_link_url(@project) + %a.gl-mr-3{ href: badge_link_url, + target: '_blank', + rel: 'noopener noreferrer', + data: { testid: 'badge-image-link', qa_link_url: badge_link_url } }> + %img.project-badge{ src: badge.rendered_image_url(@project), + 'aria-hidden': true, + alt: 'Project badge' }> -# Invite members - if @project.empty_repo? diff --git a/app/views/projects/buttons/_code.html.haml b/app/views/projects/buttons/_code.html.haml index a78e3861e94..9cdbe1d5f6b 100644 --- a/app/views/projects/buttons/_code.html.haml +++ b/app/views/projects/buttons/_code.html.haml @@ -8,9 +8,9 @@ %span.js-clone-dropdown-label = _('Code') = sprite_icon("chevron-down", css_class: "icon") - %ul.dropdown-menu.dropdown-menu-large.clone-options-dropdown{ class: dropdown_class, data: { testid: 'clone-dropdown-content' } } + %ul.dropdown-menu.dropdown-menu-large.clone-options-dropdown{ role: 'menu', class: dropdown_class, data: { testid: 'clone-dropdown-content' } } - if ssh_enabled? - %li.gl-dropdown-item.js-clone-links{ class: 'gl-px-4!' } + %li.gl-dropdown-item.js-clone-links{ role: 'menuitem', class: 'gl-px-4!' } %label.label-bold = _('Clone with SSH') .input-group.btn-group @@ -19,7 +19,7 @@ = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), category: :primary, size: :medium) = render_if_exists 'projects/buttons/geo' - if http_enabled? - %li.pt-2.gl-dropdown-item.js-clone-links{ class: 'gl-px-4!' } + %li.pt-2.gl-dropdown-item.js-clone-links{ role: 'menuitem', class: 'gl-px-4!' } %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group.btn-group @@ -28,8 +28,8 @@ = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), category: :primary, size: :medium) = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' - %li.divider.mt-2 - %li.pt-2.gl-dropdown-item.js-clone-links + %li.divider.mt-2{ role: 'presentation' } + %li.pt-2.gl-dropdown-item.js-clone-links{ role: 'menuitem' } %label.label-bold{ class: 'gl-px-4!' } = _('Open in your IDE') - if ssh_enabled? @@ -55,5 +55,5 @@ .gl-dropdown-item-text-wrapper = _("Xcode") - if !project.empty_repo? && can?(current_user, :download_code, project) - %li.divider.mt-2 + %li.divider.mt-2{ role: 'presentation' } = render 'projects/buttons/download_menu_items', project: project, ref: ref diff --git a/app/views/projects/buttons/_download_menu_items.html.haml b/app/views/projects/buttons/_download_menu_items.html.haml index f5f8efca073..7d7033da9cd 100644 --- a/app/views/projects/buttons/_download_menu_items.html.haml +++ b/app/views/projects/buttons/_download_menu_items.html.haml @@ -2,7 +2,7 @@ - ref = local_assigns.fetch(:ref) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" -%li.gl-dropdown-item{ role: 'menuitem' } - %h3.h5.m-0.dropdown-bold-header= _('Download source code') +%li.gl-dropdown-item{ class: 'gl-pt-3!', role: 'menuitem' } + %label.label-bold{ class: 'gl-px-4!' }= _('Download source code') = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil .js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index be0e5a428b4..fc9104f9f27 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -18,7 +18,6 @@ = commit_committer_link(@commit, avatar: true, size: 24) #{time_ago_with_tooltip(@commit.committed_date)} - #js-commit-comments-button{ data: { comments_count: @notes_count.to_i } } = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-w-full gl-sm-w-auto gl-mb-3 gl-sm-mb-0' #js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) } @@ -36,7 +35,7 @@ %span.cgray= n_('parent', 'parents', @commit.parents.count) - @commit.parents.each do |parent| = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha" - #js-commit-branches-and-tags{ data: { full_path: @project.full_path, commit_sha: @commit.short_id } } + #js-commit-branches-and-tags{ data: { full_path: @project.full_path, commit_sha: @commit.short_id } } .well-segment.merge-request-info .icon-container diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 9269369c83e..90837a1a291 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -118,8 +118,11 @@ = render 'remove_fork', project: @project = render 'remove', project: @project -- elsif can?(current_user, :archive_project, @project) - = render_if_exists 'projects/settings/archive' +- else + - if can?(current_user, :archive_project, @project) + = render_if_exists 'projects/settings/archive' + - if can?(current_user, :remove_project, @project) + = render 'remove', project: @project .save-project-loader.hide .center diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 684ea8242f7..ac3b67d6157 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -20,7 +20,7 @@ .project-clone-holder.d-block.d-sm-none = render "shared/mobile_clone_panel" - .project-clone-holder.gl-display-none.gl-sm-display-flex.gl-justify-content-end.gl-w-full.gl-mt-2 + .project-clone-holder.gl-display-none.gl-sm-display-flex.gl-justify-content-end.gl-w-full = render "projects/buttons/code", ref: @ref = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-new-card-body gl-bg-gray-10 gl-p-5' }) do |c| diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml index 2b4d19a0e1d..54855999431 100644 --- a/app/views/projects/environments/folder.html.haml +++ b/app/views/projects/environments/folder.html.haml @@ -3,4 +3,4 @@ - page_title _("Environments in %{name}") % { name: @folder } - add_page_specific_style 'page_bundles/environments' -#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data, project_path: @project.full_path } } +#environments-folder-list-view{ data: environments_folder_list_view_data(@project, @folder) } diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 98055534a27..10ca730ac11 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -16,7 +16,7 @@ .dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0 .btn-group{ role: 'group' } - = gl_redirect_listbox_tag [created_at, activity], @sort + = gl_redirect_listbox_tag [created_at, activity], @sort, class: 'btn-group' = forks_sort_direction_button(sort_value) - if current_user && can?(current_user, :fork_project, @project) diff --git a/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml index 0118fe94810..750dea9896f 100644 --- a/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml +++ b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml @@ -11,8 +11,8 @@ Full name: #{docker_image.name} .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 = sprite_icon('earth', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') - URI: - %a{ href: docker_image.uri, target: 'blank', rel: 'noopener noreferrer' }= docker_image.uri + %a{ href: docker_image.details_url, target: 'blank', rel: 'noopener noreferrer' } + Artifact Registry details page .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 = sprite_icon('doc-code', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') Media Type: #{docker_image.media_type} diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml index bfa33f26453..c03b1ac1b28 100644 --- a/app/views/projects/merge_requests/_code_dropdown.html.haml +++ b/app/views/projects/merge_requests/_code_dropdown.html.haml @@ -1,8 +1,8 @@ .gl-md-ml-3.dropdown.gl-dropdown{ class: "gl-display-none! gl-md-display-flex!" } #js-check-out-modal{ data: how_merge_modal_data(@merge_request) } - = button_tag type: 'button', class: "btn dropdown-toggle btn-confirm gl-button gl-dropdown-toggle", data: { toggle: 'dropdown', testid: 'mr-code-dropdown' } do - %span.gl-dropdown-button-text= _('Code') - = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon gl-ml-2 gl-mr-0!" + = render Pajamas::ButtonComponent.new(category: :primary, variant: :confirm, button_options: { data: { toggle: 'dropdown', testid: 'mr-code-dropdown' } }) do + = _('Code') + = sprite_icon "chevron-down", size: 16, css_class: "gl-icon gl-mr-0!" .dropdown-menu.dropdown-menu-right .gl-dropdown-inner .gl-dropdown-contents diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 1b0aba8d496..03c850b7fbb 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -24,7 +24,10 @@ .detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex - if can_update_merge_request - = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { testid: "edit-title-button" }}) do + - edit_action_description = _('Edit merge request') + - edit_action_shortcut = 'e' + - edit_button_title = "#{edit_action_description} <kbd class='flat ml-1' aria-hidden=true>#{edit_action_shortcut}</kbd>" + = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: { aria: {label: edit_action_description, keyshortcuts: edit_action_shortcut}, class: "gl-display-none gl-md-display-block has-tooltip js-issuable-edit", data: { html: "true", testid: "edit-title-button" }, title: edit_button_title }) do = _('Edit') - if @merge_request.source_project diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 03a1f2f3179..af8ad22fa50 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -16,6 +16,7 @@ - add_page_specific_style 'page_bundles/ci_status' - add_page_startup_api_call @endpoint_metadata_url +- add_page_startup_api_call @pinned_file_url if @pinned_file_url - if mr_action == 'diffs' && !@file_by_file_default - add_page_startup_api_call @endpoint_diff_batch_url diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml index f2c2700b012..4f722ba901d 100644 --- a/app/views/projects/merge_requests/creations/new.html.haml +++ b/app/views/projects/merge_requests/creations/new.html.haml @@ -11,7 +11,7 @@ = render 'new_submit' - else - if conflicting_mr - - link_to_mr = link_to(conflicting_mr.to_reference, project_merge_request_path(@project, conflicting_mr)) + - link_to_mr = link_to(conflicting_mr.to_reference, project_merge_request_path(conflicting_mr.target_project, conflicting_mr)) - flash.now[:alert] = safe_format(s_("These branches already have an open merge request: %{link_to_mr}. Select a different source or target branch."), link_to_mr: link_to_mr) = render 'new_compare' diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml index 5e3c4889d1d..ab0786a6f5b 100644 --- a/app/views/projects/mirrors/_mirror_repos_list.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -29,7 +29,7 @@ - if mirror.disabled? = render 'projects/mirrors/disabled_mirror_badge' - if mirror.last_error.present? - = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', testid: 'mirror-error-badge-content' }, title: html_escape(mirror.last_error.try(:strip)) } + = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', testid: 'mirror-error-badge-content' }, title: html_escape(mirror.last_error.try(:strip)), tabindex: 0 } %td - if mirror_settings_enabled .btn-group.mirror-actions-group{ role: 'group' } diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml index ffe7ee3397e..ba695bce435 100644 --- a/app/views/projects/ml/models/index.html.haml +++ b/app/views/projects/ml/models/index.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title s_('ModelRegistry|Model registry') - page_title s_('ModelRegistry|Model registry') -= render(Projects::Ml::ModelsIndexComponent.new(paginator: @paginator, model_count: @model_count)) += render(Projects::Ml::ModelsIndexComponent.new(project: @project, current_user: current_user, paginator: @paginator, model_count: @model_count)) diff --git a/app/views/projects/ml/models/new.html.haml b/app/views/projects/ml/models/new.html.haml new file mode 100644 index 00000000000..8510ffd42fd --- /dev/null +++ b/app/views/projects/ml/models/new.html.haml @@ -0,0 +1,5 @@ +- breadcrumb_title s_('ModelRegistry|New model') +- page_title s_('ModelRegistry|New model') +- view_model = Gitlab::Json.generate({ projectPath: @project.full_path }) + +#js-mount-new-ml-model{ data: { view_model: view_model } } diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml index bec35dba147..360ef01620b 100644 --- a/app/views/projects/pages_domains/_dns.html.haml +++ b/app/views/projects/pages_domains/_dns.html.haml @@ -9,7 +9,7 @@ .input-group = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true .input-group-append - = deprecated_clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') + = clipboard_button(target: '#domain_dns', category: :primary, size: :medium) %p.form-text.text-muted = _("To access this domain create a new DNS record") - if verification_enabled @@ -25,7 +25,7 @@ .input-group = text_field_tag :domain_verification, domain_presenter.verification_record, class: "monospace js-select-on-focus form-control", readonly: true .input-group-append - = deprecated_clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') + = clipboard_button(target: '#domain_verification', category: :primary, size: :medium) %p.form-text.text-muted - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: '4-verify-the-domains-ownership')) = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration within seven days.").html_safe % { link_to_help: link_to_help } diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml index c42367f45c5..6e11b625490 100644 --- a/app/views/search/results/_blob_data.html.haml +++ b/app/views/search/results/_blob_data.html.haml @@ -1,6 +1,6 @@ -.js-blob-result.gl-mt-3.gl-mb-5{ data: { qa_selector: 'result_item_content' } } +.js-blob-result.gl-mt-3.gl-mb-5{ data: { testid: 'result-item-content' } } .file-holder.file-holder-top-border - .js-file-title.file-title{ data: { qa_selector: 'file_title_content' } } + .js-file-title.file-title{ data: { testid: 'file-title-content' } } = link_to blob_link, data: {track_action: 'click_text', track_label: 'blob_path', track_property: 'search_result'} do = sprite_icon('document') %strong @@ -8,7 +8,7 @@ = copy_file_path_button(path) - if blob.data - if blob.data.size > 0 - .file-content.code.term{ data: { qa_selector: 'file_text_content' } } + .file-content.code.term{ data: { testid: 'file-text-content' } } = render 'search/results/blob_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, blame_link: blame_link, highlight_line: blob.highlight_line - else .file-content.code diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml index 37ffabad717..2a10bc1989b 100644 --- a/app/views/search/results/_blob_highlight.html.haml +++ b/app/views/search/results/_blob_highlight.html.haml @@ -3,7 +3,7 @@ #search-blob-content.file-content.code.js-syntax-highlight{ class: 'gl-py-3!' } - if blob.present? - .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } } + .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight } } - blob_highlight = blob.present.highlight_and_trim(trim_length: 1024, ellipsis_svg: sprite_icon('ellipsis_h', size: 12, css_class: "gl-text-gray-700")) - blob_highlight.lines.each_with_index do |line, index| - i = index + offset diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 16e4ff4d17f..3e2373446ca 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -14,6 +14,7 @@ = _("Are you sure you want to unsubscribe from the %{type}: %{link_to_noteable_text}?").html_safe % { type: noteable_type, link_to_noteable_text: link_to_noteable_text } %p - = link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true), - class: 'gl-button btn btn-confirm gl-mr-3' - = link_button_to _('Cancel'), new_user_session_path, class: 'gl-mr-3' + = render Pajamas::ButtonComponent.new(href: unsubscribe_sent_notification_path(@sent_notification, force: true), variant: 'confirm', button_options: { class: 'gl-mr-3'}) do + = _('Unsubscribe') + = render Pajamas::ButtonComponent.new(href: new_user_session_path, button_options: { class: 'gl-mr-3'}) do + = _('Cancel') diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index af09b62c229..546e92e7dcb 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,14 +1,13 @@ %div{ class: [container_class, @content_class, 'gl-pt-5!'] } = render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'), button_link: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), - svg_path: 'illustrations/autodevops.svg', + svg_path: 'illustrations/devops-sm.svg', banner_options: { class: 'js-autodevops-banner auto-devops-callout', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }, close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c| - c.with_title do = s_('AutoDevOps|Auto DevOps') - %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') - %p - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index'), target: '_blank', rel: 'noopener noreferrer') + = s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 0ff2ee935cc..05a3fd2abc9 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,5 +1,5 @@ - if show_auto_devops_implicitly_enabled_banner?(project, current_user) - = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner', data: { qa_selector: 'auto_devops_banner_content' } }, + = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner' }, close_button_options: { class: 'hide-auto-devops-implicitly-enabled-banner', data: { project_id: project.id }}) do |c| - c.with_body do diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index 9dfbad20726..3b53a4b4e89 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -16,7 +16,7 @@ %a.file-line-num.diff-line-num{ class: line_class, href: "#L#{i}", id: "L#{i}", 'data-line-number' => i } = i - .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } } + .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight } } %pre.code.highlight %code = highlighted_blob diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml index 1bac75e0ff5..b4013cb5b80 100644 --- a/app/views/shared/_visibility_radios.html.haml +++ b/app/views/shared/_visibility_radios.html.haml @@ -6,7 +6,7 @@ = form.gitlab_ui_radio_component model_method, level, "#{visibility_level_icon(level)} #{visibility_level_label(level)}".html_safe, help_text: '<span class="option-description">%{visibility_level_description}</span><span class="option-disabled-reason"></span>'.html_safe % { visibility_level_description: visibility_level_description(level, form_model)}, - radio_options: { checked: (selected_level == level), data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" } }, + radio_options: { checked: (selected_level == level), data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "" } }, label_options: { class: 'js-visibility-level-radio' } diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index 5188c530672..95c99f20380 100644 --- a/app/views/shared/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -13,4 +13,9 @@ .gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content = render @deploy_keys.form_partial_path - #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } } + #js-deploy-keys{ data: { project_id: @project.id, + project_path: @project.full_path, + enabled_endpoint: enabled_keys_project_deploy_keys_path(@project), + available_project_endpoint: available_project_keys_project_deploy_keys_path(@project), + available_public_endpoint: available_public_keys_project_deploy_keys_path(@project) + } } diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml deleted file mode 100644 index 2bc2e6c5b81..00000000000 --- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -.created-deploy-token-container.info-well{ data: { testid: 'created-deploy-token-container' } } - .well-segment - %h5.gl-mt-0 - = s_('DeployTokens|Your new Deploy Token username') - - .form-group - .input-group - = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { testid: 'deploy-token-user-field' } - .input-group-append - = deprecated_clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left') - %span.deploy-token-help-block.gl-mt-2.text-success - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_tokens/index') } - - link_end = "</a>".html_safe - = s_("DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}").html_safe % { link_start: link_start, link_end: link_end } - - .form-group - .input-group - = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { testid: 'deploy-token-field' } - .input-group-append - = deprecated_clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left') - %span.deploy-token-help-block.gl-mt-2.text-danger - - i_start = "<i>".html_safe - - i_end = "</i>".html_safe - = s_("DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.").html_safe % { i_start: i_start, i_end: i_end } diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 375e10de065..cdcbee1bb72 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -2,9 +2,8 @@ - access = user&.max_member_access_for_group(group.id) %li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!" } - .avatar-container.rect-avatar.s48.gl-flex-shrink-0 - = link_to group do - = render Pajamas::AvatarComponent.new(group, alt: group.name, size: 48) + = link_to group do + = render Pajamas::AvatarComponent.new(group, alt: group.name, size: 48, class: 'gl-mr-5') .gl-min-w-0.gl-flex-grow-1 .title = link_to group.full_name, group, class: 'group-name' @@ -23,7 +22,7 @@ %span.gl-ml-5.has-tooltip{ title: _('Users') } = sprite_icon('users', css_class: 'gl-vertical-align-text-bottom') - = number_with_delimiter(group.users.count) + = number_with_delimiter(group.group_members.non_invite.count) %span.gl-ml-5.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } = visibility_level_icon(group.visibility_level) diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml index 550f079bf3b..ecd722ec53e 100644 --- a/app/views/shared/groups/_list.html.haml +++ b/app/views/shared/groups/_list.html.haml @@ -11,6 +11,7 @@ %ul.content-list - groups.each_with_index do |group, i| = render "shared/groups/group", group: group, user: user + = paginate_collection(groups) - else = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, current_user_empty_message_header: current_user_empty_message_header, diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index 0a3fd4f8b9e..261a7517eda 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -5,5 +5,5 @@ .gl-ml-3 .btn-group{ role: 'group' } - = gl_redirect_listbox_tag(items, selected, data: { placement: 'right' }) + = gl_redirect_listbox_tag(items, selected, class: 'btn-group', data: { placement: 'right' }) = issuable_sort_direction_button(@sort) diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index c86993f5b77..42eb9e5ca19 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -9,7 +9,7 @@ -# Note this is just for individual members. For groups please see shared/members/_group -%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } +%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("flex-md-row" unless force_mobile_view)], id: dom_id(member) } %span.list-item-name.mb-2.m-md-0 - if user = render Pajamas::AvatarComponent.new(user, size: 32, class: 'gl-mr-3 flex-shrink-0 flex-grow-0') @@ -49,7 +49,7 @@ = _("Expires %{preposition} %{expires_at}").html_safe % { expires_at: time_ago_with_tooltip(member.expires_at), preposition: preposition } - else - = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' + = render Pajamas::AvatarComponent.new(Pajamas::AvatarEmail.new(member.invite_email), size: 32, class: 'gl-mr-3 flex-shrink-0 flex-grow-0') .user-info .member= member.invite_email .cgray diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 343a8597444..969ca2084d7 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -10,7 +10,7 @@ .notes.notes-form.timeline .timeline-entry.note-form .timeline-entry-inner - .flash-container.timeline-content + .flash-container .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml index e3c1ca4d9cf..4dc276a45c2 100644 --- a/app/views/shared/users/_user.html.haml +++ b/app/views/shared/users/_user.html.haml @@ -7,7 +7,7 @@ .user-info .block-truncated - = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id, qa_selector: 'user_link', qa_username: user.username } + = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id, testid: 'user-link', qa_username: user.username } .block-truncated %span.gl-text-gray-900= user.to_reference diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index ccd86937e4f..c4670a3ac73 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -11,7 +11,7 @@ - c.with_body do = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-m-3 gl-display-none js-toggle-content' } do |f| = render partial: partial, locals: { form: f, hook: @hook } - = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" } + = f.submit _('Add webhook'), pajamas_button: true = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do = _('Cancel') - if hooks.any? diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index cd752d31643..dd4ea9e72ab 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -15,7 +15,7 @@ - if can?(current_user, :create_wiki, @wiki) - edit_sidebar_url = wiki_page_path(@wiki, Wiki::SIDEBAR, action: :edit) - link_class = (editing && @page&.slug == Wiki::SIDEBAR) ? 'active' : '' - = link_to edit_sidebar_url, class: link_class, data: { qa_selector: 'edit_sidebar_link' } do + = link_to edit_sidebar_url, class: link_class do = sprite_icon('pencil', css_class: 'gl-mr-2') %span= _("Edit sidebar") diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml index 12eddb4a61e..aee359b35b3 100644 --- a/app/views/shared/wikis/git_error.html.haml +++ b/app/views/shared/wikis/git_error.html.haml @@ -8,7 +8,7 @@ .wiki-page-header.top-area.gl-flex-direction-column.gl-lg-flex-direction-row .gl-mt-5.gl-mb-3 .gl-display-flex.gl-justify-content-space-between - %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page') - .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content' } } + %h2.gl-mt-0.gl-mb-5{ data: { testid: 'wiki-page-title' } }= @page ? @page.human_title : _('Failed to retrieve page') + .js-wiki-page-content.md.gl-pt-2{ data: { testid: 'wiki-page-content' } } = _('The page could not be displayed because it timed out.') = html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe } diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index a896aa29f52..e33828b95ab 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -13,7 +13,10 @@ .nav-controls.pb-md-3.pb-lg-0 - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding - = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), button_options: { class: 'js-wiki-edit', data: { testid: 'wiki-edit-button' }}) do + - edit_action_description = _('Edit page') + - edit_action_shortcut = 'e' + - edit_button_title = "#{edit_action_description} <kbd class='flat ml-1' aria-hidden=true>#{edit_action_shortcut}</kbd>" + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), button_options: { aria: {label: edit_action_description, keyshortcuts: edit_action_shortcut}, class: 'has-tooltip js-wiki-edit', data: { html: 'true', testid: 'wiki-edit-button' }, title: edit_button_title }) do = _('Edit') = render 'shared/wikis/main_links' @@ -35,6 +38,6 @@ .gl-display-flex.gl-justify-content-space-between %h2.gl-mt-0.gl-mb-5{ data: { testid: 'wiki-page-title' } }= @page.human_title - .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } + .js-async-wiki-page-content.md.gl-pt-2{ data: { testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } = render 'shared/wikis/sidebar' diff --git a/app/views/user_settings/passwords/edit.html.haml b/app/views/user_settings/passwords/edit.html.haml index afe6ee2c0b3..179f54ac45e 100644 --- a/app/views/user_settings/passwords/edit.html.haml +++ b/app/views/user_settings/passwords/edit.html.haml @@ -18,18 +18,18 @@ - unless @user.password_automatically_set? .form-group = f.label :password, _('Current password'), class: 'label-bold' - = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'current_password_field' } + = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'current-password-field' } %p.form-text.text-muted = _('You must provide your current password in order to change it.') .form-group = f.label :new_password, _('New password'), class: 'label-bold' - = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { qa_selector: 'new_password_field' } + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { testid: 'new-password-field' } = render_if_exists 'shared/password_requirements_list' .form-group = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' - = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'confirm_password_field' } + = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'confirm-password-field' } .gl-mt-3.gl-mb-3 - = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true + = f.submit _('Save password'), class: "gl-mr-3", data: { testid: 'save-password-button' }, pajamas_button: true - unless @user.password_automatically_set? = render Pajamas::ButtonComponent.new(href: reset_user_settings_password_path, variant: :link, method: :put) do = _('I forgot my password') diff --git a/app/views/user_settings/passwords/new.html.haml b/app/views/user_settings/passwords/new.html.haml index 3616c9ec252..4b47dfa3e83 100644 --- a/app/views/user_settings/passwords/new.html.haml +++ b/app/views/user_settings/passwords/new.html.haml @@ -16,17 +16,17 @@ .col-sm-2.col-form-label = f.label :password, _('Current password') .col-sm-10 - = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } + = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { testid: 'current-password-field' } .form-group.row .col-sm-2.col-form-label = f.label :new_password, _('New password') .col-sm-10 - = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' } + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { testid: 'new-password-field' } = render_if_exists 'shared/password_requirements_list' .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation, _('Password confirmation') .col-sm-10 - = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } + = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { testid: 'confirm-password-field' } .form-actions - = f.submit _('Set new password'), data: { qa_selector: 'set_new_password_button' }, pajamas_button: true + = f.submit _('Set new password'), data: { testid: 'set-new-password-button' }, pajamas_button: true diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 99097ac397c..3aee73b0b96 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -26,7 +26,7 @@ = render 'users/view_user_in_admin_area' .js-user-profile-actions{ data: user_profile_actions_data(@user) } - .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?), ('gl-mb-4!' if show_super_sidebar?)] } + .profile-header.gl-mx-5.gl-mb-4{ class: [('gl-mb-6' if profile_tabs.empty?)] } .gl-display-inline-block.gl-mx-8.gl-vertical-align-top .avatar-holder = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do @@ -113,9 +113,9 @@ = @user.bio -# TODO: Remove this with the removal of the old navigation. - -# See https://gitlab.com/groups/gitlab-org/-/epics/11875. + -# See https://gitlab.com/gitlab-org/gitlab/-/issues/435899. - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user) - .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] } + .scrolling-tabs-container.gl-display-none %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } = sprite_icon('chevron-lg-left', size: 12) %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index ec5156bb1d0..dfad9f7f673 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -345,6 +345,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:click_house_event_authors_consistency_cron + :worker_name: ClickHouse::EventAuthorsConsistencyCronWorker + :feature_category: :value_stream_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:click_house_events_sync :worker_name: ClickHouse::EventsSyncWorker :feature_category: :value_stream_management @@ -786,6 +795,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:releases_publish_event + :worker_name: Releases::PublishEventWorker + :feature_category: :release_orchestration + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:remove_expired_group_links :worker_name: RemoveExpiredGroupLinksWorker :feature_category: :system_access @@ -1353,6 +1371,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_replay_events + :worker_name: Gitlab::GithubImport::ReplayEventsWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: github_importer:github_import_stage_finish_import :worker_name: Gitlab::GithubImport::Stage::FinishImportWorker :feature_category: :importers @@ -2593,7 +2620,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: false + :idempotent: true :tags: [] - :name: bulk_import :worker_name: BulkImportWorker diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index bfe561cca5c..5204db2159d 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -75,7 +75,7 @@ module BulkImports ::GlobalID.parse(response.dig(*entity_query.data_path, 'id')).model_id rescue StandardError => e - log_exception(e, message: 'Failed to fetch source entity id') + log_warning(e, message: 'Failed to fetch source entity id') nil end @@ -92,14 +92,21 @@ module BulkImports @logger ||= Logger.build.with_entity(entity) end - def log_exception(exception, payload) + def build_payload(exception, payload) Gitlab::ExceptionLogFormatter.format!(exception, payload) + structured_payload(payload) + end + + def log_warning(exception, payload) + logger.warn(build_payload(exception, payload)) + end - logger.error(structured_payload(payload)) + def log_error(exception, payload) + logger.error(build_payload(exception, payload)) end def log_and_fail(exception) - log_exception(exception, message: "Request to export #{entity.source_type} failed") + log_error(exception, message: "Request to export #{entity.source_type} failed") BulkImports::Failure.create(failure_attributes(exception)) @@ -107,7 +114,7 @@ module BulkImports end def export_url - entity.export_relations_url_path(batched: Feature.enabled?(:bulk_imports_batched_import_export)) + entity.export_relations_url_path end end end diff --git a/app/workers/ci/unlock_pipelines_in_queue_worker.rb b/app/workers/ci/unlock_pipelines_in_queue_worker.rb index de579504711..01a0dff4ca0 100644 --- a/app/workers/ci/unlock_pipelines_in_queue_worker.rb +++ b/app/workers/ci/unlock_pipelines_in_queue_worker.rb @@ -11,6 +11,7 @@ module Ci feature_category :build_artifacts idempotent! + MAX_RUNNING_EXTRA_LOW = 10 MAX_RUNNING_LOW = 50 MAX_RUNNING_MEDIUM = 500 MAX_RUNNING_HIGH = 1500 @@ -44,6 +45,8 @@ module Ci MAX_RUNNING_HIGH elsif ::Feature.enabled?(:ci_unlock_pipelines_medium, type: :ops) MAX_RUNNING_MEDIUM + elsif ::Feature.enabled?(:ci_unlock_pipelines_extra_low, type: :ops) + MAX_RUNNING_EXTRA_LOW elsif ::Feature.enabled?(:ci_unlock_pipelines, type: :ops) # This is the default enabled flag MAX_RUNNING_LOW diff --git a/app/workers/click_house/event_authors_consistency_cron_worker.rb b/app/workers/click_house/event_authors_consistency_cron_worker.rb new file mode 100644 index 00000000000..c35aadba593 --- /dev/null +++ b/app/workers/click_house/event_authors_consistency_cron_worker.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module ClickHouse + # rubocop: disable CodeReuse/ActiveRecord -- Building worker-specific ActiveRecord and ClickHouse queries + class EventAuthorsConsistencyCronWorker + include ApplicationWorker + include ClickHouseWorker + include Gitlab::ExclusiveLeaseHelpers + include Gitlab::Utils::StrongMemoize + + idempotent! + queue_namespace :cronjob + data_consistency :delayed + worker_has_external_dependencies! # the worker interacts with a ClickHouse database + feature_category :value_stream_management + + MAX_TTL = 5.minutes.to_i + MAX_RUNTIME = 150.seconds + MAX_AUTHOR_DELETIONS = 2000 + CLICK_HOUSE_BATCH_SIZE = 100_000 + POSTGRESQL_BATCH_SIZE = 2500 + + def perform + return unless enabled? + + runtime_limiter = Analytics::CycleAnalytics::RuntimeLimiter.new(MAX_RUNTIME) + + in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do + author_records_to_delete = [] + last_processed_id = 0 + iterator.each_batch(column: :author_id, of: CLICK_HOUSE_BATCH_SIZE) do |scope| + query = scope.select(Arel.sql('DISTINCT author_id')).to_sql + ids_from_click_house = connection.select(query).pluck('author_id').sort + + ids_from_click_house.each_slice(POSTGRESQL_BATCH_SIZE) do |ids| + author_records_to_delete.concat(missing_user_ids(ids)) + last_processed_id = ids.last + + to_be_deleted_size = author_records_to_delete.size + if to_be_deleted_size >= MAX_AUTHOR_DELETIONS + metadata.merge!(status: :deletion_limit_reached, deletions: to_be_deleted_size) + break + end + + if runtime_limiter.over_time? + metadata.merge!(status: :over_time, deletions: to_be_deleted_size) + break + end + end + + break if limit_was_reached? + end + + delete_records_from_click_house(author_records_to_delete) + + last_processed_id = 0 if table_fully_processed? + ClickHouse::SyncCursor.update_cursor_for(:event_authors_consistency_check, last_processed_id) + + log_extra_metadata_on_done(:result, metadata) + end + end + + private + + def metadata + @metadata ||= { status: :processed, deletions: 0 } + end + + def limit_was_reached? + metadata[:status] == :deletion_limit_reached || metadata[:status] == :over_time + end + + def table_fully_processed? + metadata[:status] == :processed + end + + def enabled? + ClickHouse::Client.database_configured?(:main) && Feature.enabled?(:event_sync_worker_for_click_house) + end + + def previous_author_id + value = ClickHouse::SyncCursor.cursor_for(:event_authors_consistency_check) + value == 0 ? nil : value + end + strong_memoize_attr :previous_author_id + + def iterator + builder = ClickHouse::QueryBuilder.new('event_authors') + ClickHouse::Iterator.new(query_builder: builder, connection: connection, min_value: previous_author_id) + end + + def connection + @connection ||= ClickHouse::Connection.new(:main) + end + + def missing_user_ids(ids) + value_list = Arel::Nodes::ValuesList.new(ids.map { |id| [id] }) + User + .from("(#{value_list.to_sql}) AS user_ids(id)") + .where('NOT EXISTS (SELECT 1 FROM users WHERE id = user_ids.id)') + .pluck(:id) + end + + def delete_records_from_click_house(ids) + query = ClickHouse::Client::Query.new( + raw_query: "ALTER TABLE events DELETE WHERE author_id IN ({author_ids:Array(UInt64)})", + placeholders: { author_ids: ids.to_json } + ) + + connection.execute(query) + + query = ClickHouse::Client::Query.new( + raw_query: "ALTER TABLE event_authors DELETE WHERE author_id IN ({author_ids:Array(UInt64)})", + placeholders: { author_ids: ids.to_json } + ) + + connection.execute(query) + end + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb index 21c10566a67..3cfd3f91a29 100644 --- a/app/workers/click_house/events_sync_worker.rb +++ b/app/workers/click_house/events_sync_worker.rb @@ -4,8 +4,6 @@ module ClickHouse class EventsSyncWorker include ApplicationWorker include ClickHouseWorker - include Gitlab::ExclusiveLeaseHelpers - include Gitlab::Utils::StrongMemoize idempotent! queue_namespace :cronjob @@ -13,138 +11,9 @@ module ClickHouse worker_has_external_dependencies! # the worker interacts with a ClickHouse database feature_category :value_stream_management - # the job is scheduled every 3 minutes and we will allow maximum 2.5 minutes runtime - MAX_TTL = 2.5.minutes.to_i - MAX_RUNTIME = 120.seconds - BATCH_SIZE = 500 - INSERT_BATCH_SIZE = 5000 - CSV_MAPPING = { - id: :id, - path: :path, - author_id: :author_id, - target_id: :target_id, - target_type: :target_type, - action: :raw_action, - created_at: :casted_created_at, - updated_at: :casted_updated_at - }.freeze - - # transforms the traversal_ids to a String: - # Example: group_id/subgroup_id/group_or_projectnamespace_id/ - PATH_COLUMN = <<~SQL - ( - CASE - WHEN project_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = (SELECT project_namespace_id FROM projects WHERE id = events.project_id LIMIT 1) LIMIT 1) - WHEN group_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = events.group_id LIMIT 1) - ELSE '' - END - ) AS path - SQL - - EVENT_PROJECTIONS = [ - :id, - PATH_COLUMN, - :author_id, - :target_id, - :target_type, - 'action AS raw_action', - 'EXTRACT(epoch FROM created_at) AS casted_created_at', - 'EXTRACT(epoch FROM updated_at) AS casted_updated_at' - ].freeze - - INSERT_EVENTS_QUERY = <<~SQL.squish - INSERT INTO events (#{CSV_MAPPING.keys.join(', ')}) - SETTINGS async_insert=1, wait_for_async_insert=1 FORMAT CSV - SQL - def perform - unless enabled? - log_extra_metadata_on_done(:result, { status: :disabled }) - - return - end - - metadata = { status: :processed } - - begin - # Prevent parallel jobs - in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do - loop { break unless next_batch } - - metadata.merge!(records_inserted: context.total_record_count, reached_end_of_table: context.no_more_records?) - - ClickHouse::SyncCursor.update_cursor_for(:events, context.last_processed_id) if context.last_processed_id - end - rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError - # Skip retrying, just let the next worker to start after a few minutes - metadata = { status: :skipped } - end - - log_extra_metadata_on_done(:result, metadata) - end - - private - - def context - @context ||= ClickHouse::RecordSyncContext.new( - last_record_id: ClickHouse::SyncCursor.cursor_for(:events), - max_records_per_batch: INSERT_BATCH_SIZE, - runtime_limiter: Analytics::CycleAnalytics::RuntimeLimiter.new(MAX_RUNTIME) - ) - end - - def last_event_id_in_postgresql - Event.maximum(:id) - end - strong_memoize_attr :last_event_id_in_postgresql - - def enabled? - ClickHouse::Client.database_configured?(:main) && Feature.enabled?(:event_sync_worker_for_click_house) - end - - def next_batch - context.new_batch! - - CsvBuilder::Gzip.new(process_batch(context), CSV_MAPPING).render do |tempfile, rows_written| - unless rows_written == 0 - ClickHouse::Client.insert_csv(INSERT_EVENTS_QUERY, File.open(tempfile.path), - :main) - end - end - - !(context.over_time? || context.no_more_records?) - end - - def process_batch(context) - Enumerator.new do |yielder| - has_more_data = false - batching_scope.each_batch(of: BATCH_SIZE) do |relation| - records = relation.select(*EVENT_PROJECTIONS).to_a - has_more_data = records.size == BATCH_SIZE - records.each do |row| - yielder << row - context.last_processed_id = row.id - - break if context.record_limit_reached? - end - - break if context.over_time? || context.record_limit_reached? || !has_more_data - end - - context.no_more_records! unless has_more_data - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def batching_scope - return Event.none unless last_event_id_in_postgresql - - table = Event.arel_table - - Event - .where(table[:id].gt(context.last_record_id)) - .where(table[:id].lteq(last_event_id_in_postgresql)) + result = ::ClickHouse::SyncStrategies::EventSyncStrategy.new.execute + log_extra_metadata_on_done(:result, result) end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index 15156e1deef..da0a54c79f8 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -53,14 +53,16 @@ module Gitlab importer_class.new(object, project, client).execute - if increment_object_counter?(object) - Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported) - end + increment_object_counter(object, project) if increment_object_counter?(object) info(project.id, message: 'importer finished') rescue ActiveRecord::RecordInvalid, NotRetriableError, NoMethodError => e # We do not raise exception to prevent job retry track_exception(project, e) + rescue UserFinder::FailedToObtainLockError + warn(project.id, message: 'Failed to obtaing lock for user finder. Retrying later.') + + raise rescue StandardError => e track_and_raise_exception(project, e) end @@ -69,6 +71,10 @@ module Gitlab true end + def increment_object_counter(_object, project) + Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported) + end + def object_type raise NotImplementedError end @@ -92,6 +98,10 @@ module Gitlab Logger.info(log_attributes(project_id, extra)) end + def warn(project_id, extra = {}) + Logger.warn(log_attributes(project_id, extra)) + end + def log_attributes(project_id, extra = {}) extra.merge( project_id: project_id, diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb index 5aabc74a3d5..98fea6c1ab7 100644 --- a/app/workers/concerns/gitlab/github_import/queue.rb +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -8,12 +8,6 @@ module Gitlab included do queue_namespace :github_importer feature_category :importers - - # If a job produces an error it may block a stage from advancing - # forever. To prevent this from happening we prevent jobs from going to - # the dead queue. This does mean some resources may not be imported, but - # this is better than a project being stuck in the "import" state - # forever. sidekiq_options dead: false end end diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index e2808f45821..61c5aff6592 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -38,7 +38,7 @@ module Gitlab def try_import(...) import(...) true - rescue RateLimitError + rescue RateLimitError, UserFinder::FailedToObtainLockError false end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 5f6812ab84f..69cf6f424af 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -50,7 +50,7 @@ module Gitlab def perform(project_id) info(project_id, message: 'starting stage') - return unless (project = find_project(project_id)) + return unless (project = Project.find_by_id(project_id)) if project.import_state&.completed? info( @@ -62,11 +62,11 @@ module Gitlab return end + RefreshImportJidWorker.perform_in_the_future(project.id, jid) + client = GithubImport.new_client_for(project) try_import(client, project) - - info(project_id, message: 'stage finished') rescue StandardError => e Gitlab::Import::ImportFailureService.track( project_id: project_id, @@ -79,25 +79,19 @@ module Gitlab raise(e) end + private + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def try_import(client, project) - RefreshImportJidWorker.perform_in_the_future(project.id, jid) - import(client, project) - rescue RateLimitError - self.class.perform_in(client.rate_limit_resets_in, project.id) - end - def find_project(id) - # If the project has been marked as failed we want to bail out - # automatically. - # rubocop: disable CodeReuse/ActiveRecord - Project.joins_import_state.where(import_state: { status: :started }).find_by_id(id) - # rubocop: enable CodeReuse/ActiveRecord - end + info(project.id, message: 'stage finished') + rescue RateLimitError, UserFinder::FailedToObtainLockError => e + info(project.id, message: "stage retrying", exception_class: e.class.name) - private + self.class.perform_in(client.rate_limit_resets_in, project.id) + end def info(project_id, extra = {}) Gitlab::GithubImport::Logger.info(log_attributes(project_id, extra)) diff --git a/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb b/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb index dd18139fc9e..4c323a11755 100644 --- a/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb +++ b/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb @@ -3,9 +3,11 @@ module Gitlab module BitbucketServerImport module Stage - class ImportUsersWorker # rubocop:disable Scalability/IdempotentWorker -- ImportPullRequestsWorker is not idempotent + class ImportUsersWorker include StageMethods + idempotent! + private def import(project) diff --git a/app/workers/gitlab/github_gists_import/start_import_worker.rb b/app/workers/gitlab/github_gists_import/start_import_worker.rb index f7d3eb1d759..d6c637f6d49 100644 --- a/app/workers/gitlab/github_gists_import/start_import_worker.rb +++ b/app/workers/gitlab/github_gists_import/start_import_worker.rb @@ -17,7 +17,7 @@ module Gitlab Gitlab::GithubGistsImport::Status.new(msg['args'][0]).fail! user = User.find(msg['args'][0]) - Gitlab::GithubImport::PageCounter.new(user, :gists, 'github-gists-importer').expire! + Gitlab::Import::PageCounter.new(user, :gists, 'github-gists-importer').expire! end def perform(user_id, encrypted_token) diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 417b8598547..8de9850298b 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -14,22 +14,24 @@ module Gitlab include ::Gitlab::Import::AdvanceStage loggable_arguments 1, 2 - sidekiq_options retry: 6 - - # TODO: Allow this class to include GithubImport::Queue and remove - # the following two lines https://gitlab.com/gitlab-org/gitlab/-/issues/435622 + sidekiq_options retry: 6, dead: false feature_category :importers - sidekiq_options dead: false # The known importer stages and their corresponding Sidekiq workers. + # + # Note: AdvanceStageWorker is not used for the repository, base_data, and pull_requests stages. + # They are included in the list for us to easily see all stage workers and the order in which they are executed. STAGES = { + repository: Stage::ImportRepositoryWorker, + base_data: Stage::ImportBaseDataWorker, + pull_requests: Stage::ImportPullRequestsWorker, collaborators: Stage::ImportCollaboratorsWorker, - pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker, - pull_request_review_requests: Stage::ImportPullRequestsReviewRequestsWorker, - pull_request_reviews: Stage::ImportPullRequestsReviewsWorker, + pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker, # Skipped on extended_events + pull_request_review_requests: Stage::ImportPullRequestsReviewRequestsWorker, # Skipped on extended_events + pull_request_reviews: Stage::ImportPullRequestsReviewsWorker, # Skipped on extended_events issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker, issue_events: Stage::ImportIssueEventsWorker, - notes: Stage::ImportNotesWorker, + notes: Stage::ImportNotesWorker, # Skipped on extended_events attachments: Stage::ImportAttachmentsWorker, protected_branches: Stage::ImportProtectedBranchesWorker, lfs_objects: Stage::ImportLfsObjectsWorker, diff --git a/app/workers/gitlab/github_import/import_issue_event_worker.rb b/app/workers/gitlab/github_import/import_issue_event_worker.rb index d7071d3ee09..f5e88787a77 100644 --- a/app/workers/gitlab/github_import/import_issue_event_worker.rb +++ b/app/workers/gitlab/github_import/import_issue_event_worker.rb @@ -16,6 +16,16 @@ module Gitlab def object_type :issue_event end + + def increment_object_counter(object, project) + counter_type = importer_class::EVENT_COUNTER_MAP[object[:event]] if import_settings.extended_events? + counter_type ||= object_type + Gitlab::GithubImport::ObjectCounter.increment(project, counter_type, :imported) + end + + def import_settings + @import_settings ||= Gitlab::GithubImport::Settings.new(project) + end end end end diff --git a/app/workers/gitlab/github_import/replay_events_worker.rb b/app/workers/gitlab/github_import/replay_events_worker.rb new file mode 100644 index 00000000000..680d5ec2d7d --- /dev/null +++ b/app/workers/gitlab/github_import/replay_events_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ReplayEventsWorker + include ObjectImporter + + idempotent! + + def representation_class + Representation::ReplayEvent + end + + def importer_class + Importer::ReplayEventsImporter + end + + def object_type + :replay_event + end + + def increment_object_counter?(_object) + false + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb index b5b1601e3ed..38e1fd52889 100644 --- a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb @@ -42,9 +42,15 @@ module Gitlab def move_to_next_stage(project, waiters = {}) AdvanceStageWorker.perform_async( - project.id, waiters.deep_stringify_keys, 'pull_requests_merged_by' + project.id, waiters.deep_stringify_keys, next_stage(project) ) end + + def next_stage(project) + return 'issues_and_diff_notes' if import_settings(project).extended_events? + + 'pull_requests_merged_by' + end end end end diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb index 27d14a1a108..9618500604a 100644 --- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -15,7 +15,7 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) - return skip_to_next_stage(project) if import_settings(project).disabled?(:single_endpoint_issue_events_import) + return skip_to_next_stage(project) if skip_to_next_stage?(project) importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter info(project.id, message: "starting importer", importer: importer.name) @@ -25,13 +25,26 @@ module Gitlab private + def skip_to_next_stage?(project) + # This stage is mandatory when using extended_events + return false if import_settings(project).extended_events? + + import_settings(project).disabled?(:single_endpoint_issue_events_import) + end + def skip_to_next_stage(project) info(project.id, message: "skipping importer", importer: "IssueEventsImporter") move_to_next_stage(project) end def move_to_next_stage(project, waiters = {}) - AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, 'notes') + AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, next_stage(project)) + end + + def next_stage(project) + return "attachments" if import_settings(project).extended_events? + + "notes" end end end diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb index 34c31fea726..3f57b958418 100644 --- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -15,16 +15,8 @@ module Gitlab # https://gitlab.com/gitlab-org/gitlab/-/blob/eabf0800/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb#L69-71 resumes_work_when_interrupted! - def perform(project_id) - return unless (project = find_project(project_id)) - - import(project) - end - # project - An instance of Project. - def import(project) - info(project.id, message: "starting importer", importer: 'Importer::LfsObjectsImporter') - + def import(_client, project) waiter = Importer::LfsObjectsImporter .new(project, nil) .execute diff --git a/app/workers/google_cloud/create_cloudsql_instance_worker.rb b/app/workers/google_cloud/create_cloudsql_instance_worker.rb index 8c4f4c83339..e0d0747e227 100644 --- a/app/workers/google_cloud/create_cloudsql_instance_worker.rb +++ b/app/workers/google_cloud/create_cloudsql_instance_worker.rb @@ -13,7 +13,7 @@ module GoogleCloud project = Project.find(project_id) params = params.with_indifferent_access - response = ::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute + response = ::CloudSeed::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute if response[:status] == :error raise "Error SetupCloudsqlInstanceService: #{response.to_json}" diff --git a/app/workers/google_cloud/fetch_google_ip_list_worker.rb b/app/workers/google_cloud/fetch_google_ip_list_worker.rb index b14b4e735dc..de725709bea 100644 --- a/app/workers/google_cloud/fetch_google_ip_list_worker.rb +++ b/app/workers/google_cloud/fetch_google_ip_list_worker.rb @@ -11,7 +11,7 @@ module GoogleCloud idempotent! def perform - GoogleCloud::FetchGoogleIpListService.new.execute + CloudSeed::GoogleCloud::FetchGoogleIpListService.new.execute end end end diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 0e7f11debd2..80f9e922456 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -15,13 +15,14 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker attr_reader :issuable_class - def perform(issue_id, user_id, issuable_class = 'Issue') + # TODO: Add skip_notifications argument to the invocations of the worker in the next release (16.9) + def perform(issue_id, user_id, issuable_class = 'Issue', skip_notifications = false) @issuable_class = issuable_class.constantize return unless objects_found?(issue_id, user_id) ::EventCreateService.new.open_issue(issuable, user) - ::NotificationService.new.new_issue(issuable, user) + ::NotificationService.new.new_issue(issuable, user) unless skip_notifications issuable.create_cross_references!(user) diff --git a/app/workers/releases/publish_event_worker.rb b/app/workers/releases/publish_event_worker.rb new file mode 100644 index 00000000000..8bcc580dceb --- /dev/null +++ b/app/workers/releases/publish_event_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Releases + class PublishEventWorker + include ApplicationWorker + include CronjobQueue + + idempotent! + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency -- usual for EventStore jobs. + feature_category :release_orchestration + + def perform + releases_published = 0 + + Release.waiting_for_publish_event.each_batch(of: 100) do |releases| + releases.each do |release| + with_context(project: release.project) do + ::Gitlab::EventStore.publish( + ::Projects::ReleasePublishedEvent.new(data: { release_id: release.id }) + ) + + releases_published += 1 + end + end + + releases.touch_all(:release_published_at) + end + + log_extra_metadata_on_done(:releases_published, releases_published) if releases_published > 0 + end + end +end |